@aiquants/markdown-explorer 0.1.0 → 0.3.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +53 -1
  2. package/README.md +53 -16
  3. package/dist/{ExplorerSplit-DwEFmvQM.js → ExplorerSplit-B7pmzR8t.js} +3 -3
  4. package/dist/{ExplorerSplit-BESGFcu6.cjs → ExplorerSplit-Bi3yU6NG.cjs} +1 -1
  5. package/dist/MultiLayout-BxeXOLX1.js +909 -0
  6. package/dist/MultiLayout-DtFrFRmS.cjs +1 -0
  7. package/dist/TreeLayout-BQUfVNlA.cjs +1 -0
  8. package/dist/TreeLayout-D6hCSihW.js +22 -0
  9. package/dist/core/config.d.cts +8 -0
  10. package/dist/core/config.d.ts +8 -0
  11. package/dist/core/labels.d.cts +6 -1
  12. package/dist/core/labels.d.ts +6 -1
  13. package/dist/{dataRoute-CKbdwkQ7.cjs → dataRoute-B9Wk-G7A.cjs} +1 -1
  14. package/dist/{dataRoute-Bkfwfp1R.js → dataRoute-zUX-oR9j.js} +39 -38
  15. package/dist/{index-BgkZ9ATV.js → index-BJTmp0lR.js} +947 -901
  16. package/dist/index-DkiGlM7C.cjs +3 -0
  17. package/dist/index.cjs +1 -1
  18. package/dist/index.js +13 -13
  19. package/dist/location-DfYTzfFS.cjs +1 -0
  20. package/dist/{location-Cb2Ak55B.js → location-V5BKeWNs.js} +189 -162
  21. package/dist/sanitizeWorker.cjs +1 -1
  22. package/dist/sanitizeWorker.js +196 -206
  23. package/dist/server/anonymous.d.cts +2 -2
  24. package/dist/server/anonymous.d.ts +2 -2
  25. package/dist/server/ports.d.cts +4 -1
  26. package/dist/server/ports.d.ts +4 -1
  27. package/dist/server.cjs +1 -1
  28. package/dist/server.d.cts +1 -1
  29. package/dist/server.d.ts +1 -1
  30. package/dist/server.js +631 -603
  31. package/dist/styles/markdown-explorer.css +1 -1
  32. package/docs/behavior/dom-hooks.md +4 -4
  33. package/docs/behavior/layout.md +21 -9
  34. package/docs/behavior/localization.md +4 -3
  35. package/docs/behavior/security.md +23 -5
  36. package/docs/behavior/url-contract.md +8 -2
  37. package/package.json +5 -5
  38. package/dist/MultiLayout-DxPiARsv.js +0 -896
  39. package/dist/MultiLayout-r_qx1Mfz.cjs +0 -1
  40. package/dist/TreeLayout-9-20bbSM.cjs +0 -1
  41. package/dist/TreeLayout-CW7lgV_e.js +0 -19
  42. package/dist/index-y9tVvDW7.cjs +0 -3
  43. package/dist/location-DWHQYntR.cjs +0 -1
