@aiquants/markdown-explorer
v0.5.0
Published
Markdown document explorer for React Router 8 (framework mode): a source tree with a single-document view and a multi-panel reading view, storage-agnostic server ports, golden-ratio layout and an en / ja UI.
Downloads
816
Readme
@aiquants/markdown-explorer
A Markdown document explorer for React Router 8 (framework mode): a source tree beside the document you are reading, a multi-panel view for reading several documents side by side, and a full-page view for reading or embedding one document. Storage, authentication and Markdown parsing are yours — the package talks to them through small server ports, so the same explorer browses a folder on disk, a cloud bucket or a document store.
- Three views, one URL contract — the tree view, the multi-panel view and the single view are addressed by plain URLs (
/docs/tree/<document>,/docs/multi?doc=a&doc=b,/docs/single/<document>).explorerHrefwrites only canonical URLs (it refuses a target that would be redirected or partly ignored), and a non-canonical URL is redirected to its canonical form on the server and in the browser alike. - Golden-ratio layout — the explorer takes 1/φ³ of the width in the tree view (the document gets 2φ times the explorer) and 1/φ⁴ in the multi-panel view, may grow to the golden section 1/φ² and never below 13rem (it follows the reader's root font size); on a frame too narrow for that, such as a phone, the explorer opens as a sheet over the content from a labelled toggle; spacing and durations are Fibonacci numbers; multi-view columns never get narrower than the 40-character multi-column reading measure, so text is never scaled down.
- Streaming first paint — the page loader streams the tree, the open documents and the notices; after that the browser reads through a JSON data route with a stale-while-revalidate cache (a document opened again within 55 s is used as it is; an older one is shown at once and revalidated in the background), so switching documents never reloads the tree. A tree row or document link that the pointer or the keyboard focus rests on for 89 ms is prefetched, one request at a time (a target the reader has left by then is skipped). The explorer's Refresh tree button (a visible label, also its accessible name) has the server reload the tree and answers it, then reads the documents again past the host's caches: shown ones in the background (replaced only when they changed), shown failures at once, a shown one still loading as soon as its request settles, the others when they are next opened. A tree that loads meanwhile never discards the reader's refresh; the newer of the two stays. A reloaded tree is announced; a failed reload keeps the tree under an alert naming the failure. Every view also reads one shown document alone again with Refresh document, without listing the tree again: a labelled button in a thin bar above the document in the tree and single views, and an icon-only button named after the document in a bar pinned to the top of each multi-view panel's scroll area, reachable from anywhere in the document. The document stays on screen while it reloads and is replaced in place, keeping its scroll position; a reloaded document is announced, and a failed reload keeps the document under a notice naming the failure, which is announced too and stays until a newer copy of the document replaces or confirms it, the reader moves to another document, or the next refresh starts. A single-view (embedding) page never downloads the tree and multi-panel views, which load on demand.
- Embeds and link cards — with
embedsin the configuration, a link alone in a paragraph shows its post or video from up to ten providers (YouTube, X, Instagram, Threads, TikTok, Bluesky, LinkedIn, Reddit, note and niconico) in a cross-origin frame, with no provider script in the page, or a link card the explorer's own data route reads ({dataPath}/link-card, through@aiquants/markdown's link-card handler: internal addresses never become cards); the link itself always stays, under the frame, as the card or as the paragraph. - Secure by default — Markdown HTML is sanitized on the server with an allow-list (scripts, event handlers,
srcdocand foreign iframes never reach the browser), and the document viewer applies its own on every render (a document keeps only the converter's class tokens, and ids only on headings, footnotes anduser-content-…targets); document paths are validated, and deny rules hide environment files, keys and version-control metadata through up to three percent-decoding levels; tokens, principals, server paths and raw error messages never leave the server. - Multi-panel reading — open documents as panels, rearrange them by dragging (on a touch screen or with a pen, after a long-press on a panel's header), with the move buttons in each panel's header, or with Alt + Shift + arrow keys, hide, maximize (Escape restores), and navigate inside a panel; the arrangement survives reloads, and a document reopened later returns to its column.
- Accessible and localized — named landmarks and controls, a keyboard-operable tree with one Tab stop on the shown document, keyboard-operable menus, panel headers and resize handle, polite announcements (spoken from the sheet while it is open), WCAG 2.2 contrast in light and dark; English (default) and Japanese built in — the tree's and the panel headers' strings included —, every string overridable.
Requirements
- React 19.2.7 or later and React Router 8 in framework mode (route modules with
loader/headers). - Node.js 22.22 or later (the floor React Router 8 declares). The package ships ES modules and CommonJS; the CommonJS entries load the ESM-only
react-routerthroughrequire(esm). - The peer packages, at these versions or later within the caret ranges the package declares:
@aiquants/markdown^6.2.0 (document rendering; its math diagnostics, gantt sizing, embeds, link cards and their sizes, the React-free@aiquants/markdown/embedsthe sanitizer worker loads, and the Node-only link-card handler of@aiquants/markdown/server, which needs Node.js 22.13 or later — below the explorer's own floor),@aiquants/directory-tree^4.1.1 (reveals of rows under the scroll bar's buttons),@aiquants/drag-drop-panels^0.10.0,@aiquants/resize-panels^2.1.0 (the source of each layout change, by which the tree pane's width is saved) and@aiquants/virtualscroll^3.11.4. The client depends on these floors, while a package manager may only warn about an older peer. - A Markdown parser of your choice on the server that produces HTML and its headings (the explorer sanitizes the HTML before it is sent).
- Browsers: the multi view's panel-limit notice uses the Popover API (Chrome and Edge 114, Firefox 125, Safari 17 and later). In browsers without it (for example iOS 16), the notice is drawn in place at the stage's corner instead of in the top layer (while the sheet is open it spans the sheet's header, as in every browser).
That notice wraps around the sheet's close button with a logical float (
float: inline-end, Chrome and Edge 118 and later); in browsers without logical floats (before Chrome and Edge 118, the Chromium versions without the Popover API included) a long notice runs partly under the close button.
Installation
pnpm add @aiquants/markdown-explorer @aiquants/markdown @aiquants/directory-tree @aiquants/drag-drop-panels @aiquants/resize-panels @aiquants/virtualscrollThe package has three entries and one stylesheet: @aiquants/markdown-explorer (the browser half: <MarkdownExplorer>, the shared configuration and links), @aiquants/markdown-explorer/server (the server half), @aiquants/markdown-explorer/storage (clearExplorerStorage only, with no dependencies) and @aiquants/markdown-explorer/styles/markdown-explorer.css.
Quick start
The example mounts the explorer at /docs with its data route at /docs/_data, reads Markdown files from a folder, and needs no sign-in.
1. Share one configuration between the server and the browser
// app/explorer.config.ts
import { defineExplorerConfig } from "@aiquants/markdown-explorer"
export const explorerConfig = defineExplorerConfig({
basePath: "/docs",
dataPath: "/docs/_data",
settingsCookie: "docs-display",
})defineExplorerConfig validates everything once and returns a frozen value: mount paths, view slugs (tree, multi, single by default), the multi-view limits (maxPanels 12, columns 4), Expand all's budget per press (maxFolders 500, maxDepth 16), the deepest document the reader's reveal opens folders for (maxDepth 16), the document styles, the iframe host allow-list, the embeds and link cards and the reader's default tree settings. A mistake throws at startup instead of producing links that point nowhere.
The source ids are not part of it: they are the ids of the server's sources (a runtime setting of the server), and the browser learns them from the page data. withExplorerSources(config, sourceIds) joins them to the configuration where the URL codec needs them (see Views and URLs).
| Option | Type | Purpose |
| --- | --- | --- |
| basePath | string | Page mount, with a leading slash and no trailing slash (for example /docs). |
| dataPath | string | Data route mount in the same form; it differs from basePath and does not start with a view slug under it. |
| settingsCookie | string | Name of the display-preference cookie (an RFC 6265 token). |
| views? | { tree?, multi?, single? } | URL slugs of the views; each defaults to its view's name. |
| multiView? | { maxPanels?, columns? } | The panel limit (1–64, default 12) and the logical column count (1–6, default 4). |
| expandAll? | { maxFolders?, maxDepth? } | Expand all's budget per press (see Expand all and Collapse all): the folder listings a press may start (1–10,000, default 500, EXPAND_ALL_FOLDER_LIMITS) and the deepest level (the top being 1) at which it opens a folder of unknown contents (1–64, default 16, EXPAND_ALL_DEPTH_LIMITS). Each field defaults on its own, also when given as undefined; a value that is not an integer within its bounds or an unknown key throws a RangeError naming the field, and anything but a plain object a TypeError. |
| revealDocument? | { maxDepth? } | The deepest document whose folders the reader's "Reveal the open document" setting opens, counted in folders between the source's top level and the document (1–64, default 16, REVEAL_DOCUMENT_DEPTH_LIMITS); a deeper document is not revealed. Each folder of the chain takes at most one listing, so this also bounds a reveal's listings. The field defaults on its own, also when given as undefined; an unknown key throws a RangeError, anything but a plain object a TypeError (see Revealing the open document). |
| documentStyles? | readonly { name, className }[] | Document styles, the first being the default; @aiquants/markdown's DEFAULT_MARKDOWN_STYLES (None, GitHub, Zenn; also exported without React from @aiquants/markdown/viewer-settings) when omitted. |
| iframeHosts? | readonly string[] | Host names whose https: iframes written in documents may load (exact match); none when omitted. They gate only iframes written in documents; provider frames come from embeds, and no iframe of the page's own origin ever renders, whatever is listed. |
| embeds? | { providers?, linkCards?, linkCardSize? } | What @aiquants/markdown makes of a link alone in a paragraph. providers lists the ids (EMBED_PROVIDER_IDS of @aiquants/markdown/embeds: youtube, x, instagram, threads, tiktok, bluesky, linkedin, reddit, note, niconico) whose posts and videos documents embed as cross-origin frames, whatever the link's text. linkCards: true turns a link written as its own URL into a card, which the data route's own link-card operation serves (see Link cards): no route of yours is needed. linkCardSize picks the size of every card, compact, banner or full (LINK_CARD_SIZES of @aiquants/markdown/embeds; @aiquants/markdown's banner when omitted), and needs linkCards: true. Each field defaults to none ([], false, omitted), also when given as undefined, so without the option such a link stays its paragraph. Repeated ids are removed. An unknown id, a linkCards that is not a boolean, an unknown size, a size without linkCards: true or an unknown key (linkCardEndpoint included) throws a RangeError ([markdown-explorer] embeds…), and anything but a plain object a TypeError. The resolved config.embeds is the MarkdownEmbeds the viewer takes: linkCardEndpoint is <dataPath>/link-card when link cards are on and null otherwise, and linkCardSize is present only when given. See @aiquants/markdown's README (Embeds) for the URLs each provider recognizes, the frames' sizes and the cards' sizes. |
| defaultTreeSettings? | Partial<TreeSettings> | The reader's tree settings before they change any, merged over the package's DEFAULT_TREE_SETTINGS (the source's own order, folders mixed among the files, Single selection, a recursive double click, 8 px expand icons, nothing kept expanded, the open document not revealed, the root indent kept, lines, expand icons and folder and file icons on). Each value is one the settings menu offers: sortMode "native", "asc" or "desc"; folderPlacement "mixed", "first" or "last"; selectionMode "single", "multiple" or "none"; doubleClickAction "recursive" or "toggle"; expandIconSize 8, 13 or 5; the other settings (alwaysExpanded, revealDocument, removeRootIndent, lines, expandIcons, directoryIcons, fileIcons) true or false. An unknown key or any other value throws a RangeError, and anything but a plain object (a Map, a class instance, an object inheriting its settings) throws a TypeError; a setting given as undefined keeps the package default. The server render and the hydration draw these defaults (see Tree settings). |
For example, defaultTreeSettings: { sortMode: "desc" } lists every folder's entries by name, Z to A, until the reader picks another order.
2. Create the server half
// app/explorer.server.ts
import { resolve } from "node:path"
import { anonymousAccess, createFileSystemSource, createMarkdownExplorer } from "@aiquants/markdown-explorer/server"
import { explorerConfig } from "./explorer.config"
import { renderMarkdown } from "./markdown.server" // your parser: async (markdown, path) => ({ htmlContent, headings }); it resolves links and images (docs/behavior/url-contract.md#document-images)
const contentRoot = process.env.HANDBOOK_ROOT
if (contentRoot === undefined || contentRoot === "") throw new Error("HANDBOOK_ROOT must name the handbook folder")
// At least 32 characters, and the same on every server instance
const scopeSecret = process.env.EXPLORER_SCOPE_SECRET
if (scopeSecret === undefined) throw new Error("EXPLORER_SCOPE_SECRET must hold the explorer's scope secret")
const handbook = createFileSystemSource({
id: "handbook",
label: "Handbook",
root: resolve(contentRoot),
pathPrefix: "", // required: "" publishes the whole root
parse: renderMarkdown,
})
export const explorer = createMarkdownExplorer({
config: explorerConfig,
...anonymousAccess,
scopeSecret,
sources: [handbook],
document: handbook.document,
asset: handbook.asset,
})scopeSecret is required. The browser identifies its reader by a scope, an HMAC of principalKey under this secret: a pseudonym nobody without the secret can link to a principal. Take it from configuration, with at least 32 characters (a shorter one throws a RangeError when the explorer is created) and the same value on every server instance; otherwise a reader's scope changes between instances and the browser drops its caches.
Without extra routes, the handbook's images, videos, audio and PDF files open in place, text files show their beginning with a download, and any other file opens as a download view: the explorer serves the bytes itself through its data route's asset operation, which reads them from the source's asset port (see Serving files).
3. Register the routes
// app/routes.ts
import { type RouteConfig, route } from "@react-router/dev/routes"
export default [
route("docs", "routes/docs.tsx", { id: "docs" }),
route("docs/*", "routes/docs.tsx", { id: "docs-splat" }),
route("docs/_data/*", "routes/docs-data.ts"),
] satisfies RouteConfigThe base route (docs) must be registered as well as the splat: a client navigation to the bare base path then still runs the loader, which redirects it to the tree view.
4. Write the two route modules
// app/routes/docs.tsx
import { MarkdownExplorer, shouldRevalidateExplorer } from "@aiquants/markdown-explorer"
import { isRouteErrorResponse, useLoaderData, useRouteError } from "react-router"
import { explorerConfig } from "../explorer.config"
import { explorer } from "../explorer.server"
export const loader = explorer.loader
export const headers = explorer.headers
export const shouldRevalidate = shouldRevalidateExplorer
export default function Docs() {
return (
<div className="docs-page">
<header>Handbook</header>
<main className="docs-explorer">
<MarkdownExplorer data={useLoaderData<typeof loader>()} config={explorerConfig} storageNamespace="docs" lockDocumentScroll />
</main>
</div>
)
}
export function ErrorBoundary() {
const error = useRouteError()
return <p>{isRouteErrorResponse(error) && error.status === 403 ? "You cannot open these documents." : "Something went wrong."}</p>
}// app/routes/docs-data.ts
import { explorer } from "../explorer.server"
export const loader = explorer.dataLoader
export const action = explorer.dataAction- Take the content folder from configuration (an environment variable here), not from
import.meta.url: afterreact-router buildthe server module runs frombuild/server/index.js, so a path relative to the module would point intobuild/. - The page route needs its own
ErrorBoundary: React Router stops carryingheadersat the boundary that renders an error, so without one the default 403 answer loses itsCache-Control: private, no-store. - The data route module must not have a default export; with one it becomes a UI route and the JSON answers are rendered as a page.
- The explorer adds no
mainlandmark in any view (its own landmarks — the explorernav, the document region, the multi view's columns — sit inside yours), so the page places it in its own<main>.
5. Load the styles
The explorer does not import CSS from JavaScript. Load its stylesheet with those of its peers, in this order, from the root route's links:
// app/root.tsx (added to the root module React Router generated)
import resizePanels from "@aiquants/resize-panels/styles/resize-panels.standalone.css?url"
import dragDropPanels from "@aiquants/drag-drop-panels/styles/drag-drop-panels.standalone.css?url"
import virtualscroll from "@aiquants/virtualscroll/styles/virtualscroll.standalone.css?url"
import directoryTree from "@aiquants/directory-tree/styles/directory-tree.standalone.css?url"
import katex from "@aiquants/markdown/styles/katex.min.css?url"
import markdown from "@aiquants/markdown/styles/markdown.css?url"
import githubMarkdown from "@aiquants/markdown/styles/github-markdown.css?url"
import zennContent from "@aiquants/markdown/styles/zenn-content.css?url"
import message from "@aiquants/markdown/styles/message.css?url"
import details from "@aiquants/markdown/styles/details.css?url"
import markdownExplorer from "@aiquants/markdown-explorer/styles/markdown-explorer.css?url"
import app from "./app.css?url"
export const links = () => [resizePanels, dragDropPanels, virtualscroll, directoryTree, katex, markdown, githubMarkdown, zennContent, message, details, markdownExplorer, app].map((href) => ({ rel: "stylesheet", href }))Your own stylesheet gives the explorer the definite block size it fills (see Sizing):
/* app/app.css */
html,
body {
height: 100%;
margin: 0;
}
.docs-page {
display: flex;
flex-direction: column;
height: 100%;
}
.docs-explorer {
flex: 1;
min-height: 0;
}htmlandbodytake the viewport's height (React Router's default root renders the page straight intobody; give any element your own root puts between themheight: 100%as well), andmargin: 0keeps the page from ending below the viewport. The page is a flex column of that height, and the explorer's wrapper takes what the header leaves (flex: 1) and may be shorter than its content (min-height: 0).- The
.standalone.cssbuilds suit hosts without Tailwind CSS. A Tailwind host loads the plain builds (resize-panels.css,@aiquants/drag-drop-panels/css,virtualscroll.css,directory-tree.css) and lets Tailwind scan the peers'distwith@source. - A Tailwind host must fix the cascade-layer order before any package stylesheet names a layer: load its own stylesheet (whose
@import "tailwindcss"declarestheme, base, components, utilities) first, or@importthe package stylesheets from it after@import "tailwindcss". Otherwise the first package sheet putscomponentsbeforebase, and Tailwind's preflight resets the explorer's (and the peers') spacing and borders.markdown-explorer.cssitself declarestheme,baseandcomponentsin that order before any rule, which fixes the order whenever it is the first sheet to name a layer. github-markdown.cssandzenn-content.cssback the default document styles, andgithub-markdown.cssalso draws GitHub alerts (.markdown-alert);message.cssanddetails.cssdraw Zenn's message and details containers (aside.message,details.markdown-details) under every document style; load these two when your converter writes that markup (see Documents).markdown.cssalso gives GitHub alerts a baseline under the other document styles (None, Zenn).@aiquants/markdown/styles/katex.min.cssis KaTeX's own stylesheet, shipped with the fonts it points to; load it when your documents contain math.- Every explorer rule sits in
@layer components, so your own unlayered rules (or a Tailwind host's utilities) override it — except a few declarations that are!importantinside the layer, which no unlayered rule of yours can override: the document viewer's transparent body background (so the region's--aqmx-surfaceshows through), the outward focus-ring offset of a document's links inside superscripts and subscripts (footnote references:github-markdown.css, whose.markdown-bodythe viewer puts around the content of every document style, draws a link's ring 2 px inside its box, which on a footnote reference's one-glyph box paints over the glyph), the zero transitions of the resize handle and of the tree (whose fade@aiquants/directory-treesets on the tree's root element) underprefers-reduced-motion: reduce, and, under forced colors, the resize handle's focus ring and the text and icon colour inside highlighted tree rows and checked tree settings. This is the complete list. Change the rings through the--aqmx-*tokens they read (--aqmx-focus-ring-width,--aqmx-focus-ring-offset); the transitions and the forced-colors system colours read no token. - The multi-panel view styles
@aiquants/drag-drop-panels' chrome only through that package's neutral custom properties, bound with ordinary declarations on the stage to the explorer's tokens: the stage's block and inline padding and the column gap to--aqmx-space-7and--aqmx-space-6; the panels' surface and border (and those of the bar of hidden panels, an empty column and a hidden panel's stand-in during a drag) to--aqmx-surfaceand--aqmx-border; the panel titles (and the text of those parts) to--aqmx-text; the header buttons' icons to--aqmx-text-muted, and on hover to--aqmx-textover--aqmx-surface-sunken; the custom drag mode's badge (and the pressed drag-mode toggle) to--aqmx-warning-texton--aqmx-warning-surface; the restore buttons of the bar of hidden panels to--aqmx-surfaceon--aqmx-accent(--aqmx-info-textwhile hovered); and the drop placeholders of a drag (text, icon and dashed border) to--aqmx-accenton--aqmx-surface, the placeholder a drop would land on to--aqmx-accent-surface. So the panel chrome follows the explorer'stheme, not adarkclass of the page, and you restyle it by changing those tokens on the explorer (or by setting an--aqdd-*property yourself on an element inside the stage). - A maximized multi-view panel covers the viewport inside
--aqdd-maximize-top,--aqdd-maximize-right,--aqdd-maximize-bottomand--aqdd-maximize-left(all0pxby default); bind them in your layout to keep your own header or menu visible. Everything of the explorer it covers (the explorer and its handle or the sheet's bar, and the other panels) is inert until the panel is restored. Your own chrome is not: whatever of it the panel covers stays in the Tab order and in the accessibility tree, so either bind the variables so the panel leaves it uncovered, or make it inert (orvisibility: hidden) while a panel is maximized — the maximized panel's frame carriesdata-aqmx-maximized="true"(:has([data-aqmx-panel-frame][data-aqmx-maximized="true"])), and the URL carriesmaximized.
Views and URLs
| View | URL | What it shows |
| --- | --- | --- |
| Tree | {basePath}/{tree} or {basePath}/{tree}/{document} | The source tree beside the selected document |
| Multi-panel | {basePath}/{multi}?doc=…&doc=…&maximized=… | Several documents as panels in columns |
| Single | {basePath}/{single}/{document} | One document over the whole frame (for reading and embedding) |
?source=<id>selects the tree's source among the server's sources; the first source is the default and is written by omission. Switching the source never closes open documents — a document path names one document whichever trees list it.- A document path occupies one URL segment. Build links with
explorerHref(config, target), the only function that writes explorer URLs; read them back withresolveExplorerLocation(config, splat, search). Both need the server's source ids for thesourceparameter: pass a configuration joined with them,withExplorerSources(config, sourceIds)(ExplorerLocationConfig;isExplorerSourceIdis the id rule, and an empty, invalid or repeated id throws aRangeError).explorerHrefalso accepts a plainExplorerConfigwith a target withoutsource(ExplorerSourcelessHrefTarget, the default source), such as a host menu's link; a target with asourceand a configuration without ids throws aTypeErrornamingwithExplorerSources, as doesresolveExplorerLocationwith such a configuration.explorerHrefthrows aRangeErrorfor a target the resolver would redirect or partly ignore — repeated or more thanmultiView.maxPanelsdocuments, a maximized document that is not open, an unknown source or document style, an unsupported alignment or embedding value — so every link it writes resolves back to the location given. - The single view accepts embedding parameters:
showToc=false,showStyle=false,showAlign=false,padding(0–256 px, written in decimal digits),widthandmaxWidth(a CSS length in px, rem, em, ch, vw or %; the column never grows wider than the pane, whatever the value). The tree and single views acceptstyleandalignas display overrides. - History: a click in the tree replaces the URL's document; links inside a document, entries of its table of contents (in the tree and single views, to the heading's hash), source switches and view switches push; every multi-view action replaces (hiding and showing a panel leave the URL unchanged).
- An entry of a document's table of contents links to its heading's own address: the page's URL with the heading's fragment, or, in a multi-view panel, the panel's document in the tree view with that fragment, so a modified or middle click, a new tab or a copied address opens the heading (see URL contract).
The full contract, including canonicalization, is in URL contract.
Server API
createMarkdownExplorer(config)
| Option | Type | Purpose |
| --- | --- | --- |
| config | ExplorerConfig | The shared configuration; the source ids come from sources. |
| authenticate | (request, mode) => Promise<ExplorerAuthentication<P>> | signed-in with a principal, signed-out, or forbidden; headers (for example a refreshed session cookie) are added to every response. mode is an ExplorerAuthenticationMode, { readOnly: boolean }: the page loader and every data operation but link-card pass { readOnly: false }; the link-card operation passes { readOnly: true }, and then the host only reads the session — it never refreshes a token, writes no cookie and returns no headers — because cards ask in the background and a refreshed cookie arriving after a sign-out would sign the reader back in. The explorer fails closed: a read-only authentication that carries any header is logged and answered as a failure (see Link cards). |
| principalKey | (principal) => string | A stable identity of the principal (it must not change when a token refreshes); used to isolate caches and, through an HMAC under scopeSecret, as the browser's pseudonymous scope. |
| scopeSecret | string | The deployment's secret for the readers' scopes: at least 32 characters (a shorter one throws a RangeError) and the same on every server instance. |
| sources | ExplorerSource<P>[] | The sources in switcher order (at least one; the first is the default): id (lowercase letters, digits and -, starting with a letter or digit, unique; the URL's source value), label (a string, or a function of the principal), boundary (where the source's documents may lie, see Source boundaries), tree(context) (lists the source's roots), children?(context, path, readPath) for lazy folders (asked only while the tree shows the folder open — the reader opened it, Expand all opened it (a press keeps opening folders as their listings land, within expandAll), or a remembered expansion opened it — and only for a path inside the source's boundary; a listing no tree wants any more is aborted through context.signal, which the source should honour), ancestors?(context, path, readPath, options) (the folders down to a document, for the reader's reveal; see Revealing the open document), cacheScope? ("principal", the default, or "shared"). The context is described under Entries and notices. |
| document | (context, path, readPath) => Promise<ExplorerHostDocument> | Resolves a document path. Called only for a path inside at least one source's boundary; readPath is what the admitting source's confirm answered (path when that source has no confirm), so read readPath and show the document as path. An image, media, text or file document reports its file's version; the explorer writes its URL (see Documents). |
| asset | (context, path, readPath) => Promise<ExplorerAssetFile> | Finds the file behind the data route's asset operation: any regular file the document admission admits, whatever its kind. Called only after the path passed the deny rules and the same union admission as a document, with the same readPath; see Serving files. |
| signedOutPage | (request, headers) => Response | The page response for a signed-out reader, usually a redirect to sign-in. Data requests answer 401 instead. |
| forbiddenPage? | (request, headers) => Response | The page response for a reader without access (default: a 403 for the route's error boundary). |
| notices? | (context) => Promise<ExplorerNotice[]> | Callouts above the tree (a missing permission with a link that grants it, for example). |
| deny? | string[] \| false | Segment patterns hidden from every listing and refused on every read (default DEFAULT_DENIED_PATH_SEGMENTS). |
| treeCache? | { ttlMs?, maxEntries?, maxBytes? } \| false | The server's tree cache, each limit defaulting on its own (5 minutes, 64 entries, 64 MiB of the trees' JSON, counted at two bytes per character). The time to live counts from the moment the tree's walk began, and an expired tree is never answered: a page load or tree request after it waits for one new walk, which every concurrent request shares. Each cached tree is also kept parsed beside its JSON, which maxBytes does not count; its share falls as names get longer and is higher for non-ASCII names (measured on trees of 20,000 entries: about 1.0 to 1.9 times the counted bytes for ASCII names of 40 down to 4 characters, 1.4 to 2.2 times for non-ASCII names), and a tree with any non-ASCII name keeps a two-byte JSON as large as its count, so plan for up to about 2.4 times maxBytes of memory per process with ASCII names and up to about 3.2 times with short non-ASCII names. false turns the cache off. |
| sanitizer? | { workers?, deadlineMs?, maxHtmlBytes?, cacheBytes?, workerHeapMb?, jobsPerScope?, maxQueuedJobs? } | The HTML sanitizer, each field defaulting on its own: 2 worker threads, a 30 s deadline per document, HTML up to 4 MiB, a 64 MiB cache of outcomes, 512 MiB of heap per worker (workerHeapMb), at most multiView.maxPanels + max(1, workers − 1) + 3 documents per reader running or waiting (maxPanels + 4 with the default two workers, 16 with the default 12 panels) and four times jobsPerScope waiting in all, also when jobsPerScope is set. Neither jobsPerScope nor maxQueuedJobs may be set below multiView.maxPanels, because a page sends the document of every panel at once and a reader who finds every worker busy waits with all of them; a smaller value throws a RangeError naming both options at startup, as does a workerHeapMb that leaves room for fewer than 4,096 parsed nodes beside HTML of maxHtmlBytes. A document over the size, nesting, node, comment or attribute limits, whose sanitized HTML is longer than four characters per byte of maxHtmlBytes (16,777,216 by default), past the deadline, or one that runs a worker out of heap is too-large; one beyond either admission limit, or one whose worker could not start, is failed (logged without the principal) and a later request tries again. A reader runs at most workers − 1 documents at once (one when there is a single worker): with the default two workers, one reader — or all anonymous readers, who share one scope and so one admission budget — uses one. |
| deferredTimeoutMs? | number | How long the page loader waits for a streamed value (default 4000 ms; keep it below your server entry's streamTimeout). A late value renders the loading state on the server (never a failure) and is fetched by the browser once a pane shows it. The work behind a late tree or document keeps running for another deferredTimeoutMs, so the browser's data request for it can join that work instead of starting over (a host that joins requests for the same work keeps it for that request), and its signal aborts then; the work behind late notices, which the browser never fetches again, is aborted as soon as they are given up. |
| logger? | { error(message, detail) } | Where adapter failures and malformed source output are reported (default console.error). A tree or children entry with an empty name, or with a path the browser cannot address (empty, too long, a lone surrogate, a control character or a backslash — for example macOS's Icon\r), or outside its source's boundary, is left out together with its subtree and reported with the message [markdown-explorer] <operation> left out an entry and the detail { operation, sourceId, path, error }, whose error names the reason; any other malformed listing becomes failed. A boundary that throws or answers malformed values is reported with the operation boundary, and so is the rejection of a promise a contains answers (it is not awaited and counts as outside). A failed authentication of a data request is reported with the operation authenticate and never quotes a header: headers that Headers cannot read are reported by a fixed message, and a Response that authenticate throws (React Router's throw redirect(...)) by its status alone; any other exception of your adapters is logged as thrown, so keep secrets out of it. |
| onTreeRefreshed? | (key: string) => void | Called once per tree refresh, after its reload settled (whatever its result) unless the request went away, with the refreshed tree's cache key (opaque). A multi-process host relays the key to its other processes, which call forgetTree(key) (see Hosting notes). Its return value is ignored, but a promise it returns (an asynchronous relay) is observed, never awaited: a throw, or the rejection of that promise, is logged with the operation refresh and does not change the response. |
It returns { loader, headers, dataLoader, dataAction, forgetTree }. forgetTree(key) drops this process's cached tree for a key another process refreshed and supersedes a load of it that already began (its waiting requests still receive that tree, which is not kept); it starts no load and throws a TypeError for a non-string key. Export headers from the page route: it carries the loader's Cache-Control: private, no-store to both the HTML and the single-fetch responses.
dataLoader and dataAction answer a React Router single-fetch request to the data route (<dataPath>/<operation>.data) with a not-found failure (404) before authenticating or calling a source: React Router would run them for it, read the whole response and keep none of its headers but Set-Cookie.
The loader's result type is exported as ExplorerPageLoaderResult (ReturnType<typeof data<ExplorerPageData>>, named through React Router's public data() rather than a type React Router marks UNSAFE_).
A source adapter signals a failure the reader should see with throw new ExplorerError(reason, { code }); any other exception is logged and shown as a generic failure. Reasons: unauthorized, forbidden, not-found, not-configured, invalid, too-large, unsupported, failed. code (1–64 lowercase letters, digits and hyphens; anything else throws a RangeError when the error is created) lets your own renderTreeFailure show a specific remedy.
Source boundaries
Every source declares where its documents may lie, as boundary: { contains, confirm? } (ExplorerSourceBoundary, exported from the server entry with ExplorerBoundaryAnswer):
| Member | Type | Meaning |
| --- | --- | --- |
| contains | (path) => boolean | Pure and synchronous: whether the path's text can lie inside the source's roots. It never reads storage. A throw or a non-boolean answer is logged and counts as outside. |
| confirm? | (context, path) => Promise<{ inside: false } \| { inside: true; readPath: string }> | Storage's confirmation for a path contains accepted, required when the text cannot decide (for example a folder of a store whose paths are opaque ids). readPath is what the host must read or list (the object the store placed the path at), never the path as written. Throw ExplorerError for anything but inside or outside (not-found for a missing or trashed object, unauthorized, failed). A malformed answer is logged and counts as outside; a readPath the explorer would not serve (unacceptable or denied) is logged and fails. |
- A source's tree and children list only paths inside its own boundary: an entry outside it is left out with its subtree and logged.
- A children request is checked against the named source alone: the deny rules, then
contains(outside answersforbidden), thenconfirm(outside answersforbidden, a thrownExplorerErrorits reason);children(context, path, readPath)is called only after both passed. - An ancestors request is checked against the named source alone as well: the deny rules (a denied path answers
forbidden), thencontainsandconfirm, where outside answers{ status: "ok", ancestors: null }without calling the host (the open document may lie in another source) and a thrownExplorerErrorits reason;ancestors(context, path, readPath, options)is called only after both passed. - A reveal request checks the deny rules once, then each source in turn as an ancestors request does — the shown source first, then the others in configuration order —, passing over a source without
ancestorsor whosecontainsrefuses the path without calling any of its ports. - A document — of any view, panel, document link or seeded page — carries no source: it opens when the boundary of at least one source admits its path (the union of every source's boundary). The deny rules come first; a path no source contains answers
forbiddenwithout calling the host or storage; a containing source withoutconfirmadmits it at once withreadPathequal to the path; otherwise the containing sources confirm it in configuration order and the first inside admits it with itsreadPath. When none does, the first confirmation's failure answers, elseforbidden. - The union is exactly what the deployment exposes: a source whose boundary admits every document a reader can read in a store (an entry point listing everything the reader sees) opens every such document through every view, link and asset URL, so documents of that store are confined only when every source over it has a confined boundary. Asset URLs are admitted by the same union, because the explorer serves them itself.
Entries and notices
The published type declarations carry no doc comments, so the shapes a source receives and returns are described here. Every source call (tree, children, document, asset) receives an ExplorerSourceContext:
| ExplorerSourceContext field | Type | Meaning |
| --- | --- | --- |
| principal | P | The reader, as authenticate returned it. |
| request | Request | The request that asked for the call. |
| signal | AbortSignal | Aborts when nobody waits for the result any longer: every request that waited on the call has left (a walk a refresh or forgetTree superseded keeps running for the requests already waiting on it), or the page loader gave up streaming the value — at once for notices, and deferredTimeoutMs later for a tree or document, whose late value the browser fetches through the data route (that request has the time to join the work; the page request's own signal never aborts once its response has finished). Pass it to your I/O, so an abandoned call stops at once; a source that ignores it keeps running, and a tree walk that ignores it holds its cache key until it ends. |
| refresh | boolean | Whether the reader asked to reload from the source: the tree refresh, every document request it triggered, and a document's own Refresh document (X-Markdown-Explorer: refresh). A cache of your own in front of the source should be skipped and refilled when it is true. |
tree(context) returns the source's entries (its roots), and children(context, path, readPath) those of one lazy folder (readPath as for document):
| ExplorerEntry field | Type | Meaning |
| --- | --- | --- |
| name | string | The name shown in the row (not empty). |
| path | string | The document path that document() resolves and URLs carry. A path names one document; it may appear in several sources' trees (sources may overlap). It must lie inside the listing source's boundary. |
| type | "file" \| "directory" | A file opens as a document; a directory holds entries. |
| mimeType? | string | Handed on to the tree's row. |
| children? | ExplorerEntry[] | A directory's entries; never on a file. |
| lazy? | boolean | true marks a directory whose entries come from the source's children() when the reader opens it: the source needs children(), and the entry lists no children (an empty array at most). |
An entry with an empty name, with a path the browser cannot address, or outside the source's boundary is left out with its subtree and logged (see logger); any other entry that breaks these rules (a lazy directory from a source without children() or with listed children, a file with children or lazy) makes the whole listing failed.
| ExplorerNotice field | Type | Meaning |
| --- | --- | --- |
| id | string | Not empty; the callout's data-aqmx-notice. |
| tone | "info" \| "warning" \| "danger" | The callout's tone. |
| title | string | The callout's title. |
| body? | string | Text under the title. |
| action? | { label: string; href: string } | A link in the callout; href is http:, https:, mailto: or relative. |
Notices are shown in your own words, as given; a malformed notice list is logged and the reader sees none. A source's label is a string, or a function of the principal evaluated for each reader.
Documents
type ExplorerDocument =
| { kind: "markdown"; name: string; htmlContent: string; headings: { text: string; depth: number; id: string }[] }
| { kind: "text"; name: string; text: string; truncated: boolean; url: string }
| { kind: "image"; name: string; url: string }
| { kind: "media"; name: string; url: string; mediaType: "video" | "audio" | "pdf" }
| { kind: "file"; name: string; url: string }
| ExplorerFailureDocument // { kind: "failure"; failure: { reason: ExplorerFailureReason; code?: string } }This is the document the browser receives. Your document port answers ExplorerHostDocument (exported from the server entry), which is the same except that an image, media, text or file document carries its file's version instead of a url:
{ kind: "image"; name; version }, { kind: "media"; name; mediaType; version }, { kind: "text"; name; text; truncated; version } and { kind: "file"; name; version }.
version is an opaque validator of 1 to 256 visible ASCII characters other than ", the same one your asset port reports for the file; the explorer writes the document's url as its own asset URL with that version (<dataPath>/asset?path=…&v=<version>, see URL contract).
An answer of those kinds that carries a url key, or lacks a valid version, breaks the contract: it is logged and the reader sees failed.
failureDocument(reason, code?) returns an ExplorerFailureDocument, which both unions accept.
A text document shows the beginning of a file under the file header (its name, Open in a new tab and Download, so a truncated preview can still be fetched whole); a file document is a file the explorer cannot show, which every view (single, tree, the multi view's panels) shows as a download view: the file header over a note (the label fileNotShown) with a download link.
A markdown document lists at most EXPLORER_MAX_HEADINGS (10,000) headings; one that lists more is answered too-large, because every listed heading travels with the document (in the data response, the browser's document cache and the viewer's table of contents, one entry each) and a parser can list headings its HTML never renders (# lines inside a math block), so none of the HTML's limits bounds the list. A cache of your parser's results can rely on the same bound.
Markdown links the viewer should follow inside the explorer carry data-link-kind="doc" and data-doc-path="<document path>" (plus data-doc-exists="false" for a missing target); a doc link without a path, or with one that is not an acceptable document path (isAcceptableDocumentPath: non-empty, at most MAX_DOCUMENT_PATH_LENGTH characters, no lone surrogates, control characters or backslashes), renders as an inert link.
The sanitizer keeps only the data-* attributes of this contract: data-link-kind, data-doc-path, data-doc-exists, data-footnote-ref and data-footnote-backref on links, data-asset-src and data-asset-exists on images, and data-footnotes on sections; every other one is removed, including the data-plugin-type iframe trigger of @aiquants/markdown and the <div data-plugin-type> placeholders its 5.x converter wrote.
The one exception is the converter's embed placeholder: a paragraph whose only content is one absolute http(s) link without a user name or password (besides white space, U+00A0 and U+3000) carries the link's href, byte for byte, in data-embed-url on the <p>, and the paragraph and its link stay as written.
The sanitizer keeps that attribute only where placeholderLinkOf of @aiquants/markdown/embeds accepts it against the paragraph's children with the link's sanitized href; a forged or altered placeholder leaves the plain paragraph (see Security). Mark such paragraphs in your parser as the Go converter does to get embeds and cards (the demo's parser shows how); without the mark the link stays its paragraph.
The viewer (@aiquants/markdown) renders an external link itself, in a new tab with rel="noreferrer": one marked data-link-kind="external", or, without data-link-kind, one whose href names a scheme in any case (https:, HTTPS:, mailto:) or another host (//host/…), so links to other sites need no mark.
Every other link with an href reaches the explorer's link component with its data-link-kind, data-doc-path and data-doc-exists, its class, its content and the attributes your parser gives it for assistive technology and footnotes — title, lang, dir, role, aria-*, data-footnote-* and an id in the user-content- namespace — which the explorer puts on the anchor it renders (an inert link's title is the explorer's reason instead). Anchors without an href keep their attributes.
The viewer keeps only its own class tokens and fragment targets of your HTML, whatever the sanitizer kept (see Security): a class token survives when it belongs to the markup below, to footnotes (footnotes, footnote-ref, footnote-backref), to formulas (math, inline, display) or to a code block's language (language-*),
and an id on a heading, on a footnote (fnref:N or fnrefK:N on a reference's sup, fn:N on a note's li, as goldmark writes them) or in the user-content- namespace (remark-gfm's footnote ids, which sit on the reference's link and the note). Write any other fragment target as a heading, a user-content-… id or <a name>.
So a footnote reference keeps its id, the target of its back-reference, and its aria-describedby, and a back-reference keeps its aria-label, in either markup. A formula is <span class="math inline">\(…\)</span>, or <p><span class="math display">\[…\]</span></p> for display math, which the viewer sets apart by the display token (a formula without it is typeset inline). A task list's contains-task-list and task-list-item classes are dropped, so its items keep their list markers.
Converters may also write GitHub alerts and Zenn's message and details containers; the sanitizer and the viewer keep their markup as written. An alert is div.markdown-alert.markdown-alert-{type} (note, tip, important, warning or caution) whose first child is p.markdown-alert-title, holding the type's Octicon as an inline svg (its class, viewBox, version, width, height and aria-hidden, and the d of its path) and then the title. A message is aside.message (with alert, info or warning added for those types) holding span.message-symbol (aria-hidden="true") and div.message-content; a details is details.markdown-details holding its summary and div.markdown-details-content.
github-markdown.css draws the alerts under the GitHub document style and markdown.css gives them a baseline under the others, and message.css and details.css draw the containers (see Load the styles). The demo's parser writes all three, and footnotes, task lists and formulas as the Go converter of @aiquants/markdown (goldmark) does.
Your parser resolves each link and each image; the rules are in Document links and Document images (a relative image carries its target's document path in data-asset-src and no src, and the explorer's sanitizer writes the src, the explorer's own asset URL;
a missing target gets data-asset-exists="false", which the viewer shows as a labelled placeholder; a relative src left as written would resolve against the page URL and load an explorer page instead), and the demo's parser (demo/app/markdown.server.ts in the source repository) is a complete example.
The deny rules are checked on the document's path only: a parser that expands include directives (such as <!-- @import "…" -->) must apply them to every file it reads, or leave such directives unexpanded (@aiquants/markdown's parseMarkdown leaves them unexpanded unless resolveImports is set, and applies the deniedPathSegments you give it, the explorer's rules, to every import, image and link target). The explorer serves the files of documents and embedded images itself (see Serving files).
Revealing the open document
The reader's tree setting "Reveal the open document" (revealDocument, off in DEFAULT_TREE_SETTINGS; turn it on for every reader with defaultTreeSettings: { revealDocument: true }) opens the folders down to the open document and brings its row into the tree's window. The open document is the URL's document in the tree view (whatever the selection mode, "Off" included), and in the multi view the maximized panel's document, else the one opened last (a panel's link replaces its document; on load, where no order of opening is known, the last open document in layout order).
The single view has no tree.
- When: on load, and whenever that document changes from outside the tree (a link in the document, Back and Forward, opening or maximizing a panel). A document the reader opens by clicking or pressing Enter in the tree is not revealed again: the tree already has the focus there.
- How: the browser asks the data route's
revealoperation (GET <dataPath>/reveal?source=<shown source>&path=<document>) which source reveals the document, opens the folders of that source's answer all at once, and keeps bringing the row into view while their listings land (each lazy folder is listed as soon as its parent's contents are known, ahead of bulk work). - Which source: the server asks the shown source first, then the other sources in configuration order, one after another (never all at once: each call of a cloud source costs storage requests), and answers the first source whose
ancestorsanswers a chain, with that chain (see therevealoperation).- The shown source answers: the reveal happens there.
- Another source answers: the tree moves to it. The URL's
sourceis replaced (no history entry; the view, the documents, the maximized document, the overrides and the fragment stay), and the new tree opens the chain it was handed without asking again. - No source answers: nothing moves and no error is shown; a row the shown tree already lists is still brought into view.
- When the tree may move: only for a change of the open document (load, a link in a document, Back and Forward, opening or maximizing a panel), once per change.
- It never moves for a document the reader opened from the tree (by click, Enter or Space), for the document that was open while the reader picked a source by hand, or again for a document it already moved for. The explorer remembers that document across remounts of the tree and the pane, until another document is settled.
- Such a document is asked of the shown source alone, through the
ancestorsoperation (GET <dataPath>/ancestors?source=<shown source>&path=<document>), so a source the reader picked is never left by itself and costs no search of the others. It stops at the reader's next input in the tree (a press, a wheel, a touch, a key or a focus), at the next document or source, at Collapse all, when the explorer is hidden during the reveal (closing the sheet, collapsing the explorer panel), when a folder's listing fails, or when every folder of the chain has loaded and the row is not there — so it waits for at most one listing per folder. A chain deeper thanrevealDocument.maxDepth(16 by default) opens nothing: the server answersnullfor it.
- Stored state: the folders a reveal opens are part of the reader's remembered expansion for that source, exactly as if the reader had opened them, so a reload shows them open and the reader closes them as any other folder.
- The port:
ExplorerSource.ancestors?(context, path, readPath, options): Promise<readonly string[] | null>answers the folder row paths from the source's top level down to the document's direct parent, exactly as that source'streeandchildrenname them ([]for a document at the top level), ornullwhen the document is not in this source or cannot be revealed there (for example a document reached only through a shortcut, whose row has the shortcut's path). It is called only for a document path inside the source's boundary that passed the deny rules, with the confirmedreadPath(see Source boundaries). Without it, the source's documents are not revealed in it: theancestorsoperation answersnot-foundfor the source, and therevealoperation passes it over without calling any of its ports (a source that only lists, such as an entry point onto what others shared, reveals its documents through a source that answers them).options(ExplorerAncestorsOptions) is always{ maxDepth }, the configuration'srevealDocument.maxDepth. The server answers{ status: "ok", ancestors: null }for a chain of more folders than that, without validating it, so a source whose climb costs requests (a parent climb in a cloud drive, for example) may stop once the chain is longer and answernullitself; a source that ignoresoptionsgets the same answer. - Validation: every path of a chain within
maxDepthmust be acceptable (non-empty, within the length limit, no control character, lone surrogate or backslash), pass the deny rules, lie inside the source's boundary, differ from the document and appear once. Any other answer (notnullor an array, or one bad path) is logged with the operationancestorsand answered{ status: "error", failure: { reason: "failed" } }, and the tree opens nothing; the browser checks the answer's shape again. A thrownExplorerErroranswers its reason, any other exception is logged and answersfailed. - Answers:
200with{ status: "ok", ancestors: string[] | null }or{ status: "error", failure },400for a missing or malformedsourceorpath,404for an unknown source or one withoutancestors,405for anything butGETandHEAD, and the usual401and403.
The reveal operation
GET <dataPath>/reveal?source=<shown source>&path=<document> (and HEAD) answers which source reveals a document, behind the same authentication, deny rules, boundary checks, validation and maxDepth as ancestors:
- A denied document answers
{ status: "error", failure: { reason: "forbidden" } }without asking any source. - The sources are asked one after another: the shown source first, then the others in configuration order. A source without
ancestors, or whosecontainsrefuses the path, is passed over without calling any of its ports; the others are checked and called exactly as byancestors. - A source's failure counts as
nulland the search goes on to the next source: a failed confirmation, a thrown exception or a malformed answer (logged as inancestors; a thrownExplorerErroris the host's own answer and is not logged), and a chain deeper thanmaxDepth(neither validated nor logged). - The first chain answers
{ status: "ok", source: "<id>", ancestors: string[] }; when no source answers one,{ status: "ok", source: null, ancestors: null }. A request that went away ends the search. 400for a missing or malformedsourceorpath,404for an unknown source (a shown source withoutancestorsis passed over, not refused),405for anything butGETandHEAD, and the usual401and403.
createFileSystemSource(options)
A reference source over a folder on disk: lexical containment checked before any filesystem call and again after resolving the real path, symbolic links refused, a file read only when the opened file is the one the checks saw, denied branches pruned while walking, a cap on the number of entries (too-large above it), a size cap on Markdown files, and a bounded streamed preview for files with a known text extension or name (any other file is a file document, offered for download).
Names the tree cannot carry — not in NFC, with a colon, ending in a dot or a space, holding a control character or a backslash, too long, a symbolic link or a special file — and subdirectories that cannot be read (a permission error, or one that vanished during the walk) are left out and reported once per path through reportSkipped; only an unreadable root fails the tree.
| Option | Type | Purpose |
| --- | --- | --- |
| id, label | string | The source's id and its display name. |
| root | string | The folder to publish: an absolute path that exists (checked when the source is created). |
| pathPrefix | string | Required: the prefix of this source's document paths, ending with / (for example "guide/"), or "" for a source over the whole root (its boundary then admits every path). The source's boundary.contains admits exactly the paths that start with it, and it has no confirm. Omitting it, or any value that is not a string, throws a TypeError. |
| parse | (markdown, docPath) => Promise<{ htmlContent, headings }> | Turns Markdown into HTML and headings; docPath includes the prefix. The explorer sanitizes the HTML afterwards. |
| deny? | string[] \| false | Deny rules applied while walking (default DEFAULT_DENIED_PATH_SEGMENTS). They are separate from createMarkdownExplorer's deny, which filters what every source lists and serves. |
| maxEntries? | number | The most entries a tree may list before it is too-large (default 20000). |
| maxDocumentBytes? | number | The largest Markdown file read (default 2 MiB); a larger one is too-large. |
| textPreviewBytes? | number | How many leading bytes of a text file are shown (default 8 KiB). |
| walkConcurrency? | number | The most directories the walk reads at once (default 8). |
| cacheScope? | "principal" \| "shared" | Who shares the cached tree: "principal" (default), or "shared" when the tree does not depend on the reader. Keep "principal" when you wrap the source to filter its tree per reader. |
| reportSkipped? | (skipped: FileSystemSkippedEntry) => void | Receives each name the walk leaves out (sourceId, path and a FileSystemSkipReason), once per path (default: a console.warn). |
Any other option key (a misspelt limit, an option this source does not have) throws a TypeError naming it when the source is created, instead of being ignored.
The source's document(context, path, readPath) reads the file readPath names and shows it as path (its parser receives path); every other kind (image, media, text, file) reports the file's version, <size>-<mtimeNs>. A file of an unknown extension, and a text file whose preview holds a NUL byte, is a file document.
The source's ancestors(context, path, readPath) answers the folders down to the file readPath names from its path prefixes (<prefix>a and <prefix>a/b for <prefix>a/b/c.md, [] for a file at the top level), which are exactly its tree's row paths, and null for any path its walk would not list (outside the prefix, missing, not a regular file, denied, or reached through a symbolic link). A wrapper that turns a folder lazy (as the demo's withLazyFolder does) keeps it, because the row paths do not change.
The source's asset(context, path, readPath) serves every regular file the same checks admit: images and media with their type, everything else (Markdown, text and the files of unknown extensions) without one (""), so the explorer delivers it as an application/octet-stream download.
It opens the file again without following links, requires the device, inode, size and modification time the checks saw (not-found when the path now names another file or the file has changed since) and streams the requested range from that handle (the whole file only as long as the checks saw it), which closes when the stream ends, fails or is cancelled, or the request aborts.
Files of unknown extensions are
