Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/guide/pages-routing/markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion packages/md/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/md/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 15 additions & 3 deletions packages/md/src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -137,6 +138,11 @@ async function createCompiler(options?: CompileOptions): Promise<MarkdownExit> {
// 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);
Comment thread
Brooooooklyn marked this conversation as resolved.

// Heading anchors
// @ts-expect-error markdown-it-anchor types reference @types/markdown-it, structurally incompatible with markdown-exit
markdown.use(anchor, {
Expand Down Expand Up @@ -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<string, unknown> = {};
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 ?? '';
Expand Down
5 changes: 5 additions & 0 deletions packages/md/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ declare module 'markdown-it-container' {
export default plugin;
}

declare module 'markdown-it-footnote' {
const plugin: (md: unknown, ...args: ReadonlyArray<unknown>) => void;
export default plugin;
}

declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, unknown>;
Expand Down
7 changes: 7 additions & 0 deletions packages/md/src/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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' }),
Expand Down
21 changes: 21 additions & 0 deletions packages/md/test/unit/compile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand Down
28 changes: 28 additions & 0 deletions packages/md/test/unit/metadata.test.ts
Original file line number Diff line number Diff line change
@@ -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"');
});
});
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.