package/CHANGELOG.md CHANGED
@@ -1,6 +1,58 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0 (unreleased)
3
+ ## 0.3.0 (2026-10-08)
4
+
5
+ ### Breaking
6
+
7
+ - **`authenticate` takes a mode**: the port is `(request: Request, mode: ExplorerAuthenticationMode) => Promise<ExplorerAuthentication<P>>`, with `ExplorerAuthenticationMode = { readonly readOnly: boolean }` (exported from `@aiquants/markdown-explorer/server`).
8
+ The page loader and every data operation but `link-card` pass `{ readOnly: false }`; the `link-card` operation passes `{ readOnly: true }`, in which the host only reads the session: it never refreshes a token, writes no cookie and returns no `headers` (cards ask in the background, and a refreshed cookie arriving after a sign-out would sign the reader back in).
9
+ The explorer fails closed: a read-only authentication that carries any header is logged through `logger` (operation `authenticate`) and answered `failed` with `private, no-store` and none of its headers; a throw is logged and answered `failed` the same way (an `ExplorerError` answers its own reason unlogged and an aborted request is not logged, as on every operation), never with `Set-Cookie`. `anonymousAccess` returns no headers in any mode and needs no change.
10
+ - **`embeds.linkCardEndpoint` is replaced by `embeds.linkCards`**: `defineExplorerConfig({ embeds: { providers?, linkCards?, linkCardSize? } })`. `linkCards: true` points the cards at the data route's own `link-card` operation, so the host's separate card endpoint and its route go away; `linkCardEndpoint` is now an unknown key and throws a `RangeError`, as does a `linkCards` that is not a boolean.
11
+ The resolved `config.embeds` stays the `MarkdownEmbeds` of `@aiquants/markdown/embeds`: `linkCardEndpoint` is `<dataPath>/link-card` when link cards are on, otherwise `null`. A resolved configuration is no longer a valid input of `defineExplorerConfig`; `createMarkdownExplorer` still validates the frozen value it receives again (it reads the resolved embeds back into the option and requires the endpoint `dataPath` derives); a resolved `embeds` that carries the option-only `linkCards`, whatever its value, throws a `RangeError` instead of being overwritten by what the endpoint implies.
12
+ - **`@aiquants/markdown` 6.1**: the peer range becomes `^6.1.0` when the package is published (the handler `createLinkCardHandler` of `@aiquants/markdown/server` and `linkCardSize`). Node.js: `engines.node` stays `>=22.22.0`, which already covers the `>=22.13.0` `@aiquants/markdown` now declares for its handler.
13
+
14
+ ### Added
15
+
16
+ - **The `link-card` operation**: with `embeds: { linkCards: true }`, `GET` and `HEAD` `<dataPath>/link-card?url=<href>` serve the cards through `@aiquants/markdown`'s `createLinkCardHandler`, created once in `createMarkdownExplorer` behind the read-only authentication. Its answers pass through unchanged: a card (`200`, `private, max-age=1800`), a link without a card (`204 No Content` with `X-Link-Card-Skip`: `private-network`, `forbidden-url`, `unreachable` or `not-html`) or the handler's own errors (`no-store`).
17
+ `signed-out` and `forbidden` mean no card. With link cards off, `link-card` answers `not-found` exactly as an unknown operation does. Internal addresses never become cards: the SSRF guards live in `@aiquants/markdown`, and neither the handler nor the explorer has an option to allow them. The endpoint is same-origin, so a content security policy needs no extra `connect-src`.
18
+ - **Card size**: `embeds.linkCardSize` (`compact`, `banner` or `full`, `LINK_CARD_SIZES` of `@aiquants/markdown/embeds`) passes into the resolved `MarkdownEmbeds.linkCardSize`; absent means `@aiquants/markdown`'s `banner`. An unknown size, or a size without `linkCards: true`, throws a `RangeError`.
19
+ - **Build check**: `verify-dist` proves both server builds keep `@aiquants/markdown/server` external and serve `link-card` through it (an internal address answered by the real handler's `204 private-network` after a read-only authentication, `not-found` with link cards off), and that no browser, storage or sanitizer-worker build reaches it.
20
+
21
+ ### Fixed
22
+
23
+ - **No header value in the log**: `headers` that `Headers` cannot read are refused with a fixed message instead of the runtime's, which quoted the value (a session cookie or token), in the page loader and on every data operation, `link-card` included; and a `Response` that `authenticate` throws in a data request (React Router's `throw redirect(...)`) is logged as a `TypeError` naming its status instead of with its headers.
24
+ The answers do not change (`failed`, `private, no-store`, no `Set-Cookie`); unreadable `headers` of a data request are now logged as `authenticate` instead of under the operation's name.
25
+
26
+ ## 0.2.0 (2026-10-07)
27
+
28
+ ### Changed
29
+
30
+ - **Refresh tree**: the explorer's tree refresh button reads **Refresh tree** (`refreshTreeLabel`, a visible label that is also its accessible name; tooltip `refreshTree` unchanged) instead of **Refresh**, so it is told apart from the new **Refresh document**. It still reloads the tree and then reads the documents again with the refresh header. The shared visible label `refresh` is removed: a host that overrode it sets `refreshTreeLabel` and `refreshDocumentLabel` instead.
31
+ Where the explorer pane is too narrow for the whole label, the button shrinks and ends its label with an ellipsis rather than pushing the pane sideways, as Refresh document does in its bar; the accessible name stays the whole label.
32
+ - **Single view refresh**: the single view's toolbar is the shared document refresh bar. Its DOM hooks `markdown-explorer-single-toolbar`, `markdown-explorer-single-refresh` and `markdown-explorer-single-refresh-failure` are replaced by `markdown-explorer-document-refresh-bar`, `markdown-explorer-document-refresh` and `markdown-explorer-document-refresh-failure`, and a failed reload is a notice inside the bar that is no live region and that the announcer speaks, instead of an alert.
33
+ - **Table of contents entries of headings without an id** are text, as `@aiquants/markdown` 6 draws them, instead of links to the document itself; `onHeadingSelect` and `headingHref` are only asked about entries that name a heading.
34
+ - **`@aiquants/markdown` 6**: the peer range is `^6.0.0`. The viewer's labels gain `viewerEmbeddedVideo`, `viewerEmbeddedPost` and `viewerEmbeddedFrame` (en "embedded video", "embedded post", "Embedded content"; ja 「埋め込みの動画」「埋め込みの投稿」「埋め込みのコンテンツ」), which name embedded frames in the UI language.
35
+ - **`@aiquants/virtualscroll` 3.14**: the peer range is `^3.14.0` (was `^3.13.0`), the version the explorer is tested with; its scroll bar arrows are drawn as SVG triangles, and the explorer styles no arrow of its own.
36
+ - **Iframes written in documents** pass the sanitizer through the frame policy of `@aiquants/markdown/embeds` (`allowedFrameUrlOf`, `reduceFrameFeatures`, `EMBED_FRAME_REFERRER_POLICIES`), the one the viewer applies too, instead of the sanitizer's own copies: the rules are unchanged (`https:` only, no user name, password or explicit port, the host listed exactly in `iframeHosts`, six `allow` features, safe referrer policies). The viewer never renders an iframe of the page's own origin, whatever `iframeHosts` lists, so the README no longer warns about listing your own origin.
37
+
38
+ ### Added
39
+
40
+ - **Refresh document** in every view: it reads the shown document alone again with the refresh header, without reloading the tree — a labelled button in a bar above the document in the tree and single views, and an icon-only one named after the document in a bar pinned to the top of each multi-view panel's scroll area, whose state belongs to that panel.
41
+ It keeps the document and its scroll position while it reloads, announces a reloaded document, and shows a failed reload in a notice inside the bar that a newer copy of the document, a move to another document or the next refresh takes down (`refreshDocumentLabel`, `refreshDocument`, `refreshDocumentNamed`, `documentRefreshed`, `documentRefreshFailed`).
42
+ - **Embeds and link cards**: `defineExplorerConfig({ embeds: { providers, linkCardEndpoint } })` hands `@aiquants/markdown`'s embeds to the viewer. `providers` lists the ids of `EMBED_PROVIDER_IDS` (`youtube`, `x`, `instagram`, `threads`, `tiktok`, `bluesky`, `linkedin`, `reddit`, `note`, `niconico`) whose posts and videos a link alone in a paragraph embeds as a cross-origin frame (no provider script runs in the page), and `linkCardEndpoint` is the same-origin path of the host's link-card endpoint, which turns a link written as its own URL into a card.
43
+ Each field defaults to none (`null` is also none for the endpoint), so without the option such a link stays its paragraph; repeated ids are removed, and an unknown id, a malformed endpoint or an unknown key throws a `RangeError` (`[markdown-explorer] embeds…`).
44
+ The endpoint answers `GET <path>?url=<href>` with JSON `{ title?, description?, site_name?, image?, icon? }`; it must not set cookies or refresh credentials, because the request runs in the background and can be in flight during a sign-out, and a redirect, a 401, any other non-OK status or a non-JSON answer means no card.
45
+ Content security policy: `frame-src` adds `embedFrameHostsOf(config.embeds.providers)` to `'self'` and the `iframeHosts`, a policy with `connect-src` adds `embedConnectOriginsOf(config.embeds.providers)` (the Bluesky handle lookup), one with `img-src` allows `https:` for card images and icons, and a host that enables providers should send `Cross-Origin-Opener-Policy: same-origin`, which severs the opener of a popup that escapes a provider frame's sandbox.
46
+
47
+ ### Fixed
48
+
49
+ - **Fragments resolve as HTML resolves them**: within the pane, the fragment as the URL carries it and then the fragment percent-decoded once (as the URL standard decodes it), each looked up first as an element's id and then as the first `<a name>` with that value (before, only the decoded fragment was looked up, and only as an id). A fragment with a malformed escape (`#%zz`, `#%E6%A6`) no longer abandons the jump: the escape stays as written and a malformed UTF-8 sequence reads as U+FFFD.
50
+ So the explorer's own jumps now reach an `<a name>` anchor: a followed document link (which gives it the focus, as it does a heading), a multi-view panel's fragment-only link and a page view's alignment of the URL's fragment.
51
+ - **A client-side canonical redirect keeps the fragment**: reaching a non-canonical URL without the loader (`/docs#概要`, for example) replaces it by the canonical URL with the same fragment, as the browser does across the loader's HTTP redirect. A client-side navigation from another route still loses the fragment when the loader redirects it (React Router builds the new location from the redirect's `Location` alone), so link to canonical URLs.
52
+ - **A link alone in a paragraph is no longer lost**: the converter of `@aiquants/markdown` 5 replaced such a paragraph with a `<div data-plugin-type>` placeholder without the link, and the sanitizer removed the placeholder's attributes, so a YouTube or X URL or any bare URL vanished from the page.
53
+ The 6.0.0 converter keeps the paragraph and its link and marks it with `data-embed-url`, and the sanitizer keeps that mark, provider-agnostically, only where `placeholderLinkOf` of `@aiquants/markdown/embeds` accepts it against the paragraph's children with the link's sanitized `href`; a forged or altered mark leaves the plain paragraph, and the link stays in every case.
54
+
55
+ ## 0.1.0 (2026-10-07)
4
56
 
