@aiquants/markdown
v7.0.0
Published
Markdown renderer for React with advanced features
Readme
@aiquants/markdown
Advanced Markdown renderer for React with support for various extensions and custom components.
Features
- Rich Markdown Support: Full support for CommonMark, GitHub Flavored Markdown (GFM), and LaTeX Math expressions.
- Offloaded Parser: CPU-heavy Markdown parsing and AST transformation are offloaded to a high-performance Go API server running
goldmarkandConnectRPC. - Browser Converter: the same Go converter, compiled to WebAssembly and run in a worker, converts in the browser exactly as the service does, byte for byte, without a round trip (
@aiquants/markdown/converter). - Custom Extensions & Plugins: GitHub alerts, Zenn-style directives (
:::message/:::details), single-line display math, and pluggable subscript, superscript, mark highlights, table cell merges, and opt-in embeds: posts and videos of ten SNS and video providers as script-free cross-origin frames, and link cards from the host's metadata endpoint. - Dynamic Configuration: Easily enable or disable custom extensions dynamically using parse options.
- Syntax Highlighting: Code blocks with Shiki syntax highlighting.
- TypeScript: Full TypeScript support with comprehensive type definitions.
Installation
npm install @aiquants/markdown
# or
pnpm add @aiquants/markdown
# or
yarn add @aiquants/markdownパーサーバイナリの配布
Markdown の解析は Go 製サービス (markdown-api) が担当します。このバイナリはプラットフォーム別のパッケージとして配布され、パッケージマネージャが実行環境に合うものだけを自動選択します (esbuild や Biome と同じ方式)。本体パッケージにバイナリは含まれません。
| プラットフォーム | 対応 | | --- | --- | | Linux x64 / arm64 | ✅ | | macOS x64 (Intel) / arm64 (Apple Silicon) | ✅ | | Windows x64 / arm64 | ✅ |
postinstall スクリプトは不要です (実行権限は npm の bin 契約で保証されます)。ライフサイクルスクリプトを禁止している環境でもそのまま動きます。
parseMarkdown は最初の呼び出し時にサービスが待ち受けていなければ自動起動します。プロセスマネージャ (supervisor / systemd など) で markdown-api を別プロセスとして管理している構成では、そのソケットへそのまま接続します。
サービスを自分でビルドし、その場所を教えてください。
git clone https://github.com/fehde-k/aiquants.git
cd aiquants/packages/go/markdown-api
go build -o dist/markdown-api .export MARKDOWN_API_BINARY=/path/to/markdown-api環境変数
| 変数 | 既定値 | 用途 |
| --- | --- | --- |
| MARKDOWN_SOCKET_PATH | (必須) | サービスと通信する UNIX ドメインソケットのパス (サービスも同じ変数を読む)。未設定か空なら parseMarkdown は変数を名指して失敗し、サービスは起動しない (パスを推し量らない) |
| MARKDOWN_SOCKET_MODE | 0600 | サービスのソケットの権限。0600 (所有者のみ) か 0660 (所有者とグループ) だけで、ほかの値ではサービスが値を名指して起動しない (ソケットの上では何も認証しないため、ほかの利用者へは開かない) |
| MARKDOWN_API_BINARY | (なし) | 自前ビルドしたバイナリの明示指定 |
| MARKDOWN_API_STARTUP_TIMEOUT_MS | 10000 | 自動起動したサービスが待ち受け開始するまでの猶予 |
| MARKDOWN_MAX_SOURCE_BYTES | 8388608 (8 MiB) | サービスが受け付ける Markdown の本文の最大 (@import で取り込んだ分を含む) |
| MARKDOWN_MAX_HTML_BYTES | 33554432 (32 MiB) | サービスが作る HTML の最大 (呼び出しの maxHtmlBytes はこれを下げることだけができる) |
| MARKDOWN_CONVERT_TIMEOUT | 30s | 変換 1 回の最長の時間 (Go の時間の書式。呼び出しの timeoutMs はこれを短くすることだけができる) |
後の 3 つはサービス (markdown-api) が読む。自動起動したサービスは呼び出し元のプロセスの環境を受け継ぐ。正の数 (時間は正の時間) 以外の値ではサービスが起動しない。
Usage
Basic Usage
import { MarkdownRenderer } from "@aiquants/markdown"
import type { MarkdownEmbeds } from "@aiquants/markdown/embeds"
import { parseMarkdown } from "@aiquants/markdown/server"
import "@aiquants/markdown/styles/markdown.css"
// A module constant: the providers whose posts embed, and the host's link-card endpoint (see Embeds)
const EMBEDS: MarkdownEmbeds = { providers: ["youtube", "x"], linkCardEndpoint: "/api/site_metadata" }
const MyComponent = async () => {
const markdownText = `# Hello World
This is a **bold** text with inline math: $y=x^2$ and subscript: H~2~O.
\`\`\`javascript
console.log("Hello, World!")
\`\`\`
`
// Parse the markdown using the Go parser server
const parseResult = await parseMarkdown(
markdownText,
"/srv/docs/notes/my-file.md",
"/srv/docs",
undefined, // the reader's OAuth token for cloud documents
{
deniedPathSegments: [".env*", "secrets", "*.pem"], // the host's deny rules ([] denies nothing)
maxHtmlBytes: 4 * 1024 * 1024
}
)
// parseResult: ParseResult
// htmlContent: string — レンダリング済み HTML
// headings: { text: string; depth: number; id: string }[] — 目次構築用の見出し一覧
return (
<MarkdownRenderer
htmlContent={parseResult.htmlContent}
theme="light"
embeds={EMBEDS}
/>
)
}htmlContent can go to MarkdownRenderer as parseMarkdown returns it: the converter renders raw HTML as written, and the renderer passes every element through its own allow list (see Raw HTML), under server rendering and in the browser alike. A host may sanitize the HTML before as well.
Parse Options
The fifth argument of parseMarkdown is ParseOptions (exported from @aiquants/markdown/server). deniedPathSegments and maxHtmlBytes are required.
const controller = new AbortController()
const parseResult = await parseMarkdown(
markdownText,
"/srv/docs/notes/my-file.md",
"/srv/docs",
undefined, // OAuth Token if needed
{
deniedPathSegments: hostDenyRules, // required; [] denies nothing
maxHtmlBytes: 4 * 1024 * 1024, // required; the largest HTML in bytes
resolveImports: false, // expand <!-- @import "path" --> (default false)
validateExistence: true, // probe local images and doc links (data-asset-exists / data-doc-exists; default true)
includeSourceLines: false, // annotate each block with data-source-lines="<first>-<last>" (default false)
signal: controller.signal, // cancels the conversion in the service too
timeoutMs: 10_000, // the conversion's deadline
enableSubscript: false, // Disable subscript (leaves ~2~ as plain text)
enableSuperscript: true, // Enable superscript
enableMark: false, // Disable highlight mark
enableTableMerge: true, // Enable table merges (> and ^)
enableEmbeds: true, // Mark a paragraph holding one web link (data-embed-url)
enableZennDirectives: true // Enable :::message / :::details
}
)| Option | Required | Meaning |
| --- | --- | --- |
| deniedPathSegments | ✅ | Deny rules applied to every target the conversion resolves: @import targets, images and links. A pattern matches one whole path segment (* matches any run of characters within it), compared after NFKC normalization and case folding at every percent-decoding level (the semantics of @aiquants/markdown-explorer's deny rules). Local targets must also stay inside workspaceRoot, also once symbolic links are resolved. A refused image renders without src, a refused link without a destination (with validateExistence, both are marked like a missing target, whether or not the target exists), and a refused import rejects the call. |
| maxHtmlBytes | ✅ | The largest HTML in bytes, a positive integer up to MAX_HTML_BYTES_LIMIT. The service stops while it renders and the call rejects with MarkdownResourceExhaustedError; the response the call accepts is bounded by twice this plus 64 KiB. |
| resolveImports | | Expand <!-- @import "path" --> directives (default false: a directive stays an HTML comment and nothing is read). Only Markdown (.md) targets the deny rules admit are read, nested imports included; a refused target rejects the call with permission_denied. |
| validateExistence | | Probe local targets (default true): a local storage image also gets data-asset-exists ("true" or "false") and a local doc link data-doc-exists ("true" or "false"). Cloud targets are never probed, and data-asset-src is written for every admitted storage image either way. |
| includeSourceLines | | Annotate every rendered block with data-source-lines="<first>-<last>", the 1-based lines of markdownText it came from, both inclusive (default false; see "Source lines" below). |
| signal | | Cancels the call; the service stops converting. |
| timeoutMs | | The conversion's deadline in milliseconds (a positive integer); the service stops at it or at MARKDOWN_CONVERT_TIMEOUT, whichever comes first. |
| enable* | | Switch the syntax extensions and HTML plugins (each enabled unless false). The text plugins leave summary text (a :::details title included) as written. |
Invalid options throw TypeError / RangeError before any request. A conversion stopped at a size limit (the HTML limit, the service's source limit or a message limit) rejects with MarkdownResourceExhaustedError (its cause is the connect error); every other failure rejects with the ConnectError of the service (invalid_argument, permission_denied, canceled, deadline_exceeded, internal).
Image and link targets
The converter writes document paths, never URLs, so a host decides how its files are served.
An image destination is a URL decoded once:  names the file a%41.png, while  names aA.png (a % not followed by two hexadecimal digits, as in 100%.png, may be written as it is).
- Storage images (a local file inside
workspaceRoot, or agcs:///gdrive://object) have nosrc. Their document path is indata-asset-src, unescaped: the path fromworkspaceRootfor a local file (資料/進捗100%.png,x.data), the URI decoded once for a cloud object (gcs://bucket/reports/fig 1.png). The host writes thesrcfrom it, encoding the path for its own route; no URL is signed. - Existence: with
validateExistence, a local storage image also carriesdata-asset-exists("true"or"false"); a cloud image is never probed. - Refused targets (the deny rules, or outside
workspaceRoot) have neither asrcnordata-asset-src; withvalidateExistencethey carrydata-asset-exists="false"whether or not they exist. - Storage links are
data-link-kind="doc": they keep the authoredhref(its fragment is what a host reads) and carry the document path indata-doc-path; a local one also getsdata-doc-existsundervalidateExistence. data-asset-srcanddata-asset-existscome only from the converter: markers written in the document's raw HTML or by an HTML plugin are removed.- Of an element's duplicate attributes only the first counts, as in a browser: the converter drops the rest, so the HTML carries no duplicate attribute and a repeated
srcor marker cannot make the converter and the browser read different values.
MarkdownRenderer shows an image without a src (or with data-asset-exists="false") as a placeholder naming its data-asset-src, so HTML rendered before the host wrote the URLs shows which files are missing instead of blank space.
Source lines
With includeSourceLines, every rendered block carries data-source-lines="<first>-<last>": the 1-based lines of markdownText it came from, both inclusive, so an editor can open the source at the block a reader points at. With resolveImports, the lines count the text with every import expanded.
- A leaf block counts its own lines (a fenced code block from its opening fence, which holds the info string); a container (a list, an item, a blockquote, a table, an alert, a Zenn container, a definition list, the footnotes) spans its blocks. A setext heading counts its text, not its underline; an alert, its blocks, not the
[!NOTE]line. - A thematic break and a raw HTML block carry none (the converter never wraps an author's HTML).
- The HTML with source lines is the HTML without them plus the attribute alone.
- The attribute comes only from the converter: a
data-source-lineswritten in raw HTML or a heading's{…}attributes is removed whether or not the option is on. - The viewer keeps the attribute only in the converter's shape, and moves the attribute of a
preit replaces (a highlighted code block, a Mermaid diagram) onto the replacement's root.
@aiquants/markdown/source-lines holds the contract without React or any package, for host sanitizers and editors: SOURCE_LINES_ATTRIBUTE ("data-source-lines"), the type SourceLines ({ first, last }) and parseSourceLines(value), which returns null for any value the converter could not have written.
import { parseSourceLines, SOURCE_LINES_ATTRIBUTE } from "@aiquants/markdown/source-lines"
const block = target.closest(`[${SOURCE_LINES_ATTRIBUTE}]`)
const lines = parseSourceLines(block?.getAttribute(SOURCE_LINES_ATTRIBUTE))Browser converter
@aiquants/markdown/converter runs the service's Go converter, compiled to WebAssembly, in a worker, so a browser converts a document exactly as the service does, without a round trip: with existence validation off and @import directives left as written, the HTML and the headings are the service's byte for byte (the package's end-to-end tests compare both on the same documents). A host serves two assets and passes their URLs, here with Vite:
import { createMarkdownConverter } from "@aiquants/markdown/converter"
import converterWorkerUrl from "@aiquants/markdown/converter/worker?worker&url"
import converterWasmUrl from "@aiquants/markdown/converter.wasm?url"
const converter = createMarkdownConverter({ workerUrl: converterWorkerUrl, wasmUrl: converterWasmUrl, timeoutMs: 10_000 })
const { htmlContent, headings } = await converter.convert(
{
markdownText,
filePath: "notes/my-file.md", // the document's path under workspaceRoot, or its gcs:// / gdrive:// URI
workspaceRoot: "/workspace", // any absolute path: the browser has no workspace
options: { deniedPathSegments: hostDenyRules, maxHtmlBytes: 4 * 1024 * 1024, includeSourceLines: true },
},
controller.signal,
)- Options are
BrowserParseOptions:ParseOptionswithoutresolveImports,validateExistence(the browser has no storage),signalandtimeoutMs(the call has its own). They are validated asparseMarkdownvalidates them. - Paths:
filePathis read as the service reads it. Give a local document's path relative to any absoluteworkspaceRoot: the converter writes workspace-relative document paths (data-asset-src,data-doc-path), the same as the service with the real root. The deny rules apply as written; symbolic links are not resolved, since there is no file system, so the service stays the authority when the host serves a target. - One conversion at a time, in call order. One aborted before it starts never runs; one aborted while it runs rejects at once with the signal's reason and its answer is dropped, since a WebAssembly call cannot be interrupted.
timeoutMscounts from the moment the converter is loaded; past it the converter stops its worker, rejects withMarkdownConversionTimeoutErrorand starts a new worker for the next conversion. - Errors:
MarkdownResourceExhaustedErrorpast a size limit (the same class./serverexports; the browser applies the service's default limits, an 8 MiB source and a 32 MiB HTML, lowered bymaxHtmlBytes),MarkdownConversionTimeoutError, andMarkdownConversionErrorwithcode:invalid_argumentfor a refused request,internalwhen the converter failed on the document,unavailablewhen the converter could not load or was disposed (every later call fails the same way; create a new converter to retry).dispose()stops the worker. - Assets:
converter.wasmis about 9.1 MiB (gzip 2.0 MiB, brotli 1.6 MiB); serve it asapplication/wasm, so it compiles while it downloads, and compressed. The first conversion starts the worker, compiles the module and starts Go (about 0.4 s in Chromium); a 700-line document then converts in about 35 ms, the worker's round trip included. The worker is one self-contained script that starts with thewasm_exec.jsof the Go toolchain that built the module, under its BSD license;dist/converter/NOTICES.mdlists the licenses of everything linked into the module. - Content Security Policy: a page that sets
script-srcneeds'wasm-unsafe-eval'in it, andworker-src 'self'(or ascript-srcthat covers the worker) for the same-origin worker. - Building from source needs the Go toolchain on PATH:
pnpm run buildcompilescmd/markdown-wasmofpackages/go/markdown-apiand bundles the worker with the same toolchain'swasm_exec.js(pnpm run build:converteralone).
Viewer, embeds and links
MarkdownRenderer keys every top-level block by its content: a fingerprint of the block's HTML without data-source-lines, and its occurrence among equal blocks. When the document around a block changes (a paragraph added above, the lines it came from moved), the block keeps its components, so a highlighted code block, a Mermaid diagram or an embedded frame is not built again; only a block whose content changed is.
Stylesheets
| Import | Content |
| --- | --- |
| @aiquants/markdown/styles/markdown.css | The components' own styles (Tailwind build) |
| @aiquants/markdown/styles/github-markdown.css | GitHub document style (markdown-body) |
| @aiquants/markdown/styles/zenn-content.css | Zenn document style (znc) |
| @aiquants/markdown/styles/katex.min.css | KaTeX's own stylesheet, shipped with the fonts it references (fonts/ next to it) at the version of the bundled katex |
| @aiquants/markdown/styles/details.css / message.css | <details> and message blocks |
The document stylesheets size their text in rem, so the document follows text-only zoom (the browser's default font size, the root font-size) together with the viewer's controls; at the default 16 px root they look exactly as GitHub's and Zenn's own stylesheets.
Raw HTML
Every renderer of the package — MarkdownRenderer (and so MarkdownViewer) for the converter's HTML, and NodesRenderer's raw HTML nodes and HTML containers for an mdast tree — renders raw HTML through one allow list. It runs on the tree html-react-parser builds, right where the tree is rendered, so it needs no DOM and holds the same under server rendering and in the browser; nothing is serialized and parsed again.
- Removed with their content:
script,style,template,noscript,noembed,noframes,plaintext,xmp,title,head,meta,link,base,object,embed,applet,param,frame,frameset,select,datalist,track,math,foreignObject, every iframe that is not an allowed embed (see Embeds), and everyinputexcept a disabled checkbox (a task-list item). - Kept: the elements Markdown produces and common text and structure elements (
a,img,p,div,span, headings, lists, tables,blockquote,pre,code,details,summary,aside,section,figure,sup,sub,mark,video,audio,source…) and, for icons, ansvgwithgandpathinside it (anything else inside ansvgis removed). - Unwrapped (the element is dropped, its children render): every other element —
form,button,textarea,label,font,center, custom elements and the like — so its text survives. - Attributes:
title,lang,dir,role,hidden,translate,aria-*, a reducedclass,idandstyle, and each element's own (href,src,alt,width,height,colspan,rowspan,align,open,start…; a link keepsname, the fragment target of older documents). Thedata-*attributes the renderers read survive on their element only:data-link-kind,data-doc-path,data-doc-exists,data-footnote-refanddata-footnote-backrefon links,data-asset-srcanddata-asset-existson images, anddata-footnoteson sections. A paragraph'sdata-embed-urlnever renders: when it is a safe URL the paragraph becomes an embed placeholder (see Embeds); the 5.xdivmarkers (data-plugin-type,data-video-id,data-tweet-id,data-url) are dropped. Event handlers,target,rel,name(except on links),srcset,usemap,ping,formaction,xlink:href,tabindexand every other attribute are dropped. - Attribute names are read as an HTML parser stores them: in lower case, and of names that differ only in case the first one written. Kept attributes carry lower-case names, so
<p STYLE="text-align:center">or<a HREF="https://…">renders the same on the server (where html-react-parser keeps the author's case) and in the browser, and hydrates without a mismatch. classkeeps only the package's own tokens: the converter's markup (markdown-alert,markdown-alert-{note,tip,important,warning,caution},markdown-alert-title,octicon,octicon-{info,light-bulb,report,alert,stop},mr-2,message,alert,info,warning,message-symbol,message-content,markdown-details,markdown-details-content,footnotes,footnote-ref,footnote-backref,math,inline,display) and a code block's language (language-*). An author's tokens and utility classes (fixed inset-0 z-50…) are dropped, so a document can neither restyle the page nor lay an element over it; a link'sclassName(also the onelinkComponentreceives) holds only the kept tokens and the package's link classes. Tables carry their alignment inalignandstyle, not in classes.idsurvives only on a heading (the table of contents, in-document links and a host's fragment navigation name headings by it), on the converter's footnotes (fnref:N/fnrefK:Non a reference'ssup,fn:Non a note'sli) and in the user-content namespace (user-content-…, the prefix GitHub-style footnotes use; write raw HTML fragment targets with it, or as<a name="…">). Every other element loses its id, so a document cannot take a name the page or a script looks up (getElementById, the named properties ofwindow). A heading keeps whatever id the document gives it: a host should not look up its own elements by an id a document could also give a heading (prefer refs).- Host sanitizers: a host that sanitizes the HTML before the renderer (as
@aiquants/markdown-explorerdoes with DOMPurify) gets the intersection of both allow lists; DOMPurify keepsclassandidby default, so on the page the rules above are the ones that apply. - URLs (
href,src,cite,poster,data-embed-url):http:,https:,mailto:andtel:in any letter case, and references without a scheme.javascript:,data:and every other scheme are dropped, however they are spelled (entities, spaces, tabs, control characters). The mdastLinkNodeandImageNode, link cards (their link, image and icon) and the table of contents' entries follow the same rule. stylekeeps only its last validtext-aligndeclaration (the alignment of table cells); positioning,url()and every other declaration are dropped. An SVGfillthat references anything (url(…)) is dropped.
Embeds
Embeds are opt in. MarkdownRenderer and MarkdownViewer take embeds={{ providers, linkCardEndpoint, linkCardSize }} (type MarkdownEmbeds from @aiquants/markdown/embeds; linkCardSize is optional); the default NO_EMBEDS renders every link alone in a paragraph as that paragraph. Pass the value from a module constant (the viewer also compares it by value). An unknown provider id, a bad endpoint or an unknown card size throws a RangeError at render.
import type { MarkdownEmbeds } from "@aiquants/markdown/embeds"
const EMBEDS: MarkdownEmbeds = { providers: ["youtube", "x", "instagram", "bluesky"], linkCardEndpoint: "/api/link-card", linkCardSize: "banner" }
<MarkdownViewer {...props} embeds={EMBEDS} iFrameAllowedDomains={["www.youtube.com"]} />What a link alone in a paragraph becomes. The converter marks a paragraph whose only element is one link to an absolute http(s) URL without a user name or password (besides white space, U+00A0 and U+3000) with data-embed-url, and keeps the paragraph and the link. The renderer then decides, in this order:
- The marked link must be the paragraph's only link, with the same sanitized
href(placeholderLinkOf); otherwise the paragraph renders as written. - The fallback is a link card when
linkCardEndpointis set and the link's text is the URL itself (isBareLink), otherwise the paragraph. - An enabled provider that recognizes the URL embeds its post or video, whatever the link text says, with the paragraph's own link as the caption; otherwise the fallback renders.
The link is always kept: under the frame, as the card, or as the paragraph.
| Provider id | URLs it recognizes | Frame origin |
| --- | --- | --- |
| youtube | www.youtube.com, youtube.com, m.youtube.com: /watch?v= (any position; si, list, feature ignored), /shorts/ID, /live/ID, /embed/ID; youtu.be/ID; www.youtube-nocookie.com/embed/ID | https://www.youtube-nocookie.com |
| x | x.com, twitter.com (www., mobile.): /USER/status/ID (with /photo/N or /video/N), /i/web/status/ID | https://platform.twitter.com |
| instagram | www.instagram.com, instagram.com, instagr.am: /p/CODE, /reel/CODE, /USER/p/CODE, /USER/reel/CODE | https://www.instagram.com |
| threads | www.threads.com, threads.com, www.threads.net, threads.net: /@USER/post/CODE | https://www.threads.com |
| tiktok | www.tiktok.com, tiktok.com: /@USER/video/ID, /player/v1/ID, /embed/v2/ID | https://www.tiktok.com |
| bluesky | bsky.app/profile/ACTOR/post/RKEY (a DID, or a handle looked up at https://public.api.bsky.app) | https://embed.bsky.app |
| linkedin | www.linkedin.com, linkedin.com: /feed/update/URN, /embed/feed/update/URN, /posts/…-activity-ID-… | https://www.linkedin.com |
| reddit | www.reddit.com, reddit.com, old.reddit.com, new.reddit.com: /r/SUB/comments/ID[/SLUG] | https://embed.reddit.com |
| note | note.com/USER/n/KEY | https://note.com |
| niconico | www.nicovideo.jp, nicovideo.jp: /watch/ID; nico.ms/ID | https://embed.nicovideo.jp |
Every host must match exactly (look-alike hosts, user info, ports and URLs carrying a provider URL in their query never match), and each path segment must match its own grammar. Short links without an id, Facebook, Pinterest, TikTok photo posts and LinkedIn articles have no provider: as bare URLs they become cards.
Frames. No provider script ever runs in the page: posts and videos are cross-origin iframes with the registry's attributes (an allow within the six shared features, a sandbox, a referrer policy, loading="lazy"), titled "<provider>: <label>". Every iframe — provider frames, iframes written in documents and the Dify plugin — mounts only on the client after hydration, and never when its origin is the page's own origin, whatever the host allows.
The server HTML holds the link and a reserved box (width: 100% up to the provider's maximum, its aspect ratio or height, never a CSS min-width), hidden for readers without JavaScript. A frame whose box is narrower than the provider's minimum (YouTube 356 px, Shorts 200 px, X 250 px, Instagram 326 px, Threads 281 px, Bluesky 300 px, LinkedIn 251 px, niconico 229 px) shows the fallback instead, and mounts again when the pane widens. X follows the primary subtag of the page's <html lang>; X, Threads and Bluesky follow theme.
Frames that report their height (X, Instagram, Threads, Bluesky, Reddit, note) are sized through one message listener that accepts only the frame's own window from its exact provider origin.
Link cards. A card asks GET <linkCardEndpoint>?url=<href> (same-origin credentials, redirect: "error") once an IntersectionObserver rooted at the viewer's scroller sees it, through a page-wide queue (two requests at once, 256 answers kept, aborted when no card needs the answer).
It links to the written href (never og:url), takes the host name from the href, and shows the image and the icon only when the endpoint gives absolute http(s) URLs of at most 2048 characters (LINK_CARD_URL_LIMIT) that are not on the page's own host, whatever the scheme or port (a browser loads an http: image of an https: page over https:, and cookies ignore the port), with no referrer. The package asks no favicon service.
linkCardSize picks the size of every card:
| linkCardSize | The card |
| --- | --- |
| "banner" (default) | Image, title (two lines), description (one line) and site · host, 7.5rem tall, as wide as the column up to 37.5rem (600px) and left-aligned. The image is 1.91:1 from a card width of 32rem, square from 22rem, hidden below. |
| "full" | Image, title and description (two lines each) and site · host, 8.5rem tall, across the column. The image is 1.91:1 from 40rem, square from 22rem, hidden below. |
| "compact" | The favicon (or a neutral link icon), the title on one line and site · host, 3.75rem tall, as wide as its text up to 37.5rem. No image and no description. |
A pending card has the height of the chosen size, so an answer never shifts the page. A link the endpoint gives no card (a 204 No Content with a reason, below) becomes a compact card whatever the size: a leading icon — a building for private-network, a globe otherwise — then the host name and the URL, marked data-link-card="compact" and data-link-card-reason="<reason>". It may shrink a pending banner or full card; Chromium's scroll anchoring keeps the visible content in place when that happens above the viewport. An error answer renders the paragraph.
Every link that gets no full card writes one console.info line naming the link and the reason, for example [markdown] No link card for <href> (private-network); an error answer names its cause instead — HTTP <status> for another status, HTTP 204 without a known skip reason for a 204 whose X-Link-Card-Skip is missing or unknown, HTTP 200, not JSON or HTTP 200, not a JSON object for a 200 that is not a JSON object, network error for a failed request — and a card that unmounts before its answer writes nothing.
The card's look — light and dark (data-theme), hover, keyboard focus ring — comes from styles/markdown.css, outside every cascade layer, so the GitHub and Zenn display styles cannot override it; every size shares that one frame.
The endpoint contract:
GET <endpoint>?url=<href>answers JSON{ title?, description?, site_name?, image?, icon? }; other fields (urlincluded) are ignored.- A link that gets no card is answered
204 No Contentwith the reason inX-Link-Card-Skip(LINK_CARD_SKIP_HEADER):private-network,forbidden-url,unreachableornot-html(LINK_CARD_SKIP_REASONS, read withlinkCardSkipReasonOf). A 204 without one of them is an error answer. - It must not set cookies or refresh credentials: the request runs in the background and can be in flight during a sign-out.
- A redirect, any other non-OK status or a non-JSON answer is an error answer: no card, the paragraph.
createLinkCardHandlerfrom@aiquants/markdown/serverimplements this contract (see Link-card endpoint).
Iframes written in documents. iFrameAllowedDomains lists the hostnames such an iframe may load from: the src must be https:, without a user name, a password or an explicit port, and its hostname must match an entry exactly. Every other iframe is removed (fail closed), including an iframe without src (srcdoc). An allowed iframe is rendered from validated values and keeps only title, width, height, allow, allowfullscreen, sandbox, referrerpolicy, loading, frameborder and scrolling (no id or class).
An iframe without a title gets "<embeddedFrame>: <host>" (for example Embedded content: www.youtube.com), marked with the built-in label's language.
allow keeps only the six shared frame features — autoplay, clipboard-write, encrypted-media, fullscreen, picture-in-picture, web-share — each without its allowlist (camera; microphone; autoplay becomes autoplay); referrerpolicy is kept only as no-referrer, origin, same-origin, strict-origin or strict-origin-when-cross-origin. Until it mounts, and for good when its origin is the page's, the iframe renders as <span data-embed-frame="pending|refused"> holding a link to its src.
An iframe inside a link never mounts and stays that text, without a nested link: interactive content may not sit inside an <a>, and activating the link would follow the author's URL instead of the frame.
An iframe plugin (data-plugin-type="dify") receives the validated iframe as structured props, never as an HTML string. The Dify plugin acts only on messages its own frame's window posts from an allowed origin, and ignores a message that is not an object with a string type.
Labels. embedLabels (embeddedVideo, embeddedPost, embeddedFrame; built-in "embedded video", "embedded post", "Embedded content", in DEFAULT_MARKDOWN_EMBED_LABELS) name the frames; MarkdownViewer takes the same keys in labels.
DOM hooks. figure[data-embed-provider][data-embed-state="pending|frame"] with its box [data-embed-box], the width probe [data-embed-measure], span[data-embed-frame] for iframes written in documents, and a[data-link-card="pending|ready|compact"][data-link-card-size="compact|banner|full"] (a compact card of a skipped link also carries data-link-card-reason).
Content Security Policy. Put embedFrameHostsOf(embeds.providers) and the hosts of iFrameAllowedDomains in frame-src, embedConnectOriginsOf(embeds.providers) in connect-src (Bluesky handles), and allow https: images in img-src for card images and icons. Provider frames may open popups that escape their sandbox, so a host that enables providers should send Cross-Origin-Opener-Policy: same-origin.
@aiquants/markdown/embeds holds the placeholder contract, the frame policy, the provider registry and the card contract without React, for host sanitizers, configuration and CSP. @aiquants/markdown/embeds/testing holds captured provider messages and embedProviderStubPage(url) for offline end-to-end tests.
Link-card endpoint
createLinkCardHandler(options) from @aiquants/markdown/server (Node only) is the card endpoint as a standard handler, (request: Request) => Promise<Response>. It reads only the request's method and its url search parameter, never its path, so mount it on any path and pass that path as embeds.linkCardEndpoint. Create it once, at module scope: each handler owns its cache and its connection pool. It uses undici and htmlparser2, which no client entry loads (@aiquants/markdown/embeds stays free of React and Node).
A React Router resource route (app/routes/api.link-card.ts, for example):
import { createLinkCardHandler } from "@aiquants/markdown/server"
import type { LoaderFunctionArgs } from "react-router"
import { readSignedInReader } from "~/session.server" // the host's read-only session lookup
const linkCards = createLinkCardHandler({ authorize: async (request) => (await readSignedInReader(request)) !== undefined })
export const loader = ({ request }: LoaderFunctionArgs) => linkCards(request)Any server whose handlers take a Request and return a Response:
import { createLinkCardHandler } from "@aiquants/markdown/server"
const linkCards = createLinkCardHandler({ authorize: () => true }) // a public site: every reader may ask
export const handle = (request: Request): Promise<Response> =>
new URL(request.url).pathname === "/api/link-card" ? linkCards(request) : Promise.resolve(new Response(null, { status: 404 }))The handler can also be called from inside another loader, for example a data route that dispatches several operations: it answers the request it is given.
Options. authorize: (request) => boolean | Promise<boolean> is the only option, and it is required. Anything but true answers 401 before anything is fetched; a host that serves everyone passes () => true. It must only read the request — no cookie writes, no token refresh — because cards ask in the background and a request can still be in flight while the reader signs out. A rejection propagates to the host. createLinkCardHandler throws a TypeError when authorize is not a function and for any other option.
Answers. Every answer carries X-Content-Type-Options: nosniff and never Set-Cookie; a HEAD answer has the GET answer's status and headers and no body.
| Request | Status | Body | Cache-Control |
| --- | --- | --- | --- |
| A method other than GET and HEAD | 405 (with Allow: GET, HEAD) | {"error":"Method Not Allowed"} | no-store |
| authorize does not return true | 401 | {"error":"Unauthorized"} | no-store |
| No or an empty url | 400 | {"error":"Missing 'url' parameter"} | no-store |
| A link that gets no card | 204 (with X-Link-Card-Skip: <reason>) | none | per reason, below |
| An unexpected exception | 500 | {"error":"Internal Server Error"} | no-store |
| A card | 200 | { title?, description?, site_name?, image?, icon? } (LinkCardResponseBody) | private, max-age=1800 |
The JSON answers are application/json; charset=utf-8. The reasons of a 204:
| Reason | When | Cache-Control |
| --- | --- | --- |
| private-network | The host is internal by name or by IP literal, it resolves to or connects to a private or special-use address, or a redirect leads to such a host | private, max-age=1800 |
| forbidden-url | The URL or a redirect target is not http(s), carries a user name or password, or does not parse | private, max-age=1800 |
| unreachable | A network error, a timeout, a non-OK answer of the linked site, too many redirects or an unparsable Location | private, max-age=300 |
| not-html | The page's Content-Type is missing or not HTML | private, max-age=300 |
Internal addresses never become cards, in any environment; there is no switch. Internal is decided by address, never by the company's domain:
- a loopback, private, link-local, CGNAT, multicast, documentation, benchmarking, discard-only or reserved IPv4 or IPv6 address (every IANA special-purpose range that is not globally reachable) written as the host, in every form the URL parser normalizes (
2130706433,0x7f.1,127.1,[::ffff:127.0.0.1], and the NAT64, 6to4, Teredo and IPv4-translated forms); - an internal name:
localhost,*.localhost, a single-label name, one without a dot (the server's DNS search list would complete it to an internal name), and names under.local,.internaland.home.arpa, with or without a trailing dot; - a name that resolves to an internal address: every connection resolves through a lookup that refuses a name when any of its A or AAAA answers is private, and a
net.BlockList(honoured bynet.connectfrom Node 22.13, whichenginesrequires) checks the very address connected to — forhttpstoo, whose TLS handshake runs over a TCP socket opened with the block list — so DNS rebinding cannot slip past; - every redirect hop, checked again before it is followed.
What the card shows. The title is the last og:title, falling back to the first <title> of the document's head when that og:title is blank; the description is the last og:description or <meta name="description">; the site name the last og:site_name; the image the last og:image; the icon the first <link rel> holding the token icon (no /favicon.ico is guessed). A later blank <meta> clears an earlier one.
Only the head is read: parsing stops at the first body content, skips the content of a <noscript>, <template> or <noframes> in the head as a browser with scripting does, and stops at an element nesting deeper than 256, after 4096 start tags and at a tag with more than 64 attributes, so a hostile page costs time and memory linear in the bytes read. Text is trimmed and cut at the card's limits (title 300, description 1000, site name 200 code points), so linkCardMetadataOf reads every body back unchanged.
The image and the icon are resolved against the first <base href> — itself resolved against the page's URL, as a browser does, and used when it is http(s) and at most 2048 characters — else against the page's URL after redirects, and kept only when they are at most 2048 characters (LINK_CARD_URL_LIMIT) and the URL check accepts them.
Only text/html and application/xhtml+xml answers are read, decoded by WHATWG encoding sniffing: a byte order mark, then the charset of Content-Type, then a <meta charset> or <meta http-equiv="Content-Type"> in the first 1024 bytes, else UTF-8 (Shift_JIS, EUC-JP, ISO-2022-JP and every other label TextDecoder knows; an unknown label is ignored).
Limits. 5 s per page for the headers, the body and its parsing; 5 redirects, each followed by hand and checked again; 64 KiB of response headers; at most the first 8 MiB of the body, read as a stream and parsed as it arrives, yielding to the event loop between chunks, and the download stops once the head has been read.
DNS goes through c-ares, never through getaddrinfo on libuv's threadpool, so a name whose DNS never answers holds none of the threads the host's fs, zlib and crypto share: A and AAAA queries to the DNS servers of the system's resolver configuration (the hosts file is not read), 1 s for the first attempt and 2 attempts per server; a name connects over IPv4 when it has an A record, else over IPv6.
Each handler caches cards per URL for 1 hour and skips as long as their Cache-Control, 1000 URLs at most; the key is the URL as the URL parser serializes it without its fragment, so spellings of one URL share one entry, and concurrent requests for one URL share one fetch.
Logs. The handler logs through the package's logger, never with a URL's user name or password (a refused URL that is not http(s) is logged by its scheme alone): policy refusals (private-network, forbidden-url) at info, network errors, unparsable Locations, too many redirects and unexpected exceptions at error, and a body cut at the size limit at warn.
Links
- A link is external when its
hrefnames a scheme in any case (https:,HTTPS:,mailto:) or another host (//host/…,/\host/…), whatever itsdata-link-kindsays, and whendata-link-kindisexternal. An external link opens in a new tab withrel="noreferrer"and is never handed tolinkComponent. The author'stargetandrelnever survive. linkComponentreceiveshref,kind,docPath,docExists,className,childrenandattributes: the link'sid,title,lang,dir,role,aria-*anddata-footnote-*(event handlers,styleandhref-like attributes never pass). Spreadattributesonto the anchor you render so footnote references keep theiridandaria-describedbyand back-references keep theiraria-label:
const AppLink = ({ href, className, attributes, children }: MarkdownLinkProps) => (
<Link to={href} className={className} {...attributes}>
{children}
</Link>
)- Only a link that wraps an image or block content is laid out as a box (
inline-block); a link around text stays inline, so a footnote reference inside<sup>keeps the height of its glyphs and shows its focus ring.
Content column and alignment
MarkdownViewer lays the document out in one column, [data-aqmd-content], inside the scroll container's padding:
| Prop | Effect |
| --- | --- |
| width | The column's CSS width. Unset, the column is 100% wide: the full width of the scroll container's track, whatever the content, so a short document and a diagram drawn at the column's width get the whole width |
| maxWidth | The column's CSS max-width ("1200px", "min(1200px, 100%)" …). Unset, nothing caps the column beyond width |
| align | Places a column narrower than the track: left (margin-inline: 0 auto), center (auto) or right (auto 0). It shows only when width or maxWidth makes the column narrower than the track |
The alignment button needs onAlignChange, showAlign (on by default) and a width or maxWidth. Without either of the two, the column fills the track and no alignment can move it, so the viewer does not render the button, whatever showAlign says.
With the GitHub and Zenn styles of DEFAULT_MARKDOWN_STYLES, a wide table or a long code line scrolls inside its own box. Content with a fixed width wider than the column, such as an embedded <iframe> whose width attribute exceeds it, overflows the column, and so does any wide content under the None style, which loads no stylesheet; the scroll container then scrolls sideways.
Mermaid diagrams
A fenced code block in the mermaid language renders as MermaidDiagram, a block that takes its containing block's width (its slot). The slot's first child is a hidden measuring element, [data-aqmd-mermaid-measure] (no height, clipped, visibility: hidden, aria-hidden), present while the diagram loads, shows or fails; the component measures the slot's width with it.
Each drawing gets its own drawing host, [data-aqmd-mermaid-host]: a hidden element (visibility: hidden, aria-hidden, absolutely positioned just outside the start edge, contain: strict) appended to the main document's <body>, with its width set to the measured width in px.
Mermaid draws in that element and the component shows the result in the slot, so a gantt chart, whose width Mermaid takes from where it is drawn, is exactly as wide as the slot. Because Mermaid looks its drawing up through the main document, the host lives in the main document's light DOM, and a diagram in a slot inside a shadow root is drawn the same way.
- The component removes a drawing host when its drawing settles (shown, failed, or discarded as stale) and when the component unmounts or its text changes, so none is left behind, not even the error drawing Mermaid leaves for a text it cannot parse.
- Nothing is drawn while the slot has no width (inside a closed
<details>the browser does not lay out, or a hidden panel): the loading text stays until the slot gets a width. A drawing already under way when the slot loses its width finishes at the width it was given. - When the width of a slot showing a gantt chart changes (a splitter drag, a window resize), the chart is drawn again at the new width 200 ms after the last change, unless the slot then has no width or the width of the latest drawing. The shown chart stays until the new one replaces it, and the new one starts at the initial zoom and pan. Other diagram types take their size from their text and are never drawn again for a width change.
- A drawing that finishes after a newer one started, after the diagram's text changed or after the component unmounted is discarded.
- Touch and pen: the diagram's
<svg>and its container taketouch-action: noneonly in the modes whose pan starts with a plain drag (pan,both), where a finger or pen drag pans the diagram. In every other mode (both-shiftby default,pan-shift,zoom,none) the component writes notouch-action, so the browser default applies and a finger or pen scrolls the document over the diagram as anywhere else. The value follows the menu: switching the mode rewrites it on both elements, and the component removes it (with the other styles Panzoom wrote) when it tears its Panzoom instance down.
Viewer toolbar
MarkdownViewer shows its alignment, style and table-of-contents buttons as one fixed row, in that visual and tab order, spaced in rem so they never overlap at large text sizes. Place the row with two package-scoped custom properties, both 0px unless set:
| Property | Effect |
| --- | --- |
| --aqmd-toolbar-inset-block-start | Offset from the top of the toolbar's containing block |
| --aqmd-toolbar-inset-inline-end | Offset from the inline end of the containing block (added to the scrollbar clearance: 24 px for the native scrollbar, or the measured width of a host scroll bar, see below) |
The viewer reads no other package's variable. Where the toolbar is fixed to the viewport and must clear something of the host's own (a pinned side menu, for example), bind the property to that width in the host's layout, on an ancestor of the viewer:
.my-layout {
--aqmd-toolbar-inset-inline-end: var(--my-side-menu-width, 0px);
}A viewer inside a containing block of its own (an element with contain: layout paint, a transform) needs no binding: the row sits at that block's edge.
The scroll container ([data-aqmd-scroll-container]) publishes --aqmd-toolbar-block-overlap: the depth of its top band that the floating toolbar covers, 0px at widths where the toolbar covers none of the content. The viewer already reserves it as top padding and scroll-padding-block-start, so the first line and fragment targets stay clear of the buttons; read it when you place your own content at the start of the container.
The value is measured again whenever the container, the content or the toolbar resizes, the window resizes, the alignment changes (the content moves sideways), anything scrolls (a toolbar fixed to the viewport stays put while the container moves; at most once per frame), and the two inset properties change (an invisible box inside the toolbar, [data-aqmd-toolbar-inset-probe], takes its size from them).
Each menu's popover opens below the toolbar at its inline end, whichever button opened it, and is never wider than the scroll container less the toolbar's distance from its inline end, so the popover stays inside the container at any width and text size. The scroll container is an inline-size query container (container-type: inline-size) for that measure; the menu activated last stacks on top.
An open, unpinned popover closes on Escape while the focus is inside its menu, returns the focus to its button and calls preventDefault() on that keydown, so an outer Escape handler can skip a key the viewer consumed. Escape is not consumed while no popover is open.
Host scroll bar
renderScrollBar?: (scroller: HTMLElement) => ReactNode (type MarkdownScrollBarRenderer) lets the host put its own scroll bar beside the document, for example a control that scrolls by tapping. The package does not provide the bar; it provides the seam:
<MarkdownViewer {...props} renderScrollBar={(scroller) => <MyScrollBar target={scroller} />} />Given the prop, the viewer renders
| Element | Role |
| --- | --- |
| [data-aqmd-scroll-row] | A flex row as large as the viewer's own box |
| [data-aqmd-scroll-clip] | The row's first cell (flex: 1 1 0%, overflow: clip), holding the scroll container |
| [data-aqmd-scroll-bar] | The slot at the row's inline end (flex: none), as tall as the row; its child is stretched to the full height |
The function receives the scroll container, [data-aqmd-scroll-container], the same element with the same hooks and classes as without the prop. It is called on every render once the container is mounted, before the first paint. The bar reads and writes the container's scroll position itself (scrollTop, scrollHeight, clientHeight, scroll events).
Only the native vertical scrollbar is hidden, in every engine: the scroll container always reserves it (overflow-y: scroll, so its thickness is constant), its border box (box-sizing: border-box, whatever the host's default) is widened by the measured thickness (width: calc(100% + <thickness>px)), and the clipping cell cuts that strip off.
The native horizontal scrollbar keeps its native look, spans exactly the visible width and stays usable, as do the wheel, the keyboard and touch in both directions. No ::-webkit-scrollbar or scrollbar-width rule is involved, so a host's scrollbar-color or scrollbar-width neither breaks the hiding nor is overridden. The cell uses overflow: clip rather than hidden, so nothing can scroll the cell itself, and it creates no containing block, so the fixed toolbar is neither moved nor clipped by it.
Coverage: classic (space-taking) scrollbars are hidden in every browser that supports overflow: clip (Chromium 90, Firefox 81, Safari 16). An overlay scrollbar (macOS by default, mobile browsers, Firefox and GTK overlay modes) takes no space, measures 0 px and is drawn over the content while scrolling, so it stays visible there; the right-to-left inline end is handled the same way.
The thickness and the slot's width are measured in the scroll container's own CSS px, which a CSS zoom or a transform above it makes differ from the viewport's: a native scrollbar keeps its size on screen, so under zoom: 1.1 its 15 px are 13.64 of the container's px.
The container is positioned (position: relative) and holds an invisible, zero-tall box stretched across its padding box, [data-aqmd-scroll-bar-probe] (aria-hidden, out of flow); the thickness is the container's rect width less that box's, converted by the ratio of the container's rect width to its computed width, less its side borders.
It is rounded up to a whole px, because the clip must never leave any of the native bar showing: the price is less than 1 px of the container's own px cut off its inline-end padding and the end of the native horizontal scrollbar (0.36 px at zoom: 1.1, none where the thickness is whole).
Both are measured before the first paint and again whenever the container or the slot resizes and the window resizes (a page zoom changes a scrollbar's thickness in CSS px). The floating toolbar keeps the slot's measured width from the inline end instead of a native scrollbar's 24 px, and each popover's maximum width uses the same clearance.
Everything else keeps using the native scroller: fragment and table-of-contents scrolling with scroll-padding-block-start, the scroll position kept across a display-style change, link-card visibility observation, embed minimum widths measured from the content column, and the browser's scroll anchoring. Without the prop, the scroll container is the viewer's root element with its native scrollbars, and no row, cell or slot is rendered. Switching between given and omitted moves the scroll container onto another element, which starts at the top.
The layout is the exported HostedScrollArea (next section), which hosts can use for scrollers of their own.
Hosted scroll area
HostedScrollArea (props HostedScrollAreaProps) is the layout behind renderScrollBar, exported so a host can give any scroller of its own (an image canvas, a media stage, a panel's scroll area) the same bar. It renders the caller's scroller and, given renderScrollBar, the same row, clipping cell, slot and probe as the viewer: the host's bar replaces the native vertical scrollbar, and the native horizontal one stays.
<HostedScrollArea
scrollerRef={canvasRef}
scrollerProps={{ "data-my-scroller": "", className: "canvas", style: { blockSize: "100%", overflow: "auto" } }}
renderScrollBar={(scroller) => <MyScrollBar target={scroller} />}
rowStyle={{ flex: "1 1 0%" }}>
<img src={src} alt="" />
</HostedScrollArea>| Prop | Meaning |
| --- | --- |
| scrollerProps | The scroller's own attributes (type HostedScrollerProps: data-* hooks, class, inline style, ARIA, handlers), put on the <div> that scrolls, in their order. A hook the host finds with closest() or querySelector() therefore sits on the real scroller, not on a wrapper. |
| scrollerRef | The caller's ref to that element: an object ref, or a callback ref (one that returns a cleanup gets the cleanup instead of a call with null). |
| children | The scroller's content. |
| renderScrollBar | The host's bar (MarkdownScrollBarRenderer). Omitted, the area renders the scroller alone with the caller's props unchanged and measures nothing, so a host can use it unconditionally. |
| rowClassName, rowStyle | Place the row in its parent, with a bar only: the row takes the scroller's place there, so a flex item's flex and minimum sizes go here. rowStyle is merged over the row's own display: flex; inline-size: 100%; block-size: 100% and zero minimum sizes. |
| onBarInlineSizeChange | Called with the slot's measured width, in the scroller's own CSS px, whenever it changes, before the paint that shows it. The width is 0 before the first call and returns to 0 when the bar is removed. The viewer keeps its floating toolbar clear of the bar with it. |
With a bar, the area owns four of the scroller's styles, position: relative, box-sizing: border-box, overflow-y: scroll and width: calc(100% + <thickness>px), and appends the probe as its last child; the rest of scrollerProps.style and the class are kept, so the horizontal overflow stays the caller's. The scroller must be as tall as the clipping cell (block-size: 100%). The hiding, the measurement under zoom and their coverage are those described in the previous section.
Table of contents
Activating an entry closes an unpinned table of contents, reveals the heading if it is hidden, scrolls to it (instantly under prefers-reduced-motion: reduce) and moves the focus to it (adding tabindex="-1" when the heading has none), so the next Tab continues from that section. Pass onHeadingSelect(heading, event) to take part: return false to let the viewer reveal, scroll and focus after your own work (writing the URL hash, say), or true when you have handled the navigation yourself.
The entry of a heading without an id (# ★, whose text keeps no character) is its text alone, not a link: no fragment names that heading, so neither headingHref nor onHeadingSelect is called for it.
Any other entry links to # followed by the heading's id. Where the page's URL does not address the document (several documents side by side, a document chosen by state), pass headingHref(heading) (MarkdownHeadingHrefResolver) to give each entry the heading's own address, so copying the entry's address or opening it in a new tab with a middle click reaches the heading; pass a stable function, since the table of contents is built again when it changes.
The href goes through the package's URL rule (an entry with a refused href has none), and activating an entry still runs onHeadingSelect and the viewer's navigation. A Ctrl-, Cmd- or Shift-click is an activation, so return true from onHeadingSelect when a modifier key is down to let the browser open the address instead.
The viewer stops the link's own navigation, so it reveals the heading the way a browser reveals the fragment it navigates to:
- Every closed
<details>around the heading opens, except one whose own summary (its first<summary>child, which shows while the<details>is closed) holds the heading. A heading in a second<summary>belongs to the hidden content, so its<details>opens. - Each element from the heading upward that has
hidden="until-found"(in any letter case) receives a bubblingbeforematchevent, innermost first, and then loseshidden. - An open
<details>and a plainhiddenare left as they are.
<MarkdownViewer
{...props}
onHeadingSelect={(heading) => {
history.replaceState(null, "", `#${heading.id}`)
return false
}}
/>// Module level, so the function stays the same between renders
const headingHref: MarkdownHeadingHrefResolver = (heading) => `/docs/guide.md#${encodeURIComponent(heading.id)}`
<MarkdownViewer
{...props}
headingHref={headingHref}
onHeadingSelect={(_heading, event) => event.ctrlKey || event.metaKey || event.shiftKey}
/>Labels and languages
Every name and text the viewer draws comes from labels (MarkdownViewerLabels), including the table-of-contents and alignment controls and, through the mermaid* keys, the controls of each Mermaid diagram (MermaidDiagram takes MermaidDiagramLabels as labels).
A label you pass carries no language of its own and is read in your page's language.
A built-in label marks the element that bears it with its own language: the status texts and style controls are English (lang="en"), the table-of-contents, alignment and Mermaid menu controls are Japanese (lang="ja"), and the Mermaid zoom buttons are English. The embed labels (embeddedVideo, embeddedPost, embeddedFrame) are English. DEFAULT_MARKDOWN_VIEWER_LABELS, DEFAULT_MERMAID_DIAGRAM_LABELS and DEFAULT_MARKDOWN_EMBED_LABELS hold the built-in texts.
Settings without React
@aiquants/markdown/viewer-settings exports the built-in document styles and the alignments without loading React, for modules that also run on the server (configuration, validation): DEFAULT_MARKDOWN_STYLES, MarkdownStyleOption, ContentAlign, CONTENT_ALIGNS, DEFAULT_CONTENT_ALIGN and toContentAlign. @aiquants/markdown re-exports the same values.
import { DEFAULT_MARKDOWN_STYLES, toContentAlign } from "@aiquants/markdown/viewer-settings"Extended Syntax
The parser supports the following extended markdown syntax in addition to CommonMark and GFM.
Heading ids
The converter gives every heading its id; MarkdownViewer and MarkdownRenderer render the ids of the HTML and assign none.
- Written id: end the heading's last line with
{#id}, or with{id="…"}when the id holds a space or ASCII punctuation other than_,-,:and.(a}, say).## 概要 {#overview}gets the idoverview.- It is kept exactly as written, in any script.
- No automatic id takes it, wherever the heading is.
- When two headings write the same id, both keep it and a fragment names the first.
- An id attribute that names nothing — empty (
{#},{id=""}) or not a string ({id=123}) — is removed, and the heading gets its automatic id.
- Automatic id: GitHub's, as github-slugger 2.0.0 makes it from the heading's text:
- lower-cased;
- punctuation and symbols removed;
- letters, marks, digits,
_and-of every script kept; - each space becomes
-; - a repeated id gets
-1,-2, …. - Examples:
概要→概要,API の使い方→api-の使い方,Step 2: Install→step-2-install, a second重複→重複-1. - A heading whose text keeps no character (
★) has no id, itsParseResult.headings[].idis"", and its table-of-contents entry is plain text, not a link. - The text is the heading's text as GitHub reads it: no Typographer substitution (
A -- B→a----b), white space as written, an image's alt and raw HTML left out. The spaces and tabs before an ATX closing sequence or a{…}block are not text (# Title ##→title). - Where the converter parses a construct differently from GitHub, the id follows the converter's rendering:
$ x $(text here, a formula on GitHub), the number of a footnote referenced from inside another note, a code span continued on an indented line,<<x>>, Zenn directives and definition lists.
- Anchors in raw HTML: the viewer keeps
<a name="…">and ids starting withuser-content-.<a id="user-content-faq"></a>is reached by#user-content-faq. It removes any other author id (see Raw HTML).
Links written for GitHub or VS Code ([概要へ](#概要), [使い方](#api-の使い方)) land on the heading.
LaTeX Math Expressions
- Inline math:
$y=x^2$. A single$follows thetex_math_dollarsrule of Pandoc 3.12: the opening$must be followed by a character that is not white space, and the formula ends at the first$after it that is not escaped by\and not inside a\text{…}group whose braces close; that$must follow a character that is not white space (a character escaped by\, such as the space in$a\ $, is not) and must not be followed by an ASCII digit; otherwise the$stays text. Stricter than Pandoc, white space is Unicode's (the ideographic space included) and a$at the start of a line never closes. So$20,000 and $30,000,$100〜$200,$HOME and $PATHand$ x $are text, while$x$,$a\$b$,$a\ $and$\text{for all $x$}$are formulas; write a literal dollar as\$or inside a code span. - Within a line of text,
$$ … $$is inline math too (Cost $$ x $$ hereis the formulax): it ends at the next run of as many$, with white space allowed inside its ends. An inline formula may continue on the paragraph's next line. - Display math: a multi-line block, or a whole line
$$ e^{i\theta} = \cos\theta + i\sin\theta $$(at most three spaces of indentation; it can follow a paragraph line directly). Both forms render the same. Inside code blocks the lines stay code. - A multi-line block opens on a line of two or more
$whose rest holds no$(that rest is not part of the formula) and closes on a line of two or more$alone. Any other$$ … $$— within a line of text, or followed by more text on its line ($$E=mc^2$$.) — is inline math in its paragraph. - A formula reaches KaTeX exactly as written:
0<x<1,a & b, quotes andx^2 + y^2included (the converter escapes the formula's text once, the viewer reads the text back once, and the subscript, superscript and highlight syntax never applies inside a formula).
$$
e^{i\theta} = \cos\theta + i\sin\theta
$$Every renderer (MarkdownRenderer, MarkdownViewer, NodesRenderer) renders a formula with the same KaTeX options: throwOnError: false (an invalid formula shows its source in red) and display math in display mo
