From cca4c69a03239a035a0c7deca49597a7b103baf2 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 2 Jul 2026 15:09:28 +0800 Subject: [PATCH 1/2] feat(md): render Markdown footnotes via markdown-it-footnote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register `markdown-it-footnote` in the `@void/md` compiler so `text[^1]` references and `[^1]: definition` blocks render into a linked footnote list. Three changes in the compiler: - Import `markdown-it-footnote` and register it after the emoji plugin and before the anchor plugin, so footnote back-reference markers are not themselves treated as heading-anchor targets. - Give each markdown-it call its OWN fresh env in `compile()`. Previously `parse` and `renderAsync` shared one `env` object; because markdown-exit's `renderAsync` re-parses internally, footnote state accumulated into the same `env.footnotes` twice — producing duplicated footnote lists and wrong fn/fnref ids. A fresh env per call keeps the two parses independent, and heading/TOC extraction is unaffected (anchor ids derive per-parse from the identical content, not from shared env state). - markdown-it-footnote@4 ships no types, so add an ambient module shim in `env.d.ts` matching the existing attrs/emoji/container shims (no `@types` dependency needed). Verified: `@void/md` build + typecheck + lint pass; all 87 package tests pass, including new tests asserting a footnote renders exactly once (single fn1/fnref1/footnotes block) and that headings/TOC are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guide/pages-routing/markdown.md | 12 ++++++++++++ packages/md/README.md | 3 ++- packages/md/package.json | 1 + packages/md/src/compile.ts | 18 +++++++++++++++--- packages/md/src/env.d.ts | 5 +++++ packages/md/test/unit/compile.test.ts | 21 +++++++++++++++++++++ pnpm-lock.yaml | 8 ++++++++ 7 files changed, 64 insertions(+), 4 deletions(-) diff --git a/docs/guide/pages-routing/markdown.md b/docs/guide/pages-routing/markdown.md index e0fd00c64..41846b5a6 100644 --- a/docs/guide/pages-routing/markdown.md +++ b/docs/guide/pages-routing/markdown.md @@ -437,6 +437,18 @@ Import code from external files: Shortcodes convert to unicode: `:tada:` becomes :tada:, `:rocket:` becomes :rocket:. +### Footnotes + +Reference a footnote inline and define it anywhere in the document: + +```md +Here is a statement with a footnote.[^1] + +[^1]: And here is the footnote body. +``` + +The references and definitions render into a linked footnote list at the end of the content. + ### Attributes Add classes, IDs, or attributes to any element: diff --git a/packages/md/README.md b/packages/md/README.md index 782feb433..64ad2b316 100644 --- a/packages/md/README.md +++ b/packages/md/README.md @@ -115,6 +115,7 @@ Full VitePress-parity feature set: - **Custom containers** — `:::tip`, `:::info`, `:::warning`, `:::danger`, `:::details` - **GitHub alerts** — `> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`, `> [!IMPORTANT]`, `> [!CAUTION]` - **Emoji** — `:emoji_name:` shortcodes +- **Footnotes** — `text[^1]` refs with matching `[^1]: definition` blocks - **Attributes** — `{.class #id}` on elements via `markdown-it-attrs` - **Anchor links** — auto-generated heading anchors - **Table of contents** — `[[toc]]` directive @@ -143,7 +144,7 @@ import '@void/md/theme.css'; // or theme-content.css ## Tests -- `test/unit/compile.test.ts` — compilation pipeline: basic, frontmatter, title extraction, headings, Shiki, emoji, anchors +- `test/unit/compile.test.ts` — compilation pipeline: basic, frontmatter, title extraction, headings, Shiki, emoji, footnotes, anchors - `test/unit/extract-script.test.ts` — script extraction: island imports, client code, edge cases - `test/unit/plugins.test.ts` — markdown-it plugins: containers, GitHub alerts, links, images, line numbers, emoji, TOC - `test/unit/emit-vue.test.ts` — Vue component emission with and without islands diff --git a/packages/md/package.json b/packages/md/package.json index 74b433bf8..9b47351c0 100644 --- a/packages/md/package.json +++ b/packages/md/package.json @@ -47,6 +47,7 @@ "markdown-it-attrs": "^4.3.1", "markdown-it-container": "^4.0.0", "markdown-it-emoji": "^3.0.0", + "markdown-it-footnote": "^4.0.0", "pathe": "catalog:", "shiki": "^4.0.2", "tinyglobby": "^0.2.16" diff --git a/packages/md/src/compile.ts b/packages/md/src/compile.ts index 70f05dd00..b529dbed4 100644 --- a/packages/md/src/compile.ts +++ b/packages/md/src/compile.ts @@ -12,6 +12,7 @@ import type { RuleCore, Token } from 'markdown-exit'; import anchor from 'markdown-it-anchor'; import attrs from 'markdown-it-attrs'; import { full as emoji } from 'markdown-it-emoji'; +import footnote from 'markdown-it-footnote'; import { createHighlighter, bundledLanguages, isSpecialLang, type BundledLanguage } from 'shiki'; import { containerPlugin } from './plugins/containers.ts'; import { gitHubAlertsPlugin } from './plugins/github-alerts.ts'; @@ -137,6 +138,11 @@ async function createCompiler(options?: CompileOptions): Promise { // Emoji shortcodes markdown.use(emoji); + // Footnotes: `text[^1]` refs with matching `[^1]: definition` blocks. + // Registered after emoji and before anchor so footnote back-reference + // markers are not themselves treated as heading-anchor targets. + markdown.use(footnote); + // Heading anchors // @ts-expect-error markdown-it-anchor types reference @types/markdown-it, structurally incompatible with markdown-exit markdown.use(anchor, { @@ -173,10 +179,16 @@ export async function compile(source: string, options?: CompileOptions): Promise const { data: frontmatter, content } = matter(source); const compiler = await getCompiler(options); - const env: Record = {}; - const tokens = compiler.parse(content, env); + // Each markdown-it call gets its OWN fresh env. `renderAsync` re-parses + // internally, so sharing one env across `parse` and `renderAsync` makes + // stateful plugins double-accumulate — markdown-it-footnote would append its + // footnote refs/defs to the same `env.footnotes` twice, producing duplicated + // footnote lists and wrong fn/fnref ids. A fresh env per call keeps the two + // parses independent. Heading/TOC extraction is unaffected: anchor ids are + // derived per-parse from the (identical) content, not from shared env state. + const tokens = compiler.parse(content, {}); const headings = extractHeadings(tokens); - const html = await compiler.renderAsync(content, env); + const html = await compiler.renderAsync(content, {}); const title = (frontmatter.title as string) ?? headings.find((heading) => heading.depth === 1)?.text ?? ''; diff --git a/packages/md/src/env.d.ts b/packages/md/src/env.d.ts index 2c8d82823..cbad8236a 100644 --- a/packages/md/src/env.d.ts +++ b/packages/md/src/env.d.ts @@ -12,6 +12,11 @@ declare module 'markdown-it-container' { export default plugin; } +declare module 'markdown-it-footnote' { + const plugin: (md: unknown, ...args: ReadonlyArray) => void; + export default plugin; +} + declare module '*.vue' { import type { DefineComponent } from 'vue'; const component: DefineComponent<{}, {}, unknown>; diff --git a/packages/md/test/unit/compile.test.ts b/packages/md/test/unit/compile.test.ts index 9cabbc33a..a46d1d94e 100644 --- a/packages/md/test/unit/compile.test.ts +++ b/packages/md/test/unit/compile.test.ts @@ -87,6 +87,27 @@ describe('compile', () => { expect(result.html).toContain('my-heading'); }); + it('renders footnotes exactly once (no double-accumulation from shared env)', async () => { + const result = await compile('Here is a footnote reference.[^1]\n\n[^1]: The footnote body.'); + // Ref marker, back-reference target, and the footnote block each appear once. + expect(result.html.match(/id="fnref1"/g)).toHaveLength(1); + expect(result.html.match(/id="fn1"/g)).toHaveLength(1); + expect(result.html.match(/class="footnotes"/g)).toHaveLength(1); + expect(result.html.match(/class="footnote-item"/g)).toHaveLength(1); + expect(result.html).toContain('The footnote body.'); + }); + + it('keeps heading extraction intact alongside footnotes', async () => { + const result = await compile('# Title\n\n## Section\n\nBody with a note.[^n]\n\n[^n]: note'); + // Fresh-env fix must not regress TOC/heading ids. + expect(result.headings).toMatchObject([ + { depth: 1, text: 'Title' }, + { depth: 2, text: 'Section', slug: 'section' }, + ]); + expect(result.html).toContain('id="section"'); + expect(result.html.match(/class="footnotes"/g)).toHaveLength(1); + }); + it('handles empty source', async () => { const result = await compile(''); expect(result.html).toBe(''); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7dc9746d2..a81deec32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,9 @@ importers: markdown-it-emoji: specifier: ^3.0.0 version: 3.0.0 + markdown-it-footnote: + specifier: ^4.0.0 + version: 4.0.0 pathe: specifier: 'catalog:' version: 2.0.3 @@ -7456,6 +7459,9 @@ packages: markdown-it-emoji@3.0.0: resolution: {integrity: sha512-+rUD93bXHubA4arpEZO3q80so0qgoFJEKRkRbjKX8RTdca89v2kfyF+xR3i2sQTwql9tpPZPOQN5B+PunspXRg==} + markdown-it-footnote@4.0.0: + resolution: {integrity: sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==} + markdown-it@14.1.1: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} hasBin: true @@ -15863,6 +15869,8 @@ snapshots: markdown-it-emoji@3.0.0: {} + markdown-it-footnote@4.0.0: {} + markdown-it@14.1.1: dependencies: argparse: 2.0.1 From c5f62f9c70f44381bb3fc719c5b365dc6d4b0325 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 2 Jul 2026 16:30:44 +0800 Subject: [PATCH 2/2] fix(md): install footnote plugin in metadata compiler The metadata compiler (extractMetadata) installed only markdown-it-anchor, so a footnote ref in a heading such as `## API[^1]` stayed literal there while compile() (which does install footnote) strips it. That produced a different slug/text in `@void/md/pages` than the rendered anchor id, so sidebar/TOC/search links pointed at a missing anchor. Register footnote before anchor to mirror compile.ts, keeping metadata heading slugs in sync with rendered ids. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/md/src/metadata.ts | 7 +++++++ packages/md/test/unit/metadata.test.ts | 28 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 packages/md/test/unit/metadata.test.ts diff --git a/packages/md/src/metadata.ts b/packages/md/src/metadata.ts index 230e85af8..98ed14bc6 100644 --- a/packages/md/src/metadata.ts +++ b/packages/md/src/metadata.ts @@ -2,6 +2,7 @@ import matter from 'gray-matter'; import MarkdownExit from 'markdown-exit'; import type { Token } from 'markdown-exit'; import anchor from 'markdown-it-anchor'; +import footnote from 'markdown-it-footnote'; import type { Heading } from './compile.ts'; let metadataCompiler: MarkdownExit | null = null; @@ -11,6 +12,12 @@ function getMetadataCompiler(): MarkdownExit { return metadataCompiler; } const markdown = new MarkdownExit({ html: true, linkify: true }); + // Footnotes must run before anchor so a `text[^1]` ref in a heading is turned + // into a footnote_ref token (excluded from heading text) rather than staying + // literal. Keeps metadata heading text/slug in sync with the rendered anchor + // ids from `compile()`; otherwise `@void/md/pages` links break for such + // headings. Registration order mirrors `createCompiler` in compile.ts. + markdown.use(footnote); // @ts-expect-error markdown-it-anchor types reference @types/markdown-it, structurally incompatible with markdown-exit markdown.use(anchor, { permalink: anchor.permalink.ariaHidden({ placement: 'before' }), diff --git a/packages/md/test/unit/metadata.test.ts b/packages/md/test/unit/metadata.test.ts new file mode 100644 index 000000000..f3e272d01 --- /dev/null +++ b/packages/md/test/unit/metadata.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { compile } from '../../src/compile.ts'; +import { extractMetadata } from '../../src/metadata.ts'; + +describe('extractMetadata', () => { + it('extracts headings, title, and frontmatter', () => { + const result = extractMetadata('---\ntitle: Doc\n---\n\n# Heading\n\n## Section'); + expect(result.title).toBe('Doc'); + expect(result.frontmatter.title).toBe('Doc'); + expect(result.headings).toMatchObject([ + { depth: 1, text: 'Heading', slug: 'heading' }, + { depth: 2, text: 'Section', slug: 'section' }, + ]); + }); + + it('strips footnote refs from heading text/slug to match rendered anchor ids', async () => { + const source = '## API[^1]\n\nBody[^1].\n\n[^1]: note'; + + const meta = extractMetadata(source); + expect(meta.headings).toMatchObject([{ depth: 2, text: 'API', slug: 'api' }]); + + // The metadata slug must equal the id actually rendered by compile(), + // so links built from `@void/md/pages` resolve to a real anchor. + const compiled = await compile(source); + expect(meta.headings[0]?.slug).toBe(compiled.headings[0]?.slug); + expect(compiled.html).toContain('id="api"'); + }); +});