5
57
  The first release: a Markdown document explorer for React Router 8 framework mode, with a source tree beside the document, a multi-panel reading view and a full-page single view, behind server ports for authentication, storage and Markdown parsing.
6
58
 
package/README.md CHANGED
@@ -5,15 +5,21 @@ A Markdown document explorer for React Router 8 (framework mode): a source tree
5
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
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
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 tree's **Refresh** button (a visible label, also its accessible name) has the server reload the tree
8
+ The explorer's **Refresh tree** button (a visible label, also its accessible name) has the server reload the tree
9
9
  and answers it, then reads the documents again past the host's caches: shown ones in the background
10
10
  (replaced only when they changed), shown failures at once, a shown one still loading as soon as its
11
11
  request settles, the others when they are next opened. A tree that loads meanwhile never discards the
12
12
  reader's refresh; the newer of the two stays. A reloaded tree is announced; a failed reload keeps the
13
13
  tree under an alert naming the failure.
14
- The single view has its own **Refresh** in a toolbar row above the document, which reads the shown
15
- document again the same way (the document stays on screen, and a failed reload shows an alert).
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.
16
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 link card the explorer's own data route reads (`{dataPath}/link-card`, through `@aiquants/markdown`'s link-card handler: internal addresses never become cards); the link itself always stays, under the frame, as the card or as the paragraph.
17
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.
18
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.
19
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.
@@ -22,7 +28,8 @@ A single-view (embedding) page never downloads the tree and multi-panel views, w
22
28
 
