Skip to content

Commit eaf079b

Browse files
Brooooooklynclaude
andauthored
feat(md): render Markdown footnotes via markdown-it-footnote (#243)
## What Adds `markdown-it-footnote` support to `@void/md` so Markdown footnotes render: ```md Here is a statement.[^1] [^1]: And here is the footnote body. ``` This upstreams a yarn patch we currently apply downstream against `@void/md`. ## Why `@void/md` previously had no footnote plugin, so `[^1]` refs/definitions passed through as literal text. A subtle second bug blocked a naive add: `compile()` shared **one** `env` object across `compiler.parse()` and `compiler.renderAsync()`. Because `markdown-exit`'s `renderAsync(src, env)` re-parses internally (`this.renderer.renderAsync(this.parse(src, env), …)`), a stateful plugin like footnote would append its refs/definitions to the same `env.footnotes` **twice** — producing duplicated footnote lists and wrong `fn`/`fnref` ids. ## How Three edits in `packages/md/src/compile.ts`: 1. `import footnote from 'markdown-it-footnote'`. 2. `markdown.use(footnote)` — registered **after** the emoji `full` plugin and **before** `anchor`, so footnote back-reference markers aren't treated as heading-anchor targets. 3. Give each markdown-it call its **own fresh `env`** (`compiler.parse(content, {})` / `compiler.renderAsync(content, {})`) instead of a shared object, fixing the double-accumulation. Heading/TOC extraction is unaffected — anchor ids derive per-parse from the identical content, not from shared env state. Supporting changes: - `markdown-it-footnote@^4.0.0` added to `@void/md` dependencies (minimal lockfile addition). - v4 ships **no** types, so an ambient module shim was added to `src/env.d.ts` matching the existing `markdown-it-attrs`/`emoji`/`container` shims — no `@types` devDependency needed. - Docs updated (`packages/md/README.md` feature list + `docs/guide/pages-routing/markdown.md` Footnotes section). > Note: this repo releases via `v*` git tags (no changesets), so no changeset was added. ## Verified - `vp run --filter ./packages/md build` — passes - `vp run --filter ./packages/md typecheck` — passes - `vp lint packages/md` — 0 warnings/errors - `vp test run packages/md` — **87 passed** (10 files), including 2 new tests: - a footnote renders **exactly once** (single `fn1`/`fnref1`/`class="footnotes"`/`footnote-item`) - headings/TOC intact alongside footnotes (`slug: 'section'`, `id="section"` present) - Render proof (throwaway, since deleted) confirmed the exact output: ```html <p>Ref here.<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup></p> <hr class="footnotes-sep"> <section class="footnotes"> <ol class="footnotes-list"> <li id="fn1" class="footnote-item"><p>The footnote body. <a href="#fnref1" class="footnote-backref">↩︎</a></p> ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Scoped markdown rendering and metadata changes with targeted unit tests; no auth, security, or data-path impact. > > **Overview** > **Adds footnote rendering** to `@void/md` via `markdown-it-footnote` (`[^1]` refs and definitions → linked footnote list), with docs in the guide and package README. > > **Fixes a compile pipeline bug** where one shared markdown-it `env` was passed to both `parse()` and `renderAsync()`; because `renderAsync` re-parses internally, stateful plugins (footnotes) would double-accumulate. Each call now uses a fresh `{}` env. > > **Aligns lightweight metadata extraction** with full compile: footnotes register before heading anchors so footnote markers in headings are stripped from `@void/md/pages` heading text/slugs and match rendered anchor ids. Type shim added for the untyped plugin dependency. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c5f62f9. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 83307db commit eaf079b

9 files changed

Lines changed: 99 additions & 4 deletions

File tree

‎docs/guide/pages-routing/markdown.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,18 @@ Import code from external files:
437437

438438
Shortcodes convert to unicode: `:tada:` becomes :tada:, `:rocket:` becomes :rocket:.
439439

440+
### Footnotes
441+
442+
Reference a footnote inline and define it anywhere in the document:
443+
444+
```md
445+
Here is a statement with a footnote.[^1]
446+
447+
[^1]: And here is the footnote body.
448+
```
449+
450+
The references and definitions render into a linked footnote list at the end of the content.
451+
440452
### Attributes
441453

442454
Add classes, IDs, or attributes to any element:

‎packages/md/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ Full VitePress-parity feature set:
115115
- **Custom containers** — `:::tip`, `:::info`, `:::warning`, `:::danger`, `:::details`
116116
- **GitHub alerts** — `> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`, `> [!IMPORTANT]`, `> [!CAUTION]`
117117
- **Emoji** — `:emoji_name:` shortcodes
118+
- **Footnotes** — `text[^1]` refs with matching `[^1]: definition` blocks
118119
- **Attributes** — `{.class #id}` on elements via `markdown-it-attrs`
119120
- **Anchor links** — auto-generated heading anchors
120121
- **Table of contents** — `[[toc]]` directive
@@ -143,7 +144,7 @@ import '@void/md/theme.css'; // or theme-content.css
143144

144145
## Tests
145146

146-
- `test/unit/compile.test.ts` — compilation pipeline: basic, frontmatter, title extraction, headings, Shiki, emoji, anchors
147+
- `test/unit/compile.test.ts` — compilation pipeline: basic, frontmatter, title extraction, headings, Shiki, emoji, footnotes, anchors
147148
- `test/unit/extract-script.test.ts` — script extraction: island imports, client code, edge cases
148149
- `test/unit/plugins.test.ts` — markdown-it plugins: containers, GitHub alerts, links, images, line numbers, emoji, TOC
149150
- `test/unit/emit-vue.test.ts` — Vue component emission with and without islands

‎packages/md/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"markdown-it-attrs": "^4.3.1",
4848
"markdown-it-container": "^4.0.0",
4949
"markdown-it-emoji": "^3.0.0",
50+
"markdown-it-footnote": "^4.0.0",
5051
"pathe": "catalog:",
5152
"shiki": "^4.0.2",
5253
"tinyglobby": "^0.2.16"

‎packages/md/src/compile.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { RuleCore, Token } from 'markdown-exit';
1212
import anchor from 'markdown-it-anchor';
1313
import attrs from 'markdown-it-attrs';
1414
import { full as emoji } from 'markdown-it-emoji';
15+
import footnote from 'markdown-it-footnote';
1516
import { createHighlighter, bundledLanguages, isSpecialLang, type BundledLanguage } from 'shiki';
1617
import { containerPlugin } from './plugins/containers.ts';
1718
import { gitHubAlertsPlugin } from './plugins/github-alerts.ts';
@@ -137,6 +138,11 @@ async function createCompiler(options?: CompileOptions): Promise<MarkdownExit> {
137138
// Emoji shortcodes
138139
markdown.use(emoji);
139140

141+
// Footnotes: `text[^1]` refs with matching `[^1]: definition` blocks.
142+
// Registered after emoji and before anchor so footnote back-reference
143+
// markers are not themselves treated as heading-anchor targets.
144+
markdown.use(footnote);
145+
140146
// Heading anchors
141147
// @ts-expect-error markdown-it-anchor types reference @types/markdown-it, structurally incompatible with markdown-exit
142148
markdown.use(anchor, {
@@ -173,10 +179,16 @@ export async function compile(source: string, options?: CompileOptions): Promise
173179
const { data: frontmatter, content } = matter(source);
174180
const compiler = await getCompiler(options);
175181

176-
const env: Record<string, unknown> = {};
177-
const tokens = compiler.parse(content, env);
182+
// Each markdown-it call gets its OWN fresh env. `renderAsync` re-parses
183+
// internally, so sharing one env across `parse` and `renderAsync` makes
184+
// stateful plugins double-accumulate — markdown-it-footnote would append its
185+
// footnote refs/defs to the same `env.footnotes` twice, producing duplicated
186+
// footnote lists and wrong fn/fnref ids. A fresh env per call keeps the two
187+
// parses independent. Heading/TOC extraction is unaffected: anchor ids are
188+
// derived per-parse from the (identical) content, not from shared env state.
189+
const tokens = compiler.parse(content, {});
178190
const headings = extractHeadings(tokens);
179-
const html = await compiler.renderAsync(content, env);
191+
const html = await compiler.renderAsync(content, {});
180192

181193
const title =
182194
(frontmatter.title as string) ?? headings.find((heading) => heading.depth === 1)?.text ?? '';

‎packages/md/src/env.d.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ declare module 'markdown-it-container' {
1212
export default plugin;
1313
}
1414

15+
declare module 'markdown-it-footnote' {
16+
const plugin: (md: unknown, ...args: ReadonlyArray<unknown>) => void;
17+
export default plugin;
18+
}
19+
1520
declare module '*.vue' {
1621
import type { DefineComponent } from 'vue';
1722
const component: DefineComponent<{}, {}, unknown>;

‎packages/md/src/metadata.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import matter from 'gray-matter';
22
import MarkdownExit from 'markdown-exit';
33
import type { Token } from 'markdown-exit';
44
import anchor from 'markdown-it-anchor';
5+
import footnote from 'markdown-it-footnote';
56
import type { Heading } from './compile.ts';
67

78
let metadataCompiler: MarkdownExit | null = null;
@@ -11,6 +12,12 @@ function getMetadataCompiler(): MarkdownExit {
1112
return metadataCompiler;
1213
}
1314
const markdown = new MarkdownExit({ html: true, linkify: true });
15+
// Footnotes must run before anchor so a `text[^1]` ref in a heading is turned
16+
// into a footnote_ref token (excluded from heading text) rather than staying
17+
// literal. Keeps metadata heading text/slug in sync with the rendered anchor
18+
// ids from `compile()`; otherwise `@void/md/pages` links break for such
19+
// headings. Registration order mirrors `createCompiler` in compile.ts.
20+
markdown.use(footnote);
1421
// @ts-expect-error markdown-it-anchor types reference @types/markdown-it, structurally incompatible with markdown-exit
1522
markdown.use(anchor, {
1623
permalink: anchor.permalink.ariaHidden({ placement: 'before' }),

‎packages/md/test/unit/compile.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,27 @@ describe('compile', () => {
8787
expect(result.html).toContain('my-heading');
8888
});
8989

90+
it('renders footnotes exactly once (no double-accumulation from shared env)', async () => {
91+
const result = await compile('Here is a footnote reference.[^1]\n\n[^1]: The footnote body.');
92+
// Ref marker, back-reference target, and the footnote block each appear once.
93+
expect(result.html.match(/id="fnref1"/g)).toHaveLength(1);
94+
expect(result.html.match(/id="fn1"/g)).toHaveLength(1);
95+
expect(result.html.match(/class="footnotes"/g)).toHaveLength(1);
96+
expect(result.html.match(/class="footnote-item"/g)).toHaveLength(1);
97+
expect(result.html).toContain('The footnote body.');
98+
});
99+
100+
it('keeps heading extraction intact alongside footnotes', async () => {
101+
const result = await compile('# Title\n\n## Section\n\nBody with a note.[^n]\n\n[^n]: note');
102+
// Fresh-env fix must not regress TOC/heading ids.
103+
expect(result.headings).toMatchObject([
104+
{ depth: 1, text: 'Title' },
105+
{ depth: 2, text: 'Section', slug: 'section' },
106+
]);
107+
expect(result.html).toContain('id="section"');
108+
expect(result.html.match(/class="footnotes"/g)).toHaveLength(1);
109+
});
110+
90111
it('handles empty source', async () => {
91112
const result = await compile('');
92113
expect(result.html).toBe('');
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from 'vite-plus/test';
2+
import { compile } from '../../src/compile.ts';
3+
import { extractMetadata } from '../../src/metadata.ts';
4+
5+
describe('extractMetadata', () => {
6+
it('extracts headings, title, and frontmatter', () => {
7+
const result = extractMetadata('---\ntitle: Doc\n---\n\n# Heading\n\n## Section');
8+
expect(result.title).toBe('Doc');
9+
expect(result.frontmatter.title).toBe('Doc');
10+
expect(result.headings).toMatchObject([
11+
{ depth: 1, text: 'Heading', slug: 'heading' },
12+
{ depth: 2, text: 'Section', slug: 'section' },
13+
]);
14+
});
15+
16+
it('strips footnote refs from heading text/slug to match rendered anchor ids', async () => {
17+
const source = '## API[^1]\n\nBody[^1].\n\n[^1]: note';
18+
19+
const meta = extractMetadata(source);
20+
expect(meta.headings).toMatchObject([{ depth: 2, text: 'API', slug: 'api' }]);
21+
22+
// The metadata slug must equal the id actually rendered by compile(),
23+
// so links built from `@void/md/pages` resolve to a real anchor.
24+
const compiled = await compile(source);
25+
expect(meta.headings[0]?.slug).toBe(compiled.headings[0]?.slug);
26+
expect(compiled.html).toContain('id="api"');
27+
});
28+
});

‎pnpm-lock.yaml‎

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)