@aiquants/markdown-explorer 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +121 -0
- package/LICENSE +21 -0
- package/README.md +606 -2
- package/dist/ExplorerSplit-D2plJRRe.cjs +1 -0
- package/dist/ExplorerSplit-DB37U7Kh.js +1362 -0
- package/dist/MultiLayout-DCPDu1-l.js +909 -0
- package/dist/MultiLayout-UyWxs6Xy.cjs +1 -0
- package/dist/TreeLayout-BoyFelK2.js +22 -0
- package/dist/TreeLayout-CLWbWjOc.cjs +1 -0
- package/dist/client/MarkdownExplorer.d.cts +30 -0
- package/dist/client/MarkdownExplorer.d.ts +30 -0
- package/dist/client/storage.d.cts +11 -0
- package/dist/client/storage.d.ts +11 -0
- package/dist/core/config.d.cts +77 -0
- package/dist/core/config.d.ts +77 -0
- package/dist/core/contracts.d.cts +155 -0
- package/dist/core/contracts.d.ts +155 -0
- package/dist/core/geometry.d.cts +51 -0
- package/dist/core/geometry.d.ts +51 -0
- package/dist/core/labels.d.cts +180 -0
- package/dist/core/labels.d.ts +180 -0
- package/dist/core/location.d.cts +82 -0
- package/dist/core/location.d.ts +82 -0
- package/dist/core/paths.d.cts +17 -0
- package/dist/core/paths.d.ts +17 -0
- package/dist/core/revalidation.d.cts +2 -0
- package/dist/core/revalidation.d.ts +2 -0
- package/dist/core/theme.d.cts +6 -0
- package/dist/core/theme.d.ts +6 -0
- package/dist/core/treeSettings.d.cts +48 -0
- package/dist/core/treeSettings.d.ts +48 -0
- package/dist/core/viewSettings.d.cts +15 -0
- package/dist/core/viewSettings.d.ts +15 -0
- package/dist/dataRoute-Bkfwfp1R.js +194 -0
- package/dist/dataRoute-CKbdwkQ7.cjs +1 -0
- package/dist/index-BfuAu2GV.js +2107 -0
- package/dist/index-Cx3-jSna.cjs +3 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +44 -0
- package/dist/location-Oo21lTyV.cjs +1 -0
- package/dist/location-fVl2R1ee.js +395 -0
- package/dist/sanitizeWorker.cjs +1 -0
- package/dist/sanitizeWorker.js +281 -0
- package/dist/server/anonymous.d.cts +10 -0
- package/dist/server/anonymous.d.ts +10 -0
- package/dist/server/createMarkdownExplorer.d.cts +12 -0
- package/dist/server/createMarkdownExplorer.d.ts +12 -0
- package/dist/server/errors.d.cts +26 -0
- package/dist/server/errors.d.ts +26 -0
- package/dist/server/fileSystemSource.d.cts +38 -0
- package/dist/server/fileSystemSource.d.ts +38 -0
- package/dist/server/ports.d.cts +107 -0
- package/dist/server/ports.d.ts +107 -0
- package/dist/server/sanitizeProtocol.d.cts +17 -0
- package/dist/server/sanitizeProtocol.d.ts +17 -0
- package/dist/server/sanitizer.d.cts +51 -0
- package/dist/server/sanitizer.d.ts +51 -0
- package/dist/server/workerPool.d.cts +42 -0
- package/dist/server/workerPool.d.ts +42 -0
- package/dist/server.cjs +1 -0
- package/dist/server.d.cts +7 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +1253 -0
- package/dist/storage-Bkb2Axdi.cjs +1 -0
- package/dist/storage-DA4ItHzl.js +60 -0
- package/dist/storage.cjs +1 -0
- package/dist/storage.d.cts +1 -0
- package/dist/storage.d.ts +1 -0
- package/dist/storage.js +4 -0
- package/dist/styles/markdown-explorer.css +3 -0
- package/docs/behavior/dom-hooks.md +95 -0
- package/docs/behavior/layout.md +215 -0
- package/docs/behavior/localization.md +48 -0
- package/docs/behavior/security.md +138 -0
- package/docs/behavior/url-contract.md +135 -0
- package/package.json +141 -3
package/README.md
CHANGED
|
@@ -1,3 +1,607 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @aiquants/markdown-explorer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
- **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>`). `explorerHref` writes 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.
|
|
6
|
+
- **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.
|
|
7
|
+
- **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).
|
|
8
|
+
The explorer's **Refresh tree** button (a visible label, also its accessible name) has the server reload the tree
|
|
9
|
+
and answers it, then reads the documents again past the host's caches: shown ones in the background
|
|
10
|
+
(replaced only when they changed), shown failures at once, a shown one still loading as soon as its
|
|
11
|
+
request settles, the others when they are next opened. A tree that loads meanwhile never discards the
|
|
12
|
+
reader's refresh; the newer of the two stays. A reloaded tree is announced; a failed reload keeps the
|
|
13
|
+
tree under an alert naming the failure.
|
|
14
|
+
Every view also reads one shown document alone again with **Refresh document**, without listing the tree again: a
|
|
15
|
+
labelled button in a thin bar above the document in the tree and single views, and an icon-only button named after
|
|
16
|
+
the document in a bar pinned to the top of each multi-view panel's scroll area, reachable from anywhere in the
|
|
17
|
+
document. The document stays on screen while it reloads and is replaced in place, keeping its scroll position; a
|
|
18
|
+
reloaded document is announced, and a failed reload keeps the document under a notice naming the failure, which is
|
|
19
|
+
announced too and stays until a newer copy of the document replaces or confirms it, the reader moves to another
|
|
20
|
+
document, or the next refresh starts.
|
|
21
|
+
A single-view (embedding) page never downloads the tree and multi-panel views, which load on demand.
|
|
22
|
+
- **Embeds and link cards** — with `embeds` in 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 card from your own link-card endpoint; the link itself always stays, under the frame, as the card or as the paragraph.
|
|
23
|
+
- **Secure by default** — Markdown HTML is sanitized on the server with an allow-list (scripts, event handlers, `srcdoc` and 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 and `user-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.
|
|
24
|
+
- **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.
|
|
25
|
+
- **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.
|
|
26
|
+
|
|
27
|
+
## Requirements
|
|
28
|
+
|
|
29
|
+
- React 19.2.7 or later and React Router 8 in framework mode (route modules with `loader` / `headers`).
|
|
30
|
+
- 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-router` through `require(esm)`.
|
|
31
|
+
- The peer packages, at these versions or later within the caret ranges the package declares: `@aiquants/markdown` ^6.0.0 (document rendering; its math diagnostics, gantt sizing, embeds and link cards, and the React-free `@aiquants/markdown/embeds` the sanitizer worker loads), `@aiquants/directory-tree` ^4.1.0 (reveals of rows under the scroll bar's buttons), `@aiquants/drag-drop-panels` ^0.9.1, `@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.
|
|
32
|
+
The client depends on these floors, while a package manager may only warn about an older peer.
|
|
33
|
+
- A Markdown parser of your choice on the server that produces HTML and its headings (the explorer sanitizes the HTML before it is sent).
|
|
34
|
+
- 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).
|
|
35
|
+
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.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pnpm add @aiquants/markdown-explorer @aiquants/markdown @aiquants/directory-tree @aiquants/drag-drop-panels @aiquants/resize-panels @aiquants/virtualscroll
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The 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`.
|
|
44
|
+
|
|
45
|
+
## Quick start
|
|
46
|
+
|
|
47
|
+
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.
|
|
48
|
+
|
|
49
|
+
### 1. Share one configuration between the server and the browser
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// app/explorer.config.ts
|
|
53
|
+
import { defineExplorerConfig } from "@aiquants/markdown-explorer"
|
|
54
|
+
|
|
55
|
+
export const explorerConfig = defineExplorerConfig({
|
|
56
|
+
basePath: "/docs",
|
|
57
|
+
dataPath: "/docs/_data",
|
|
58
|
+
settingsCookie: "docs-display",
|
|
59
|
+
})
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`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 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.
|
|
63
|
+
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](#views-and-urls)).
|
|
64
|
+
|
|
65
|
+
| Option | Type | Purpose |
|
|
66
|
+
| --- | --- | --- |
|
|
67
|
+
| `basePath` | `string` | Page mount, with a leading slash and no trailing slash (for example `/docs`). |
|
|
68
|
+
| `dataPath` | `string` | Data route mount in the same form; it differs from `basePath` and does not start with a view slug under it. |
|
|
69
|
+
| `settingsCookie` | `string` | Name of the display-preference cookie (an RFC 6265 token). |
|
|
70
|
+
| `views?` | `{ tree?, multi?, single? }` | URL slugs of the views; each defaults to its view's name. |
|
|
71
|
+
| `multiView?` | `{ maxPanels?, columns? }` | The panel limit (1–64, default 12) and the logical column count (1–6, default 4). |
|
|
72
|
+
| `expandAll?` | `{ maxFolders?, maxDepth? }` | Expand all's budget per press (see [Expand all and Collapse all](#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`. |
|
|
73
|
+
| `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. |
|
|
74
|
+
| `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. |
|
|
75
|
+
| `embeds?` | `{ providers?, linkCardEndpoint? }` | 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; `linkCardEndpoint` is the same-origin path of your link-card endpoint (the mount-path grammar), which turns a link written as its own URL into a card. Each field defaults to none, also when given as `undefined` (`null` is also none for the endpoint, as in `MarkdownEmbeds`), so without the option such a link stays its paragraph. Repeated ids are removed. An unknown id, a malformed endpoint or an unknown key throws a `RangeError` (`[markdown-explorer] embeds…`), and anything but a plain object a `TypeError`. The endpoint answers `GET <path>?url=<href>` with JSON `{ title?, description?, site_name?, image?, icon? }`, sets no cookie and refreshes no credential (the request runs in the background and can be in flight during a sign-out); a redirect, a 401, any other non-OK status or a non-JSON answer means no card. See `@aiquants/markdown`'s README (Embeds) for the URLs each provider recognizes and the frames' sizes. |
|
|
76
|
+
| `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 not first, Single selection, a recursive double click, 8 px expand icons, nothing kept expanded, 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"`; `selectionMode` `"single"`, `"multiple"` or `"none"`; `doubleClickAction` `"recursive"` or `"toggle"`; `expandIconSize` `8`, `13` or `5`; the other settings (`directoriesFirst`, `alwaysExpanded`, `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](#tree-settings)). |
|
|
77
|
+
|
|
78
|
+
For example, `defaultTreeSettings: { sortMode: "desc" }` lists every folder's entries by name, Z to A, until the reader picks another order.
|
|
79
|
+
|
|
80
|
+
### 2. Create the server half
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
// app/explorer.server.ts
|
|
84
|
+
import { resolve } from "node:path"
|
|
85
|
+
import { anonymousAccess, createFileSystemSource, createMarkdownExplorer } from "@aiquants/markdown-explorer/server"
|
|
86
|
+
import { explorerConfig } from "./explorer.config"
|
|
87
|
+
import { renderMarkdown } from "./markdown.server" // your parser: async (markdown, path) => ({ htmlContent, headings }); it resolves links and images (docs/behavior/url-contract.md#document-images)
|
|
88
|
+
|
|
89
|
+
const contentRoot = process.env.HANDBOOK_ROOT
|
|
90
|
+
if (contentRoot === undefined || contentRoot === "") throw new Error("HANDBOOK_ROOT must name the handbook folder")
|
|
91
|
+
|
|
92
|
+
// At least 32 characters, and the same on every server instance
|
|
93
|
+
const scopeSecret = process.env.EXPLORER_SCOPE_SECRET
|
|
94
|
+
if (scopeSecret === undefined) throw new Error("EXPLORER_SCOPE_SECRET must hold the explorer's scope secret")
|
|
95
|
+
|
|
96
|
+
const handbook = createFileSystemSource({
|
|
97
|
+
id: "handbook",
|
|
98
|
+
label: "Handbook",
|
|
99
|
+
root: resolve(contentRoot),
|
|
100
|
+
pathPrefix: "", // required: "" publishes the whole root
|
|
101
|
+
parse: renderMarkdown,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
export const explorer = createMarkdownExplorer({
|
|
105
|
+
config: explorerConfig,
|
|
106
|
+
...anonymousAccess,
|
|
107
|
+
scopeSecret,
|
|
108
|
+
sources: [handbook],
|
|
109
|
+
document: handbook.document,
|
|
110
|
+
asset: handbook.asset,
|
|
111
|
+
})
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`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.
|
|
115
|
+
|
|
116
|
+
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](#serving-files)).
|
|
117
|
+
|
|
118
|
+
### 3. Register the routes
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// app/routes.ts
|
|
122
|
+
import { type RouteConfig, route } from "@react-router/dev/routes"
|
|
123
|
+
|
|
124
|
+
export default [
|
|
125
|
+
route("docs", "routes/docs.tsx", { id: "docs" }),
|
|
126
|
+
route("docs/*", "routes/docs.tsx", { id: "docs-splat" }),
|
|
127
|
+
route("docs/_data/*", "routes/docs-data.ts"),
|
|
128
|
+
] satisfies RouteConfig
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The 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.
|
|
132
|
+
|
|
133
|
+
### 4. Write the two route modules
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
// app/routes/docs.tsx
|
|
137
|
+
import { MarkdownExplorer, shouldRevalidateExplorer } from "@aiquants/markdown-explorer"
|
|
138
|
+
import { isRouteErrorResponse, useLoaderData, useRouteError } from "react-router"
|
|
139
|
+
import { explorerConfig } from "../explorer.config"
|
|
140
|
+
import { explorer } from "../explorer.server"
|
|
141
|
+
|
|
142
|
+
export const loader = explorer.loader
|
|
143
|
+
export const headers = explorer.headers
|
|
144
|
+
export const shouldRevalidate = shouldRevalidateExplorer
|
|
145
|
+
|
|
146
|
+
export default function Docs() {
|
|
147
|
+
return (
|
|
148
|
+
<div className="docs-page">
|
|
149
|
+
<header>Handbook</header>
|
|
150
|
+
<main className="docs-explorer">
|
|
151
|
+
<MarkdownExplorer data={useLoaderData<typeof loader>()} config={explorerConfig} storageNamespace="docs" lockDocumentScroll />
|
|
152
|
+
</main>
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function ErrorBoundary() {
|
|
158
|
+
const error = useRouteError()
|
|
159
|
+
return <p>{isRouteErrorResponse(error) && error.status === 403 ? "You cannot open these documents." : "Something went wrong."}</p>
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
// app/routes/docs-data.ts
|
|
165
|
+
import { explorer } from "../explorer.server"
|
|
166
|
+
|
|
167
|
+
export const loader = explorer.dataLoader
|
|
168
|
+
export const action = explorer.dataAction
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
- Take the content folder from configuration (an environment variable here), not from `import.meta.url`: after `react-router build` the server module runs from `build/server/index.js`, so a path relative to the module would point into `build/`.
|
|
172
|
+
- The page route needs its own `ErrorBoundary`: React Router stops carrying `headers` at the boundary that renders an error, so without one the default 403 answer loses its `Cache-Control: private, no-store`.
|
|
173
|
+
- 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.
|
|
174
|
+
- The explorer adds no `main` landmark in any view (its own landmarks — the explorer `nav`, the document region, the multi view's columns — sit inside yours), so the page places it in its own `<main>`.
|
|
175
|
+
|
|
176
|
+
### 5. Load the styles
|
|
177
|
+
|
|
178
|
+
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`:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
// app/root.tsx (added to the root module React Router generated)
|
|
182
|
+
import resizePanels from "@aiquants/resize-panels/styles/resize-panels.standalone.css?url"
|
|
183
|
+
import dragDropPanels from "@aiquants/drag-drop-panels/styles/drag-drop-panels.standalone.css?url"
|
|
184
|
+
import virtualscroll from "@aiquants/virtualscroll/styles/virtualscroll.standalone.css?url"
|
|
185
|
+
import directoryTree from "@aiquants/directory-tree/styles/directory-tree.standalone.css?url"
|
|
186
|
+
import katex from "@aiquants/markdown/styles/katex.min.css?url"
|
|
187
|
+
import markdown from "@aiquants/markdown/styles/markdown.css?url"
|
|
188
|
+
import githubMarkdown from "@aiquants/markdown/styles/github-markdown.css?url"
|
|
189
|
+
import zennContent from "@aiquants/markdown/styles/zenn-content.css?url"
|
|
190
|
+
import message from "@aiquants/markdown/styles/message.css?url"
|
|
191
|
+
import details from "@aiquants/markdown/styles/details.css?url"
|
|
192
|
+
import markdownExplorer from "@aiquants/markdown-explorer/styles/markdown-explorer.css?url"
|
|
193
|
+
import app from "./app.css?url"
|
|
194
|
+
|
|
195
|
+
export const links = () => [resizePanels, dragDropPanels, virtualscroll, directoryTree, katex, markdown, githubMarkdown, zennContent, message, details, markdownExplorer, app].map((href) => ({ rel: "stylesheet", href }))
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Your own stylesheet gives the explorer the definite block size it fills (see [Sizing](docs/behavior/layout.md#sizing)):
|
|
199
|
+
|
|
200
|
+
```css
|
|
201
|
+
/* app/app.css */
|
|
202
|
+
html,
|
|
203
|
+
body {
|
|
204
|
+
height: 100%;
|
|
205
|
+
margin: 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.docs-page {
|
|
209
|
+
display: flex;
|
|
210
|
+
flex-direction: column;
|
|
211
|
+
height: 100%;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.docs-explorer {
|
|
215
|
+
flex: 1;
|
|
216
|
+
min-height: 0;
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
- `html` and `body` take the viewport's height (React Router's default root renders the page straight into `body`; give any element your own root puts between them `height: 100%` as well), and `margin: 0` keeps 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`).
|
|
221
|
+
- The `.standalone.css` builds 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' `dist` with `@source`.
|
|
222
|
+
- A Tailwind host must fix the cascade-layer order before any package stylesheet names a layer: load its own stylesheet (whose `@import "tailwindcss"` declares `theme, base, components, utilities`) first, or `@import` the package stylesheets from it after `@import "tailwindcss"`. Otherwise the first package sheet puts `components` before `base`, and Tailwind's preflight resets the explorer's (and the peers') spacing and borders.
|
|
223
|
+
`markdown-explorer.css` itself declares `theme`, `base` and `components` in that order before any rule, which fixes the order whenever it is the first sheet to name a layer.
|
|
224
|
+
- `github-markdown.css` and `zenn-content.css` back the default document styles, and `github-markdown.css`
|
|
225
|
+
also draws GitHub alerts (`.markdown-alert`); `message.css` and `details.css` draw Zenn's message and
|
|
226
|
+
details containers (`aside.message`, `details.markdown-details`) under every document style; load these
|
|
227
|
+
two when your converter writes that markup (see [Documents](#documents)). `markdown.css` also gives
|
|
228
|
+
GitHub alerts a baseline under the other document styles (None, Zenn).
|
|
229
|
+
`@aiquants/markdown/styles/katex.min.css` is KaTeX's own stylesheet, shipped with the fonts it points
|
|
230
|
+
to; load it when your documents contain math.
|
|
231
|
+
- 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 `!important` inside the layer, which no unlayered rule of yours can override:
|
|
232
|
+
the document viewer's transparent body background (so the region's `--aqmx-surface` shows through), the outward focus-ring offset of a document's links inside superscripts and subscripts (footnote references:
|
|
233
|
+
`github-markdown.css`, whose `.markdown-body` the 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-tree` sets on the tree's root element) under `prefers-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.
|
|
234
|
+
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.
|
|
235
|
+
- 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-7` and `--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-surface` and `--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-text` over `--aqmx-surface-sunken`; the custom drag mode's badge (and the pressed drag-mode toggle) to `--aqmx-warning-text` on `--aqmx-warning-surface`; the restore buttons of the bar of hidden panels to `--aqmx-surface` on `--aqmx-accent` (`--aqmx-info-text` while hovered); and the drop placeholders of a drag (text, icon and dashed border) to `--aqmx-accent` on `--aqmx-surface`, the placeholder a drop would land on to `--aqmx-accent-surface`.
|
|
236
|
+
So the panel chrome follows the explorer's `theme`, not a `dark` class 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).
|
|
237
|
+
- A maximized multi-view panel covers the viewport inside `--aqdd-maximize-top`, `--aqdd-maximize-right`, `--aqdd-maximize-bottom` and `--aqdd-maximize-left` (all `0px` by 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 (or `visibility: hidden`) while a panel is maximized — the maximized panel's frame carries `data-aqmx-maximized="true"` (`:has([data-aqmx-panel-frame][data-aqmx-maximized="true"])`), and the URL carries `maximized`.
|
|
238
|
+
|
|
239
|
+
## Views and URLs
|
|
240
|
+
|
|
241
|
+
| View | URL | What it shows |
|
|
242
|
+
| --- | --- | --- |
|
|
243
|
+
| Tree | `{basePath}/{tree}` or `{basePath}/{tree}/{document}` | The source tree beside the selected document |
|
|
244
|
+
| Multi-panel | `{basePath}/{multi}?doc=…&doc=…&maximized=…` | Several documents as panels in columns |
|
|
245
|
+
| Single | `{basePath}/{single}/{document}` | One document over the whole frame (for reading and embedding) |
|
|
246
|
+
|
|
247
|
+
- `?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.
|
|
248
|
+
- A document path occupies one URL segment. Build links with `explorerHref(config, target)`, the only function that writes explorer URLs; read them back with `resolveExplorerLocation(config, splat, search)`. Both need the server's source ids for the `source` parameter: pass a configuration joined with them, `withExplorerSources(config, sourceIds)` (`ExplorerLocationConfig`; `isExplorerSourceId` is the id rule, and an empty, invalid or repeated id throws a `RangeError`).
|
|
249
|
+
`explorerHref` also accepts a plain `ExplorerConfig` with a target without `source` (`ExplorerSourcelessHrefTarget`, the default source), such as a host menu's link; a target with a `source` and a configuration without ids throws a `TypeError` naming `withExplorerSources`, as does `resolveExplorerLocation` with such a configuration.
|
|
250
|
+
`explorerHref` throws a `RangeError` for a target the resolver would redirect or partly ignore — repeated or more than `multiView.maxPanels` documents, 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.
|
|
251
|
+
- The single view accepts embedding parameters: `showToc=false`, `showStyle=false`, `showAlign=false`, `padding` (0–256 px, written in decimal digits), `width` and `maxWidth` (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 accept `style` and `align` as display overrides.
|
|
252
|
+
- 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).
|
|
253
|
+
- 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](docs/behavior/url-contract.md#table-of-contents)).
|
|
254
|
+
|
|
255
|
+
The full contract, including canonicalization, is in [URL contract](docs/behavior/url-contract.md).
|
|
256
|
+
|
|
257
|
+
## Server API
|
|
258
|
+
|
|
259
|
+
### `createMarkdownExplorer(config)`
|
|
260
|
+
|
|
261
|
+
| Option | Type | Purpose |
|
|
262
|
+
| --- | --- | --- |
|
|
263
|
+
| `config` | `ExplorerConfig` | The shared configuration; the source ids come from `sources`. |
|
|
264
|
+
| `authenticate` | `(request) => Promise<ExplorerAuthentication<P>>` | `signed-in` with a principal, `signed-out`, or `forbidden`; `headers` (for example a refreshed session cookie) are added to every response. |
|
|
265
|
+
| `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. |
|
|
266
|
+
| `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. |
|
|
267
|
+
| `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](#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), `cacheScope?` (`"principal"`, the default, or `"shared"`). The `context` is described under [Entries and notices](#entries-and-notices). |
|
|
268
|
+
| `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](#documents)). |
|
|
269
|
+
| `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](#serving-files). |
|
|
270
|
+
| `signedOutPage` | `(request, headers) => Response` | The page response for a signed-out reader, usually a redirect to sign-in. Data requests answer 401 instead. |
|
|
271
|
+
| `forbiddenPage?` | `(request, headers) => Response` | The page response for a reader without access (default: a 403 for the route's error boundary). |
|
|
272
|
+
| `notices?` | `(context) => Promise<ExplorerNotice[]>` | Callouts above the tree (a missing permission with a link that grants it, for example). |
|
|
273
|
+
| `deny?` | `string[] \| false` | Segment patterns hidden from every listing and refused on every read (default `DEFAULT_DENIED_PATH_SEGMENTS`). |
|
|
274
|
+
| `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. |
|
|
275
|
+
| `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. |
|
|
276
|
+
| `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. |
|
|
277
|
+
| `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). |
|
|
278
|
+
| `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](#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. |
|
|
279
|
+
|
|
280
|
+
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.
|
|
281
|
+
`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`.
|
|
282
|
+
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_`).
|
|
283
|
+
|
|
284
|
+
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.
|
|
285
|
+
|
|
286
|
+
### Source boundaries
|
|
287
|
+
|
|
288
|
+
Every source declares where its documents may lie, as `boundary: { contains, confirm? }` (`ExplorerSourceBoundary`, exported from the server entry with `ExplorerBoundaryAnswer`):
|
|
289
|
+
|
|
290
|
+
| Member | Type | Meaning |
|
|
291
|
+
| --- | --- | --- |
|
|
292
|
+
| `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. |
|
|
293
|
+
| `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. |
|
|
294
|
+
|
|
295
|
+
- 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.
|
|
296
|
+
- A children request is checked against the named source alone: the deny rules, then `contains` (outside answers `forbidden`), then `confirm` (outside answers `forbidden`, a thrown `ExplorerError` its reason); `children(context, path, readPath)` is called only after both passed.
|
|
297
|
+
- 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 `forbidden` without calling the host or storage; a containing source without `confirm` admits it at once with `readPath` equal to the path; otherwise the containing sources confirm it in configuration order and the first inside admits it with its `readPath`. When none does, the first confirmation's failure answers, else `forbidden`.
|
|
298
|
+
- 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.
|
|
299
|
+
|
|
300
|
+
### Entries and notices
|
|
301
|
+
|
|
302
|
+
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`:
|
|
303
|
+
|
|
304
|
+
| `ExplorerSourceContext` field | Type | Meaning |
|
|
305
|
+
| --- | --- | --- |
|
|
306
|
+
| `principal` | `P` | The reader, as `authenticate` returned it. |
|
|
307
|
+
| `request` | `Request` | The request that asked for the call. |
|
|
308
|
+
| `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. |
|
|
309
|
+
| `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`. |
|
|
310
|
+
|
|
311
|
+
`tree(context)` returns the source's entries (its roots), and `children(context, path, readPath)` those of one lazy folder (`readPath` as for `document`):
|
|
312
|
+
|
|
313
|
+
| `ExplorerEntry` field | Type | Meaning |
|
|
314
|
+
| --- | --- | --- |
|
|
315
|
+
| `name` | `string` | The name shown in the row (not empty). |
|
|
316
|
+
| `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. |
|
|
317
|
+
| `type` | `"file" \| "directory"` | A file opens as a document; a directory holds entries. |
|
|
318
|
+
| `mimeType?` | `string` | Handed on to the tree's row. |
|
|
319
|
+
| `children?` | `ExplorerEntry[]` | A directory's entries; never on a file. |
|
|
320
|
+
| `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). |
|
|
321
|
+
|
|
322
|
+
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`.
|
|
323
|
+
|
|
324
|
+
| `ExplorerNotice` field | Type | Meaning |
|
|
325
|
+
| --- | --- | --- |
|
|
326
|
+
| `id` | `string` | Not empty; the callout's `data-aqmx-notice`. |
|
|
327
|
+
| `tone` | `"info" \| "warning" \| "danger"` | The callout's tone. |
|
|
328
|
+
| `title` | `string` | The callout's title. |
|
|
329
|
+
| `body?` | `string` | Text under the title. |
|
|
330
|
+
| `action?` | `{ label: string; href: string }` | A link in the callout; `href` is `http:`, `https:`, `mailto:` or relative. |
|
|
331
|
+
|
|
332
|
+
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.
|
|
333
|
+
|
|
334
|
+
### Documents
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
type ExplorerDocument =
|
|
338
|
+
| { kind: "markdown"; name: string; htmlContent: string; headings: { text: string; depth: number; id: string }[] }
|
|
339
|
+
| { kind: "text"; name: string; text: string; truncated: boolean; url: string }
|
|
340
|
+
| { kind: "image"; name: string; url: string }
|
|
341
|
+
| { kind: "media"; name: string; url: string; mediaType: "video" | "audio" | "pdf" }
|
|
342
|
+
| { kind: "file"; name: string; url: string }
|
|
343
|
+
| ExplorerFailureDocument // { kind: "failure"; failure: { reason: ExplorerFailureReason; code?: string } }
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
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`:
|
|
347
|
+
`{ kind: "image"; name; version }`, `{ kind: "media"; name; mediaType; version }`, `{ kind: "text"; name; text; truncated; version }` and `{ kind: "file"; name; version }`.
|
|
348
|
+
`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](docs/behavior/url-contract.md#asset-urls)).
|
|
349
|
+
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`.
|
|
350
|
+
`failureDocument(reason, code?)` returns an `ExplorerFailureDocument`, which both unions accept.
|
|
351
|
+
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.
|
|
352
|
+
|
|
353
|
+
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.
|
|
354
|
+
|
|
355
|
+
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.
|
|
356
|
+
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.
|
|
357
|
+
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.
|
|
358
|
+
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](docs/behavior/security.md#html)). 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.
|
|
359
|
+
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.
|
|
360
|
+
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.
|
|
361
|
+
The viewer keeps only its own class tokens and fragment targets of your HTML, whatever the sanitizer kept (see [Security](docs/behavior/security.md#the-viewers-allow-list)): 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-*`),
|
|
362
|
+
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>`.
|
|
363
|
+
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.
|
|
364
|
+
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`.
|
|
365
|
+
`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](#5-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.
|
|
366
|
+
Your parser resolves each link and each image; the rules are in [Document links](docs/behavior/url-contract.md#document-links) and [Document images](docs/behavior/url-contract.md#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;
|
|
367
|
+
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.
|
|
368
|
+
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](#serving-files)).
|
|
369
|
+
|
|
370
|
+
### `createFileSystemSource(options)`
|
|
371
|
+
|
|
372
|
+
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).
|
|
373
|
+
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.
|
|
374
|
+
|
|
375
|
+
| Option | Type | Purpose |
|
|
376
|
+
| --- | --- | --- |
|
|
377
|
+
| `id`, `label` | `string` | The source's id and its display name. |
|
|
378
|
+
| `root` | `string` | The folder to publish: an absolute path that exists (checked when the source is created). |
|
|
379
|
+
| `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`. |
|
|
380
|
+
| `parse` | `(markdown, docPath) => Promise<{ htmlContent, headings }>` | Turns Markdown into HTML and headings; `docPath` includes the prefix. The explorer sanitizes the HTML afterwards. |
|
|
381
|
+
| `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. |
|
|
382
|
+
| `maxEntries?` | `number` | The most entries a tree may list before it is `too-large` (default 20000). |
|
|
383
|
+
| `maxDocumentBytes?` | `number` | The largest Markdown file read (default 2 MiB); a larger one is `too-large`. |
|
|
384
|
+
| `textPreviewBytes?` | `number` | How many leading bytes of a text file are shown (default 8 KiB). |
|
|
385
|
+
| `walkConcurrency?` | `number` | The most directories the walk reads at once (default 8). |
|
|
386
|
+
| `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. |
|
|
387
|
+
| `reportSkipped?` | `(skipped: FileSystemSkippedEntry) => void` | Receives each name the walk leaves out (`sourceId`, `path` and a `FileSystemSkipReason`), once per path (default: a `console.warn`). |
|
|
388
|
+
|
|
389
|
+
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.
|
|
390
|
+
|
|
391
|
+
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.
|
|
392
|
+
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.
|
|
393
|
+
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.
|
|
394
|
+
Files of unknown extensions are downloadable, and so are the whole bytes of every Markdown and text file the source admits, so the `deny` rules are their only filter. The explorer hands both ports a `readPath` equal to the path, because the boundary has no `confirm`. Several folders are combined by giving each a `pathPrefix` and routing `document` and `asset` to a source whose boundary contains the path; the explorer refuses a path no source contains before it calls either port:
|
|
395
|
+
|
|
396
|
+
```ts
|
|
397
|
+
import { failureDocument } from "@aiquants/markdown-explorer"
|
|
398
|
+
import { ExplorerError } from "@aiquants/markdown-explorer/server"
|
|
399
|
+
|
|
400
|
+
const sources = [guide, notes] // createFileSystemSource({ ..., pathPrefix: "guide/" }) and ({ ..., pathPrefix: "notes/" })
|
|
401
|
+
|
|
402
|
+
export const explorer = createMarkdownExplorer({
|
|
403
|
+
config: explorerConfig,
|
|
404
|
+
...anonymousAccess,
|
|
405
|
+
scopeSecret, // read as in the quick start
|
|
406
|
+
sources,
|
|
407
|
+
document: async (context, path, readPath) => {
|
|
408
|
+
const source = sources.find((candidate) => candidate.boundary.contains(path))
|
|
409
|
+
return source === undefined ? failureDocument("not-found") : source.document(context, path, readPath)
|
|
410
|
+
},
|
|
411
|
+
asset: async (context, path, readPath) => {
|
|
412
|
+
const source = sources.find((candidate) => candidate.boundary.contains(path))
|
|
413
|
+
if (source === undefined) throw new ExplorerError("not-found")
|
|
414
|
+
return source.asset(context, path, readPath)
|
|
415
|
+
},
|
|
416
|
+
})
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### Serving files
|
|
420
|
+
|
|
421
|
+
The explorer serves every file it shows or offers for download itself, through the `asset` operation of its data route: `<dataPath>/asset?path=<document path>[&v=<version>]` (GET and HEAD; see [URL contract](docs/behavior/url-contract.md#asset-urls)). Images, audio, video and PDF are shown in place; every other file (Markdown, text, spreadsheets, anything else) is delivered as a download. The host supplies only the bytes, through the `asset` port:
|
|
422
|
+
|
|
423
|
+
| `ExplorerAssetFile` field | Type | Meaning |
|
|
424
|
+
| --- | --- | --- |
|
|
425
|
+
| `name` | `string` | The name offered when the file is saved (not empty; it may contain `/`, the explorer percent-encodes it). |
|
|
426
|
+
| `contentType` | `string` | The media type the storage declares, `""` when unknown (answered as a download, never guessed from the name). |
|
|
427
|
+
| `size` | `number \| null` | The length in bytes, `null` when the storage cannot tell (then the response has no `Content-Length` and no ranges). |
|
|
428
|
+
| `version` | `string` | An opaque validator (1 to 256 visible ASCII characters other than `"`), sent as the ETag; the same `version` the `document` port reports for the file. |
|
|
429
|
+
| `open` | `(range: ExplorerByteRange \| null) => Promise<ReadableStream<Uint8Array>>` | Opens the bytes of `range` (`{ offset, length }`, or `null` for the whole file) of the file `version` and `size` describe (when the storage can tell that the file has changed since the port found it, refuse rather than open other bytes); called at most once per request, never for HEAD, 304, 416 or an empty file. Abort with the `context.signal` the port received; throw `ExplorerError` to refuse. |
|
|
430
|
+
|
|
431
|
+
The port serves any regular file the document admission admits, whatever its kind; a malformed answer is logged and answered `failed`. The explorer decides everything else, in this order:
|
|
432
|
+
|
|
433
|
+
1. A request whose own URL path ends in `.data` (a React Router single-fetch request) answers 404 before authentication; another method than GET or HEAD answers 405 (`Allow: GET, HEAD`).
|
|
434
|
+
2. `authenticate` runs as for every data request: signed out 401, forbidden 403, as JSON failures (no redirect, which suits `<img>`, `<video>` and `<iframe>`). The port's headers (a refreshed session cookie) go onto every asset response; the reader's scope header never does.
|
|
435
|
+
3. `path` must appear exactly once and be an acceptable document path (400 otherwise; `?filePath=` alone is 400). A path the deny rules match answers 403 and is logged once through `logger` with the operation `asset`.
|
|
436
|
+
4. The path must be admitted by the union of the sources' boundaries, exactly as a document (see [Source boundaries](#source-boundaries)); a miss answers 403 and is not logged (an embedded image outside every source is ordinary). Only then is your `asset` port called, with the admitting source's `readPath`.
|
|
437
|
+
5. Only after admission are `If-None-Match` and `Range` read, so a path outside every boundary answers 403 even with a matching validator.
|
|
438
|
+
|
|
439
|
+
| Case | Response |
|
|
440
|
+
| --- | --- |
|
|
441
|
+
| `If-None-Match` matches `"<version>"` as RFC 9110 evaluates it: `*`, or a comma-separated list holding it by the weak comparison (`W/"<version>"` matches too; a malformed element matches nothing) | 304 with the ETag, cache and security headers |
|
|
442
|
+
| HEAD | The status and headers of the GET, without a body |
|
|
443
|
+
| `size` is `null` | 200 without `Accept-Ranges` and `Content-Length` |
|
|
444
|
+
| `size` is 0 | 200 with `Content-Length: 0` and an empty body, whatever the range |
|
|
445
|
+
| No usable single range (none, several, another unit, `last < first`, an `If-Range` other than the ETag itself, compared strongly) | 200 with `Accept-Ranges: bytes` and `Content-Length` |
|
|
446
|
+
| `bytes=first-last`, `first-` or `-suffix` | 206 with `Content-Range` and `Content-Length` (the last byte clamped to the end) |
|
|
447
|
+
| A range starting at or after the end, or `-0` | 416 with `Content-Range: bytes */<size>`, `Cache-Control: private, no-store` and `nosniff` |
|
|
448
|
+
|
|
449
|
+
Every 200, 206 and 304 carries the declared type as a browser parses it (the last valid comma-separated value, never `*/*`), else `application/octet-stream`, with `X-Content-Type-Options: nosniff`;
|
|
450
|
+
`Content-Disposition: inline; filename*=UTF-8''<name>` for raster images, audio, video and PDF, and `attachment; filename*=UTF-8''<name>` for everything else (SVG and an empty type included), where `<name>` is the RFC 8187 encoding of the name (`'`, `(`, `)` and `*` percent-encoded too);
|
|
451
|
+
`Content-Security-Policy: default-src 'none'; media-src 'self'; style-src 'unsafe-inline'; sandbox` on everything except PDF (the browser's PDF viewer does not open in a sandboxed document), with `sandbox allow-same-origin` instead for inline audio and video (the browser's media document of a file opened on its own fetches the file again, and only a document that keeps the URL's origin sends that request same-origin, with the reader's cookie; without `allow-scripts` no script runs);
|
|
452
|
+
`Cross-Origin-Resource-Policy: same-origin` on every type;
|
|
453
|
+
and `Cache-Control: private, max-age=60, stale-while-revalidate=600, no-transform` with `Vary: Cookie`. Failures are the data route's JSON failures with its statuses (400, 401, 403, 404, 413, 500).
|
|
454
|
+
The body of a 200 or 206 with a `Content-Length` carries exactly that many bytes: the explorer drops the bytes past it and cancels the stream `open` returned, and fails the response when that stream ends short, so a file that changes while it is served never gets a body that disagrees with its length and ETag.
|
|
455
|
+
The URL carries the path in its query, so no name needs an extra escape and the request's own path never ends in `.data`; a document's URL carries `v=<version>` as a cache-buster, which the server never reads (the ETag is always the current version).
|
|
456
|
+
|
|
457
|
+
### `anonymousAccess`
|
|
458
|
+
|
|
459
|
+
Spread it into the configuration when your application has no sign-in: it provides `authenticate`, `principalKey` and `signedOutPage`.
|
|
460
|
+
|
|
461
|
+
### Hosting notes
|
|
462
|
+
|
|
463
|
+
- The HTML sanitizer runs in worker threads started from `sanitizeWorker.js` (`sanitizeWorker.cjs` for the CommonJS entry) next to the server entry, and creating the explorer fails when that file is missing: keep the package external in your server bundle instead of bundling it into your own file. Each worker has its own DOMPurify, so nothing else in your server shares its configuration. Running the package from its TypeScript sources (tests, a dev server aliased to `src/`) needs Node's type stripping (22.18+, 23.6+ or 24).
|
|
464
|
+
- Serve a content security policy of your own; the explorer's sanitizer is the first line of defence, not the only one. In a single-page app a policy binds to the document, which client-side navigations keep, so set one policy for every page (at the root route or the document handler, or at the edge), never on the explorer's route responses alone: a policy sent only there is missing when the reader arrives by a client-side navigation, and stays on every page the reader visits next.
|
|
465
|
+
The explorer needs `frame-src`, `img-src` and `media-src` to allow `'self'`, the origin of its own asset URLs (PDF documents open in a frame from that URL), plus `frame-src https://<host>` for each `iframeHosts` entry and each host of `embedFrameHostsOf(config.embeds.providers)` (from `@aiquants/markdown/embeds`); a policy with `connect-src` also needs `embedConnectOriginsOf(config.embeds.providers)` (the Bluesky handle lookup), and one with `img-src` needs `https:` for card images and icons.
|
|
466
|
+
It renders no `<object>` or `<embed>`, so `object-src 'none'` and `base-uri 'none'` hold for it; prefer a nonce-based `script-src` if your framework's inline hydration scripts allow it.
|
|
467
|
+
A host that enables providers should also send `Cross-Origin-Opener-Policy: same-origin`: provider frames may open popups that escape their sandbox (`allow-popups-to-escape-sandbox`), and the policy severs such a popup's opener.
|
|
468
|
+
- Every GET route on the explorer's origin is reachable from a document: a rendered document makes the reader's browser send credentialed same-origin GET requests through its images (``, `<img src>`), media (`<video poster>`, `<audio src>`) and frames without the reader's interaction, and through its links once the reader follows one; the sanitizer keeps relative and root-relative URLs because a document's own images are legitimate.
|
|
469
|
+
Make every endpoint that changes state (signing out, deleting, starting a download of the reader's files) non-GET, or have it refuse, before it changes anything, a request that carries `Sec-Purpose` (a prefetch or prerender: speculation rules can request a document's same-origin links, with `Sec-Fetch-Dest: document`, before or without a click) or whose `Sec-Fetch-Dest` is present and is neither `document` (a typed address or a followed link) nor `empty` with `Sec-Fetch-Site: same-origin` (a script of your own origin: `fetch`, React Router's single-fetch data).
|
|
470
|
+
- Set your server entry's `streamTimeout` above `deferredTimeoutMs`, so a slow source is fetched by the browser instead of failing the stream.
|
|
471
|
+
- Each process keeps its own tree cache. A host running several processes (a Node cluster, for example) relays the keys `onTreeRefreshed` receives to its other processes, which call `forgetTree`, so a refresh in one process is not answered by an older tree in another; without the relay each process still answers no tree older than `treeCache.ttlMs`. With `node:cluster`, a worker sends the key to the primary, the primary forwards it to every other worker, and each worker forgets it:
|
|
472
|
+
|
|
473
|
+
```ts
|
|
474
|
+
const TREE_REFRESHED = "explorer:tree-refreshed"
|
|
475
|
+
const isTreeRefreshed = (message: unknown): message is { type: string; key: string } =>
|
|
476
|
+
typeof message === "object" && message !== null && (message as { type?: unknown }).type === TREE_REFRESHED && typeof (message as { key?: unknown }).key === "string"
|
|
477
|
+
|
|
478
|
+
// In each worker (the module that creates the explorer)
|
|
479
|
+
const explorer = createMarkdownExplorer({ ...options, onTreeRefreshed: (key) => process.send?.({ type: TREE_REFRESHED, key }) })
|
|
480
|
+
process.on("message", (message: unknown) => {
|
|
481
|
+
if (isTreeRefreshed(message)) explorer.forgetTree(message.key)
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
// In the primary
|
|
485
|
+
cluster.on("message", (sender, message: unknown) => {
|
|
486
|
+
if (!isTreeRefreshed(message)) return
|
|
487
|
+
for (const worker of Object.values(cluster.workers ?? {})) if (worker !== undefined && worker !== sender && worker.isConnected()) worker.send(message)
|
|
488
|
+
})
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
- **Fragments.** A fragment — a page's URL hash, a link's `#section`, a table-of-contents entry — indicates an element as HTML's "select the indicated part" finds it, but only inside the pane that shows the document (two multi-view panels may show the same ids): first the fragment as the URL carries it, then the fragment percent-decoded once as the URL standard decodes it (a malformed escape stays as written and a malformed UTF-8 sequence reads as U+FFFD, so a hand-typed fragment never stops the jump),
|
|
492
|
+
each looked up first as an element's `id` and then as the `name` of an `a` element, the first match in tree order winning. A fragment that indicates no element but is `#` or decodes to `top` in any ASCII case takes the pane to its start.
|
|
493
|
+
Link to what survives sanitizing: a heading's id — with `@aiquants/markdown` 6 or later as your converter, GitHub's automatic ids (`#概要`, `#api-の使い方`, `#snake_case-名`, `#重複-1` for a second `重複`) and ids written as `{#id}` —, an `<a name="…">` anchor in raw HTML, or an id in the `user-content-` namespace (see [The viewer's allow list](docs/behavior/security.md#the-viewers-allow-list)). DOMPurify drops an `id` or `name` equal to a property of the document or of a form (`title`, `images`, …), except a heading's id.
|
|
494
|
+
A canonical redirect keeps the fragment on a page load (the browser carries it across the loader's HTTP redirect) and on the explorer's own client-side replace (it appends the fragment), so loading `/docs#%E6%A6%82%E8%A6%81` ends on the tree view's URL with the same fragment.
|
|
495
|
+
A client-side navigation from another of your routes to a non-canonical URL loses its fragment, because React Router builds the redirected location from the loader's `Location` alone: link to the canonical URL that `explorerHref` writes, with the fragment appended. A multi-view URL ignores the hash: a panel's fragment-only link scrolls the panel instead.
|
|
496
|
+
- In a page view the explorer brings a fragment's element to the start of the pane's own scroller and moves every scroller around it only as far as needed (see [The document region](docs/behavior/layout.md#the-document-region)). Two scrolls it does not own can do more:
|
|
497
|
+
React Router's `<ScrollRestoration>` scrolls the element whose id is the URL's hash into view itself — page-wide, aligned to the start of every scroller, the page and your own scroll containers included — on a router navigation that carries a hash to a location it has saved no window offset for (a new history entry: it keys the offsets it saves by the location's key, or by your `getKey`), whenever that element is already in the page (a link to a section of the shown document, or an entry of the document viewer's table of contents, which the explorer follows as such a link). On Back or Forward to an entry it saved an offset for, it restores the window's offset instead, as it does for every entry the browser made itself (a fragment-only link) once it has left one, since React Router gives all of those the key `default`. The browser scrolls to the element itself for a fragment-only link and for the hash of a page it loads, and for a link to `#top` or `#` that names no element it scrolls the page itself to its top (HTML's top of the document is the viewport's).
|
|
498
|
+
They move nothing while the explorer fills a container that does not scroll (with `lockDocumentScroll`, for example); in a page that scrolls around a page view, leave `<ScrollRestoration>` off the explorer's routes if your scrollers must not move.
|
|
499
|
+
- For crawlers, render with `onAllReady` and `progressiveChunkSize: Number.POSITIVE_INFINITY`: even after every boundary is ready, React writes a boundary larger than 12.8 KB as a hidden segment that a script reveals, so a crawler that runs no script would see only the loading state.
|
|
500
|
+
- The explorer does not set the page title. React Router runs `meta` again on every navigation with the new `params` and `location`, but the loader data it receives stays that of the first paint (`shouldRevalidateExplorer` keeps the loader from running), so derive the title from `resolveExplorerLocation(withExplorerSources(config, sourceIds), params["*"], new URLSearchParams(location.search))` rather than from the loader data's documents (`sourceIds` from the loader data's `sources`, which a navigation does not change).
|
|
501
|
+
|
|
502
|
+
## Client API
|
|
503
|
+
|
|
504
|
+
### `<MarkdownExplorer>`
|
|
505
|
+
|
|
506
|
+
| Prop | Type | Purpose |
|
|
507
|
+
| --- | --- | --- |
|
|
508
|
+
| `data` | `ExplorerPageData` | `useLoaderData()` of the page route. |
|
|
509
|
+
| `config` | `ExplorerConfig` | The shared configuration. |
|
|
510
|
+
| `storageNamespace` | `string` | Prefix of the browser storage keys (tree settings, expansion state, the multi-view layout): 1–64 letters, digits, `.`, `_` or `-`, starting with a letter or digit (anything else throws a `RangeError`). |
|
|
511
|
+
| `locale?` | `"en" \| "ja"` | The UI language (default `"en"`). |
|
|
512
|
+
| `labels?` | `Partial<MarkdownExplorerLabels>` | Per-key overrides; strings that embed a value are functions, except the multi view's templates for `@aiquants/drag-drop-panels` (`MARKDOWN_EXPLORER_LABEL_TEMPLATES`). |
|
|
513
|
+
| `theme?` | `"light" \| "dark"` | The colour scheme (default `"light"`). |
|
|
514
|
+
| `renderFrame?` | `({ view, children }) => ReactNode` | Wraps each view in your chrome; for example leave the single view without it for embedding. |
|
|
515
|
+
| `renderTreeFailure?` | `(failure) => ReactNode \| undefined` | Renders a tree failure you understand better (by its `code`); `undefined` keeps the explorer's own message. Your node replaces the explorer's callout inside the tree-failure element, which keeps its hooks (`data-testid="markdown-explorer-tree-failure"` and `data-aqmx-failure`); the callout is the failure's only announcement (`role="alert"`), so your node must announce itself (for example with `role="alert"`). |
|
|
516
|
+
| `lockDocumentScroll?` | `boolean` | Stops the page itself from scrolling while the explorer fills the viewport; the explorer must then fit in the viewport (see below). |
|
|
517
|
+
| `onMathDiagnostics?` | `(path, diagnostics: readonly MarkdownMathDiagnostic[]) => void` | Receives the KaTeX diagnostics of a markdown document once it rendered (never during render), with the document's path; the reader's console never sees them. `MarkdownMathDiagnostic` (`{ code, message, formula, displayMode }`) comes from `@aiquants/markdown`. The explorer reaches your function through one stable function, so passing a new function on every render neither parses the document again nor delivers its diagnostics twice; text documents report none. |
|
|
518
|
+
|
|
519
|
+
The explorer fills its container's block size, so the container needs a definite block size: for example `height: 100dvh`, or a flex column of definite height whose item has `flex: 1` and `min-height: 0`. The quick start's `app.css` is such a container.
|
|
520
|
+
A container whose block size is auto or only a minimum (`html` and `body` at their default auto height included) does not size the explorer: the areas that fill the leftover height collapse to 0 px, or the explorer grows with its content.
|
|
521
|
+
With `lockDocumentScroll` the explorer must fill a container of definite block size inside the viewport; while the page is locked it logs one `console.error` per mount when the explorer ends below the viewport or a rendered part collapses (its root, the document region of the tree or single view, or the explorer's tree area is less than 1 px tall). Hidden parts — a closed sheet, a collapsed explorer panel, an explorer the host hides — are not checked.
|
|
522
|
+
|
|
523
|
+
#### Tree settings
|
|
524
|
+
|
|
525
|
+
The reader's tree settings are kept in memory for the explorer's lifetime and saved to browser storage under `storageNamespace` when the browser allows it; where it cannot write (blocked site data, a full quota) they last until the explorer unmounts (a navigation away from its route, a reload). The server render and the hydration use the defaults — the package's, with the configuration's `defaultTreeSettings` over them — and the stored settings apply right after the hydration.
|
|
526
|
+
Storage keeps only the settings the reader changed, each with the value they chose: a later change of `defaultTreeSettings` reaches every setting the reader has left alone, the ones they changed stay as they set them (also when a later default happens to equal their choice), and a setting the reader moves back to the default in force at that moment is dropped from storage, so later defaults reach it again.
|
|
527
|
+
A stored value of another format version is ignored, so its reader starts from the defaults. Selection: Single (the package default) selects the shown document; Multiple gathers clicked documents in a tray that opens them as panels; Off shows no row as selected, not even the shown document.
|
|
528
|
+
The tray shrinks and scrolls before the tree does: the tree keeps at least three rows at any text size, the tray keeps its header row and scrolls the rest, and an explorer too short for both scrolls as a whole. Always expanded shows every folder whose contents are known as expanded; a lazy folder stays collapsed until the reader opens it, so the setting never loads folders by itself.
|
|
529
|
+
|
|
530
|
+
Call `clearExplorerStorage(storageNamespace)` when a reader signs out of a shared browser (it throws a `RangeError` for a malformed namespace instead of clearing the wrong keys). Import it from `@aiquants/markdown-explorer/storage`: that entry imports nothing (no React, no `@aiquants/markdown`), so a layout or root route that runs on every page can call it without loading the explorer; the root entry exports the same function. The explorer also clears the namespace by itself: on every page load, before anything reads it, when the reader recorded there is not the page's reader, and when a data response reports a different reader.
|
|
531
|
+
|
|
532
|
+
#### Expand all and Collapse all
|
|
533
|
+
|
|
534
|
+
Expand all opens every folder of the tree and keeps opening the folders inside as their listings land, until the tree is fully open or the press's budget (`expandAll`) runs out: a press starts at most `maxFolders` listings (500 by default), and opens a folder whose contents are not known yet only down to `maxDepth` levels (16 by default, the top level being 1).
|
|
535
|
+
Folders whose contents are known always open, so a source that lists its whole tree up front opens fully at once, and listings run roughly level by level (a listing that lands early can let a deeper level start while a slower, shallower one is still in flight).
|
|
536
|
+
A press is announced once when it starts listing (`expandAllStarted`) and once at its end: the folders it opened, with how many could not be loaded, and that every folder is open unless the reader closed one during the press (`expandAllDone`); the folder limit (`expandAllFolderLimit`), where the next press continues with a fresh budget; or the depth limit (`expandAllDepthLimit`), where another press would stop at the same depth. While a press runs the button is `aria-busy` and ignores presses. The folders a press opened are part of the tree's remembered expansion, so a reload opens them again.
|
|
537
|
+
Collapse all closes every folder, ends a running press and cancels every listing the tree asked for, at the click and silently.
|
|
538
|
+
|
|
539
|
+
Loading follows what the tree shows open: a lazy folder is listed while some tree shows it open, whatever opened it, and at most 4 listings per explorer are in flight at once. Expand all, a remembered expansion and a recursive double click are bulk work and use at most 3 of them; a folder the reader opens (a click, Enter or ArrowRight) or retries goes ahead of all bulk work. Each source has its own queues, and the sources are served in turn, so one source's bulk work never holds back another source.
|
|
540
|
+
A listing no tree wants any more is cancelled — a queued one is never sent, one in flight is aborted (the source's `context.signal` aborts) — and its folder goes back to not loaded, with no failure, no Try again and no announcement: when the reader collapses the folder or one of its ancestors, uses Collapse all, switches the source, closes the sheet or collapses the explorer panel, or a refresh replaces the tree (the folders it still shows open are then listed again).
|
|
541
|
+
Nothing is listed while the explorer is hidden (a closed sheet, a collapsed explorer panel): a running press pauses, and continues once the explorer is shown again. Try again on a failed folder that is collapsed opens it as well.
|
|
542
|
+
|
|
543
|
+
While listings are queued or in flight, a progress line floats at the bottom of the tree area without moving the tree ("Loading 12 folders…", or "Opening all folders (40 opened, 12 loading)…" during a press). A press stopped at a limit leaves its stop sentence in a notice above the tree, in the pane's flow (the tree moves down by the notice's height, so the notice covers no row), until the next press, Collapse all, the pane having no rows to show (a failed, empty or cleared tree), or unmount (a source or view switch). Neither is a live region: the stop is announced once.
|
|
544
|
+
A folder met again among its own ancestors (a shortcut inside its own target, or two folders holding shortcuts to each other) is shown as a loop row, "Name (already open above)", that never expands: activating it scrolls to that folder, above it, and moves the focus there.
|
|
545
|
+
|
|
546
|
+
### Localization
|
|
547
|
+
|
|
548
|
+
`locale` selects a built-in catalog (`MARKDOWN_EXPLORER_LABEL_CATALOGS.en` / `.ja`); `labels` overrides single keys.
|
|
549
|
+
The catalogs also name every control of the document viewer (the `viewer…` keys: its toolbar, the table of contents, the style and alignment menus, each Mermaid diagram's controls and the titles of embedded frames) and everything the multi view's drag-and-drop layout shows (the panel headers' buttons and the custom drag mode's badge, the move announcements and a touch or pen drag's lift and cancel announcements, the bar of hidden panels, the columns' names and the drop placeholders), which therefore follow `locale` too.
|
|
550
|
+
The labels the layout fills with a value are templates with `{name}` placeholders (`MARKDOWN_EXPLORER_LABEL_TEMPLATES`). An unknown key, a blank string, a value of the wrong type, a template placeholder its key does not offer or a regional tag such as `"ja-JP"` throws — see [Localization](docs/behavior/localization.md).
|
|
551
|
+
|
|
552
|
+
## Layout
|
|
553
|
+
|
|
554
|
+
| Quantity | Value |
|
|
555
|
+
| --- | --- |
|
|
556
|
+
| Explorer default, tree view | 1/φ³ ≈ 23.607 % of the split (the document is 2φ times the explorer) |
|
|
557
|
+
| Explorer default, multi-panel view | 1/φ⁴ ≈ 14.590 % |
|
|
558
|
+
| Explorer maximum | the golden section 1/φ² ≈ 38.197 % |
|
|
559
|
+
| Explorer minimum | 13rem (208 px at a 16 px root font size; 16 px is assumed until the browser measures it) |
|
|
560
|
+
| Narrow frames | below 13rem × φ² the explorer leaves the split and opens as a sheet over the content, from a labelled 44 px toggle |
|
|
561
|
+
| Resize handle | a band 24 px wide under a fine pointer and 44 px under a coarse one, around a 5 × 34 px grip |
|
|
562
|
+
| Multi-view column minimum | 40ch at 1rem (the multi-column reading measure) |
|
|
563
|
+
| Multi-view panel height | between the stage height / φ³ and the stage height / φ, except that the panel's scroll area always keeps three lines of 1rem × φ text below the panel's header (where the header wraps that far, the panel is the header plus that floor) |
|
|
564
|
+
| Spacing, controls, durations | Fibonacci numbers (px written in rem; ms), except the pointer targets: 44 px (2.75rem) for every control under a coarse pointer and for the sheet's toggle and close button, and the resize handle's 24 px under a fine pointer |
|
|
565
|
+
|
|
566
|
+
The reader's own explorer width is remembered per view as a percentage, and only after the reader moves the handle: a drag that moved it (once, when it ends) or an arrow key that moved it, never a press that moved nothing or a change of the window. The document's column fills its pane; the viewer's alignment control appears only for a column a single-view embed narrows (`?width=` / `?maxWidth=`). See [Layout](docs/behavior/layout.md).
|
|
567
|
+
|
|
568
|
+
## Security model
|
|
569
|
+
|
|
570
|
+
- Markdown HTML is sanitized on the server with DOMPurify and an allow-list, in worker threads with limits on size (4 MiB), nesting (512 levels, as the sanitizer's parser builds the tree), parsed nodes (81,920 elements, attributes and comments, at most 4,096 of them comments), attributes (at most 1,024 per element, and no more attribute characters than `maxHtmlBytes`, counting the copies the parser makes of reopened formatting elements), output (at most four characters per byte of `maxHtmlBytes`), heap (512 MiB per worker) and time (30 s) and a fair share of the workers for each reader:
|
|
571
|
+
no `script`, `style`, `form` or embedding elements, no event-handler attributes or `srcdoc`, only `text-align` in `style`, iframes only over HTTPS from hosts listed exactly in `config.iframeHosts` (with `allow` and `referrerpolicy` reduced to safe values, by the frame policy `@aiquants/markdown/embeds` shares with the viewer), `data-*` only for the link, image and footnote contract and the embed placeholder its shared contract accepts, and only `http:`, `https:`, `mailto:`, `tel:` and relative URLs (no `data:` URL in any element).
|
|
572
|
+
- The document viewer (`@aiquants/markdown`) applies its own allow list on every render, so the page holds the intersection: `class` reduced to the converter's tokens (alerts, Zenn containers, footnotes, formulas, `language-*`), `id` only on headings, footnotes and in the `user-content-` namespace, and no author `target`, `rel` or `srcset`. The sanitizer leaves classes and ids to that single definition; render `htmlContent` only through the explorer or `@aiquants/markdown`'s renderers.
|
|
573
|
+
- Document paths from URLs are limited to 4096 characters without lone surrogates, control characters or backslashes; deny rules are matched through up to three lenient percent-decoding levels, against each level whole and split on `/` or `\` (a storage may read either as part of a name or as a separator), and after full Unicode case folding.
|
|
574
|
+
- Every source is confined to its boundary: its tree and children list only paths inside it, a children request reaches the host only for a path the named source's boundary admits, and a document or a file's bytes reach the host only when the boundary of at least one source admits its path (see [Source boundaries](#source-boundaries)). A source whose boundary admits everything a reader can read in a store confines nothing in that store.
|
|
575
|
+
- File bytes are served only by the explorer's `asset` operation, after the deny rules and the document admission, inline only for raster images, audio, video and PDF, always with `nosniff` and a `same-origin` resource policy, and with a sandboxing policy except for PDF, under which inline audio and video keep the URL's origin (`allow-same-origin`, no `allow-scripts`) (see [Serving files](#serving-files)).
|
|
576
|
+
- Data responses are `private, no-store` JSON with `nosniff`, a sandboxing content security policy and `same-origin` resource policy, and the data route refuses React Router's single-fetch requests (`.data`), whose answers would carry none of those headers; the tree refresh, and a document request marked as triggered by a refresh (`X-Markdown-Explorer: refresh`, which makes the host skip its caches), require the custom request header and a same-origin fetch.
|
|
577
|
+
- Each data response carries an opaque, pseudonymous scope (an HMAC of the principal key under `scopeSecret`); when it changes, the browser drops every cache and remembered path. Browser storage is also checked against the page's scope on every page load, before anything reads it.
|
|
578
|
+
|
|
579
|
+
Details: [Security](docs/behavior/security.md).
|
|
580
|
+
|
|
581
|
+
## DOM hooks
|
|
582
|
+
|
|
583
|
+
Tests and host styling may rely on the `data-testid` and `data-aqmx-*` attributes listed in [DOM hooks](docs/behavior/dom-hooks.md). Any other attribute or class is internal.
|
|
584
|
+
|
|
585
|
+
## Known limitations
|
|
586
|
+
|
|
587
|
+
- One explorer per page: the drag-and-drop layout looks up its panels document-wide.
|
|
588
|
+
- A held Enter acts once on every control inside the explorer, those of other packages included (the tree's rows, the panel headers' buttons, the document viewer's toolbar and the controls inside a document), except Zoom in and Zoom out, which keep stepping while a press that began on them is held (see [Held keys](docs/behavior/layout.md#held-keys)): a press that hands the focus to them, such as following a link to an image, does not step them. The repeated keydowns end at the explorer's root, so a keydown listener the host attaches outside the explorer for the bubbling phase does not receive them either.
|
|
589
|
+
- A loop row (a folder met again among its own ancestors) shows an expand glyph and `aria-expanded="false"` like any closed folder, because `@aiquants/directory-tree` has no row that cannot expand; it never opens. The 4 listings in flight are counted per explorer, not across browser tabs.
|
|
590
|
+
- Document kinds are the five above; `createFileSystemSource` previews only files with a known text extension or name as text and offers any other file as a `file` document (a download).
|
|
591
|
+
- HTML larger than `sanitizer.maxHtmlBytes` (4 MiB), placed deeper than 512 levels while it is parsed, making the parser create more elements than it has characters, more nodes than the worker's heap holds (81,920 elements, attributes and comments by default) or more than 4,096 comments, holding a tag with more than 1,024 attributes (counted before parsing for every `<` or `</` followed by a letter, so text in a script, a comment or an attribute value that looks like such a tag counts too), giving `html` or `body` more than 1,024 attributes through repeated tags, carrying attributes whose characters, counting every copy of a reopened formatting element, exceed `maxHtmlBytes`, sanitizing to HTML longer than four characters per byte of `maxHtmlBytes`, or not sanitized within `sanitizer.deadlineMs` (30 s) shows as `too-large`.
|
|
592
|
+
- All anonymous readers share one scope, so they share the admission budget as well as the running share: with `anonymousAccess`, the `jobsPerScope` documents running or waiting are counted for all visitors together, and panels beyond that budget come back `failed` until a reload or "Try again". For a public deployment, set `jobsPerScope` to the number of visitors expected to open a multi view at the same moment times `multiView.maxPanels`; `maxQueuedJobs` then defaults to four times that.
|
|
593
|
+
- Each sanitizer worker runs with `workerHeapMb` (512 MiB) of old generation and a fixed 32 MiB young generation, about 1.1 GiB for the default two workers. A process-wide `--max-old-space-size` (for example in `NODE_OPTIONS`) overrides the workers' old-generation limit, because V8 gives the flag precedence; the nesting guard's node budget still keeps every document it accepts within about `workerHeapMb`, but the hard stop at the limit no longer applies.
|
|
594
|
+
- The Top/Bottom buttons of the tree's pointer scroll bar (`@aiquants/virtualscroll`, hidden from assistive technology and kept out of the Tab order because the tree owns keyboard scrolling with its arrow keys, Home and End)
|
|
595
|
+
are drawn over the tree's first and last visible rows, so with large text a row the keyboard has just reached at the tree's edge can sit partly under the Bottom button.
|
|
596
|
+
- The tree's progress line floats over the rows near the bottom of the tree area and never moves the tree, so while listings load or a press runs, the last rows of a long tree cannot be scrolled clear of it, and a row the keyboard reaches there can sit partly under it; the line goes once no press runs and nothing is loading. Of the tree's loading and Expand all messages, only this transient line covers rows: the stop sentence of a press stopped at a limit, which stays until the next press, sits in a notice above the tree instead.
|
|
597
|
+
- Chromium reports the default object size (300 × 150, fitted to the image's aspect ratio) as the natural size of an SVG whose root has no absolute `width` and `height` (only a `viewBox`, or `width="100%"`). The explorer zooms such an image relative to its fitted size once Fit has enlarged it, but in a pane narrower than that default size Fit shrinks it instead, and its zoom levels and percentages stay relative to the default size (see [The document region](docs/behavior/layout.md#the-document-region)).
|
|
598
|
+
|
|
599
|
+
## Demo
|
|
600
|
+
|
|
601
|
+
The source repository holds a runnable demo next to the package (`packages/markdown-explorer/demo`): three file-system sources with anonymous access (`guide` and `notes`, kept apart by their path prefix, with one folder of the guide, `guide/a`, served lazily through `children`; and `reference`, rooted at the folder `guide/reference` inside `guide`, so a document under it appears in two trees under the same path),
|
|
602
|
+
the page and data routes, the error boundary, document and asset ports that route each path to the first source whose boundary contains it (no asset route of its own), a Markdown parser that produces document links and image targets, GitHub alerts, Zenn's message and details containers, embed placeholders, and footnotes, task lists and formulas in the Go converter's markup, every embed provider with an offline link-card endpoint (`/api/link-card`, answering from fixtures; `guide/reference/embeds.md` and `guide/reference/link-cards.md` show them), and the end-to-end tests' fixtures.
|
|
603
|
+
Run it with `pnpm run demo:dev` from the package folder.
|
|
604
|
+
|
|
605
|
+
## License
|
|
606
|
+
|
|
607
|
+
MIT
|