23
29
  - React 19.2.7 or later and React Router 8 in framework mode (route modules with `loader` / `headers`).
24
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)`.
25
- - The peer packages, at these versions or later within the caret ranges the package declares: `@aiquants/markdown` ^4.0.0 (document rendering; its math diagnostics and gantt sizing), `@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.
31
+ - The peer packages, at these versions or later within the caret ranges the package declares: `@aiquants/markdown` ^6.1.0 (document rendering; its math diagnostics, gantt sizing, embeds, link cards and their sizes, the React-free `@aiquants/markdown/embeds` the sanitizer worker loads, and the Node-only link-card handler of `@aiquants/markdown/server`, which needs Node.js 22.13 or later — below the explorer's own floor),
32
+ `@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.
26
33
  The client depends on these floors, while a package manager may only warn about an older peer.
27
34
  - A Markdown parser of your choice on the server that produces HTML and its headings (the explorer sanitizes the HTML before it is sent).
28
35
  - 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).
@@ -53,7 +60,7 @@ export const explorerConfig = defineExplorerConfig({
53
60
  })
54
61
  ```
55
62
 
56
- `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 and the reader's default tree settings. A mistake throws at startup instead of producing links that point nowhere.
63
+ `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.
57
64
  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)).
58
65
 
59
66
  | Option | Type | Purpose |
@@ -65,7 +72,8 @@ The source ids are not part of it: they are the ids of the server's `sources` (a
65
72
  | `multiView?` | `{ maxPanels?, columns? }` | The panel limit (1–64, default 12) and the logical column count (1–6, default 4). |
66
73
  | `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`. |
67
74
  | `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. |
68
- | `iframeHosts?` | `readonly string[]` | Host names whose `https:` iframes documents may embed (exact match); none when omitted. |
75
+ | `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. |
76
+ | `embeds?` | `{ providers?, linkCards?, linkCardSize? }` | What `@aiquants/markdown` makes of a link alone in a paragraph. `providers` lists the ids (`EMBED_PROVIDER_IDS` of `@aiquants/markdown/embeds`: `youtube`, `x`, `instagram`, `threads`, `tiktok`, `bluesky`, `linkedin`, `reddit`, `note`, `niconico`) whose posts and videos documents embed as cross-origin frames, whatever the link's text. `linkCards: true` turns a link written as its own URL into a card, which the data route's own `link-card` operation serves (see [Link cards](#link-cards)): no route of yours is needed. `linkCardSize` picks the size of every card, `compact`, `banner` or `full` (`LINK_CARD_SIZES` of `@aiquants/markdown/embeds`; `@aiquants/markdown`'s `banner` when omitted), and needs `linkCards: true`. Each field defaults to none (`[]`, `false`, omitted), also when given as `undefined`, so without the option such a link stays its paragraph. Repeated ids are removed. An unknown id, a `linkCards` that is not a boolean, an unknown size, a size without `linkCards: true` or an unknown key (`linkCardEndpoint` included) throws a `RangeError` (`[markdown-explorer] embeds…`), and anything but a plain object a `TypeError`. The resolved `config.embeds` is the `MarkdownEmbeds` the viewer takes: `linkCardEndpoint` is `<dataPath>/link-card` when link cards are on and `null` otherwise, and `linkCardSize` is present only when given. See `@aiquants/markdown`'s README (Embeds) for the URLs each provider recognizes, the frames' sizes and the cards' sizes. |
69
77
  | `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)). |
70
78
 
71
79
  For example, `defaultTreeSettings: { sortMode: "desc" }` lists every folder's entries by name, Z to A, until the reader picks another order.
@@ -254,7 +262,7 @@ The full contract, including canonicalization, is in [URL contract](docs/behavio
254
262
  | Option | Type | Purpose |
255
263
  | --- | --- | --- |
256
264
  | `config` | `ExplorerConfig` | The shared configuration; the source ids come from `sources`. |
257
- | `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
+ | `authenticate` | `(request, mode) => Promise<ExplorerAuthentication<P>>` | `signed-in` with a principal, `signed-out`, or `forbidden`; `headers` (for example a refreshed session cookie) are added to every response. `mode` is an `ExplorerAuthenticationMode`, `{ readOnly: boolean }`: the page loader and every data operation but `link-card` pass `{ readOnly: false }`; the `link-card` operation passes `{ readOnly: true }`, and then the host only reads the session — it never refreshes a token, writes no cookie and returns no `headers` — because cards ask in the background and a refreshed cookie arriving after a sign-out would sign the reader back in. The explorer fails closed: a read-only authentication that carries any header is logged and answered as a failure (see [Link cards](#link-cards)). |
258
266
  | `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. |
259
267
  | `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. |
260
268
  | `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). |
@@ -267,7 +275,7 @@ The full contract, including canonicalization, is in [URL contract](docs/behavio
267
275
  | `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. |
268
276
  | `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. |
269
277
  | `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. |
270
- | `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
+ | `logger?` | `{ error(message, detail) }` | Where adapter failures and malformed source output are reported (default `console.error`). A tree or children entry with an empty name, or with a path the browser cannot address (empty, too long, a lone surrogate, a control character or a backslash — for example macOS's `Icon\r`), or outside its source's boundary, is left out together with its subtree and reported with the message `[markdown-explorer] <operation> left out an entry` and the detail `{ operation, sourceId, path, error }`, whose `error` names the reason; any other malformed listing becomes `failed`. A boundary that throws or answers malformed values is reported with the operation `boundary`, and so is the rejection of a promise a `contains` answers (it is not awaited and counts as outside). A failed authentication of a data request is reported with the operation `authenticate` and never quotes a header: `headers` that `Headers` cannot read are reported by a fixed message, and a `Response` that `authenticate` throws (React Router's `throw redirect(...)`) by its status alone; any other exception of your adapters is logged as thrown, so keep secrets out of it. |
271
279
  | `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. |
272
280
 
273
281
  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.
@@ -299,7 +307,7 @@ The published type declarations carry no doc comments, so the shapes a source re
299
307
  | `principal` | `P` | The reader, as `authenticate` returned it. |
300
308
  | `request` | `Request` | The request that asked for the call. |
301
309
  | `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. |
302
- | `refresh` | `boolean` | Whether the reader asked to reload from the source: the tree refresh, and every document request it triggered (`X-Markdown-Explorer: refresh`). A cache of your own in front of the source should be skipped and refilled when it is `true`. |
310
+ | `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`. |
303
311
 
304
312
  `tree(context)` returns the source's entries (its roots), and `children(context, path, readPath)` those of one lazy folder (`readPath` as for `document`):
305
313
 
@@ -346,7 +354,9 @@ A `text` document shows the beginning of a file under the file header (its name,
346
354
  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.
347
355
 
348
356
  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.
349
- 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, including the `data-plugin-type` embeds of `@aiquants/markdown`, is removed.
357
+ 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.
358
+ 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.
359
+ 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.
350
360
  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.
351
361
  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.
352
362
  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-*`),
@@ -445,18 +455,35 @@ and `Cache-Control: private, max-age=60, stale-while-revalidate=600, no-transfor
445
455
  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.
446
456
  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).
447
457
 
458
+ ### Link cards
459
+
460
+ With `embeds: { linkCards: true }`, the data route has a `link-card` operation: `GET` or `HEAD` `<dataPath>/link-card?url=<href>`, which `@aiquants/markdown`'s link card asks (same-origin, in the background, once the card nears the viewport). It is the only route of the cards: the explorer creates `@aiquants/markdown`'s `createLinkCardHandler` once, in `createMarkdownExplorer`, behind the read-only authentication, and passes every answer of it through unchanged — status, headers and cache headers. In order:
461
+
462
+ 1. A single-fetch request (`<dataPath>/link-card.data`) answers `not-found` (404), as every operation does; with link cards off, `link-card` answers `not-found` exactly as an unknown operation does, whatever the method.
463
+ 2. A method other than `GET` and `HEAD` answers 405 with `Allow: GET, HEAD`, before authenticating.
464
+ 3. `authenticate(request, { readOnly: true })` runs. `signed-in` lets the handler answer; `signed-out` and `forbidden` both mean no card (the handler's 401, `no-store`).
465
+ An authentication that carries any header — even a non-empty `headers` with `signed-out` — breaks the read-only contract: none of its headers is forwarded, the failure is logged once through `logger` (operation `authenticate`), and the answer is a `failed` failure (500, `private, no-store`, no `Set-Cookie`). An `authenticate` that throws is handled the same way (an `ExplorerError` answers its reason unlogged, as everywhere; an aborted request is not logged).
466
+ The log never quotes a header: `headers` that `Headers` cannot read are logged by a fixed message, and a `Response` that `authenticate` throws (React Router's `throw redirect(...)`) by its status alone.
467
+ 4. The handler answers: `200` with the card's JSON (`private, max-age=1800`), `204 No Content` with `X-Link-Card-Skip: <reason>` when the link gets no card (`private-network`, `forbidden-url`, `unreachable` or `not-html`), or its own `400`, `401` and `500` JSON with `no-store`. It never sets a cookie.
468
+
469
+ Internal addresses never become cards, in any environment: the handler refuses loopback, private, link-local and every other non-global address, internal names, names that resolve to such addresses and redirects to them, before connecting (see `@aiquants/markdown`'s README, Link-card endpoint). There is no option to allow them, in the handler or in the explorer. A skipped link renders as a compact card that keeps the link, and `@aiquants/markdown` writes one `console.info` line for it; the explorer logs nothing of its own for cards.
470
+
471
+ The cards need nothing extra in your content security policy: they ask the page's own origin, so `connect-src 'self'` covers them (only the cards' images and icons need `https:` in `img-src`, see [Hosting notes](#hosting-notes)). The handler needs Node.js 22.13 or later and keeps its own cache (cards 1 hour, skips as long as their `Cache-Control`, 1000 URLs) and connection pool per explorer.
472
+
448
473
  ### `anonymousAccess`
449
474
 
450
- Spread it into the configuration when your application has no sign-in: it provides `authenticate`, `principalKey` and `signedOutPage`.
475
+ Spread it into the configuration when your application has no sign-in: it provides `authenticate`, `principalKey` and `signedOutPage`. Its `authenticate` returns no `headers` in any mode, so it honours read-only mode as it is.
451
476
 
452
477
  ### Hosting notes
453
478
 
454
479
  - 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).
455
480
  - 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.
456
- 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; 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.
481
+ 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.
482
+ Link cards need no extra `connect-src`: the card endpoint is the data route's own `link-card` operation, same-origin.
483
+ 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.
484
+ 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.
457
485
  - 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 (`![](/path)`, `<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.
458
486
  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).
459
- Listing your own origin in `iframeHosts` lets a document frame any of your pages, with the reader's cookies; list it only when documents must frame it.
460
487
  - Set your server entry's `streamTimeout` above `deferredTimeoutMs`, so a slow source is fetched by the browser instead of failing the stream.
461
488
  - 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:
462
489
 
@@ -478,6 +505,11 @@ Spread it into the configuration when your application has no sign-in: it provid
478
505
  })
479
506
  ```
480
507
 
508
+ - **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),
509
+ 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.
510
+ 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.
511
+ 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.
512
+ 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.
481
513
  - 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:
482
514
  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).
483
515
  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.
@@ -530,7 +562,9 @@ A folder met again among its own ancestors (a shortcut inside its own target, or
530
562
 
531
563
  ### Localization
532
564
 
533
- `locale` selects a built-in catalog (`MARKDOWN_EXPLORER_LABEL_CATALOGS.en` / `.ja`); `labels` overrides single keys. The catalogs also name every control of the document viewer (the `viewer…` keys: its toolbar, the table of contents, the style and alignment menus and each Mermaid diagram's controls) 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. 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).
565
+ `locale` selects a built-in catalog (`MARKDOWN_EXPLORER_LABEL_CATALOGS.en` / `.ja`); `labels` overrides single keys.
566
+ 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.
567
+ 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).
534
568
 
535
569
  ## Layout
536
570
 
@@ -551,12 +585,13 @@ The reader's own explorer width is remembered per view as a percentage, and only
551
585
  ## Security model
552
586
 
553
587
  - 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:
554
- 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), `data-*` only for the link, image and footnote contract, and only `http:`, `https:`, `mailto:`, `tel:` and relative URLs (no `data:` URL in any element).
588
+ 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).
555
589
  - 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.
556
590
  - 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.
557
591
  - 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.
558
592
  - 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)).
559
593
  - 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.
594
+ - Link cards are read by `@aiquants/markdown`'s handler behind a read-only authentication (no token refresh, no `Set-Cookie`; an authentication that carries headers is refused and logged), and its guards keep every internal address from becoming a card; see [Link cards](#link-cards).
560
595
  - 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.
561
596
 
562
597
  Details: [Security](docs/behavior/security.md).
@@ -582,7 +617,9 @@ Tests and host styling may rely on the `data-testid` and `data-aqmx-*` attribute
582
617
  ## Demo
583
618
 
584
619
  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),
585
- 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, and footnotes, task lists and formulas in the Go converter's markup, and the end-to-end tests' fixtures. Run it with `pnpm run demo:dev` from the package folder.
620
+ 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,
621
+ every embed provider and link cards served by the data route (`embeds: { providers, linkCards: true }`; `guide/reference/embeds.md` and `guide/reference/link-cards.md` show them; the end-to-end tests answer the browser's card requests from their own fixtures, so they stay offline), and the end-to-end tests' fixtures.
622
+ Run it with `pnpm run demo:dev` from the package folder.
586
623
 
587
624
  ## License
588
625
 
@@ -1,8 +1,8 @@
1
1
  import { jsx as m, jsxs as S, Fragment as _e } from "react/jsx-runtime";
2
- import { u as k, F as ct, b as ge, f as Ie, d as it, E as dt, g as ut, h as mt, L as pt, i as ft } from "./index-BgkZ9ATV.js";
2
+ import { u as k, F as ct, d as ge, f as Ie, g as it, E as dt, h as ut, i as mt, L as pt, j as ft } from "./index-BJTmp0lR.js";
3
3
  import * as p from "react";
4
4
  import { use as ie, createContext as ve, useState as N, useCallback as x, useMemo as C, useRef as v, useLayoutEffect as P, useSyncExternalStore as de, useEffect as H, Suspense as De, useId as ue } from "react";
5
- import { C as ht, k as xt, l as He, v as wt, m as gt, n as vt, E as bt, o as qe, q as Et, s as yt, S as ce, t as Rt, F as St, A as Tt } from "./location-Cb2Ak55B.js";
5
+ import { C as ht, k as xt, l as He, v as wt, m as gt, n as vt, E as bt, o as qe, q as Et, s as yt, S as ce, t as Rt, F as St, A as Tt } from "./location-V5BKeWNs.js";
6
6
  import { useDirectoryTreeState as Lt, DirectoryTree as Nt } from "@aiquants/directory-tree";
7
7
  import { tapScrollCircleSampleVisual as kt } from "@aiquants/virtualscroll";
8
8
  import { s as qt } from "./storage-DA4ItHzl.js";
@@ -1104,7 +1104,7 @@ const ir = /* @__PURE__ */ p.forwardRef(cr), Ee = (e) => {
1104
1104
  },
1105
1105
  children: [
1106
1106
  /* @__PURE__ */ m(ge, { "aria-hidden": "true" }),
1107
- /* @__PURE__ */ m("span", { children: s.refresh })
1107
+ /* @__PURE__ */ m("span", { children: s.refreshTreeLabel })
1108
1108
  ]
1109
1109
  }
1110
1110
  ),