diff --git a/packages/repl-sdk/README.md b/packages/repl-sdk/README.md index 47bc65d07..781336cd0 100644 --- a/packages/repl-sdk/README.md +++ b/packages/repl-sdk/README.md @@ -13,3 +13,34 @@ Features: ## Usage +### Heading ids + +Every markdown heading gets an `id`, so in-page anchors can link to sections. + +Ids match what GitHub generates for the same markdown, via +[`github-slugger`][github-slugger]: + +``` +### `setupMirage` -> #setupmirage +### V2 JSON:API -> #v2-jsonapi +``` + +A `.md` file is typically read in two places — a rendered site, and the repo on +GitHub — and an in-page `#anchor` only resolves in both if the two agree on how +the id is derived. + +Like GitHub, repeated headings within a document are de-duplicated (`#usage`, +`#usage-1`, `#usage-2`), and the numbering restarts for each document. +Whitespace the author wrote is preserved rather than collapsed, also matching +GitHub: `## Hello World` becomes `#hello----world`. + +A heading with an explicit `{#custom-id}` suffix keeps that id instead. + +> [!NOTE] +> Heading anchors are not part of the [GFM spec][gfm-spec], which covers +> autolink literals, footnotes, strikethrough, tables and tasklists. GitHub +> generates them in its rendering layer — `github-slugger` is that behavior, +> extracted. + +[github-slugger]: https://github.com/Flet/github-slugger +[gfm-spec]: https://github.github.com/gfm/ diff --git a/packages/repl-sdk/package.json b/packages/repl-sdk/package.json index 2e1735ce4..909c1d003 100644 --- a/packages/repl-sdk/package.json +++ b/packages/repl-sdk/package.json @@ -86,7 +86,6 @@ "@lezer/markdown": "^1.6.3", "@replit/codemirror-lang-svelte": "^6.0.0", "@shikijs/rehype": "^3.22.0", - "change-case": "^5.4.4", "codemirror": "^6.0.2", "codemirror-lang-glimdown": "workspace:^", "codemirror-lang-glimmer": "workspace:^", @@ -95,7 +94,9 @@ "codemirror-languageserver": "^1.12.1", "comlink": "^4.4.2", "es-module-shims": "^2.8.0", + "github-slugger": "^2.0.0", "mdast": "^3.0.0", + "mdast-util-to-string": "^4.0.0", "mime": "^4.0.7", "package-name-regex": "^5.0.0", "rehype-autolink-headings": "^7.1.0", diff --git a/packages/repl-sdk/src/compilers/markdown/heading-id.js b/packages/repl-sdk/src/compilers/markdown/heading-id.js index c60f4d14e..c062cedd4 100644 --- a/packages/repl-sdk/src/compilers/markdown/heading-id.js +++ b/packages/repl-sdk/src/compilers/markdown/heading-id.js @@ -1,46 +1,7 @@ -import { kebabCase } from 'change-case'; +import GithubSlugger from 'github-slugger'; +import { toString } from 'mdast-util-to-string'; import { visit } from 'unist-util-visit'; -/** - * @param {import('mdast').PhrasingContent[]} children - * @return {string} - */ -function getDefaultId(children) { - return formatDefaultId(extractText(children)); -} - -/** - * @param {import('mdast').PhrasingContent[]} children - * @return {string} - */ -function extractText(children) { - return children - .map( - /** - * @param {any} child - */ - (child) => { - const isEmpty = !child.value?.trim(); - - if (!isEmpty) { - return child.value; - } else if (child.children && child.children.length > 0) { - return extractText(child.children); - } else { - return ''; - } - } - ) - .join(' '); -} - -/** - * @param {string} value - */ -function formatDefaultId(value) { - return kebabCase(value.replaceAll(/\s+/g, ' ').trim()); -} - /** * @param {import('mdast').Heading} node * @param {string} id @@ -52,11 +13,13 @@ function setNodeId(node, id) { /** @type {any} */ (node.data).id = node.data.hProperties.id = id; } -export function headingId(options = { defaults: false }) { +export function headingId() { /** * @param {import('mdast').Root} node */ return function (node) { + const slugger = new GithubSlugger(); + visit(node, 'heading', (node) => { const lastChild = node.children[node.children.length - 1]; @@ -81,7 +44,7 @@ export function headingId(options = { defaults: false }) { } } - setNodeId(node, getDefaultId(node.children)); + setNodeId(node, slugger.slug(toString(node))); }); }; } diff --git a/packages/repl-sdk/src/compilers/markdown/parse.test.ts b/packages/repl-sdk/src/compilers/markdown/parse.test.ts index 87815ba62..6cb32c1df 100644 --- a/packages/repl-sdk/src/compilers/markdown/parse.test.ts +++ b/packages/repl-sdk/src/compilers/markdown/parse.test.ts @@ -230,30 +230,66 @@ describe('default features', () => { expect(result).toMatchInlineSnapshot(` { "codeBlocks": [], - "text": "

<Hello @foo="two" />

", + "text": "

<Hello @foo="two" />

", } `); }); }); }); -describe('heading ids', () => { - it('collapses runs of whitespace in the heading text', async () => { - const result = await parseMarkdown(`## Hello World `, { ...defaults }); - - // Only the id is normalized; the rendered text keeps the author's spacing. - expect(result.text).toContain('id="hello-world"'); - }); - +describe('headingId', () => { it('treats a literal \\s in a heading as text, not as whitespace', async () => { - // The regex in formatDefaultId used to be /\\s+/g -- a literal backslash - // followed by `s`, rather than whitespace -- so `\s` here was replaced with - // a space and the `s` vanished from the id. const result = await parseMarkdown(`## a \\s b`, { ...defaults }); expect(result.text).toContain('id="a-s-b"'); }); + it('matches the anchor GitHub generates', async () => { + const result = await parseMarkdown(`## setupMirage`, { ...defaults }); + + expect(result.text).toBe('

setupMirage

'); + }); + + it('keeps a colon out of the id, the way GitHub does', async () => { + const result = await parseMarkdown(`## V2 JSON:API`, { ...defaults }); + + expect(result.text).toBe('

V2 JSON:API

'); + }); + + it('de-duplicates repeated headings within a document', async () => { + const result = await parseMarkdown(`## Usage\n\n## Usage\n\n## Usage`, { ...defaults }); + + expect(result.text).toContain('id="usage"'); + expect(result.text).toContain('id="usage-1"'); + expect(result.text).toContain('id="usage-2"'); + }); + + it('restarts numbering for each document', async () => { + await parseMarkdown(`## Usage`, { ...defaults }); + + const second = await parseMarkdown(`## Usage`, { ...defaults }); + + expect(second.text).toBe('

Usage

'); + }); + + it("inserts nothing between a heading's children", async () => { + const result = await parseMarkdown(`## Hello *there*`, { ...defaults }); + + expect(result.text).toContain('id="hello-there"'); + }); + + it('keeps whitespace-only text nodes between formatted children', async () => { + const result = await parseMarkdown(`## *a* *b*`, { ...defaults }); + + expect(result.text).toContain('id="a-b"'); + }); + + it('keeps whitespace the author actually wrote, like GitHub does', async () => { + const result = await parseMarkdown(`## Hello World `, { ...defaults }); + + expect(result.text).toContain('id="hello----world"'); + }); + it('uses a {#custom-id} suffix as the id and strips it from the text', async () => { const result = await parseMarkdown(`## A very long heading {#short-id}`, { ...defaults }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e92562af..684ae113c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1295,9 +1295,6 @@ importers: '@shikijs/rehype': specifier: ^3.22.0 version: 3.23.0 - change-case: - specifier: ^5.4.4 - version: 5.4.4 codemirror: specifier: ^6.0.2 version: 6.0.2 @@ -1322,9 +1319,15 @@ importers: es-module-shims: specifier: ^2.8.0 version: 2.8.0 + github-slugger: + specifier: ^2.0.0 + version: 2.0.0 mdast: specifier: ^3.0.0 version: 3.0.0 + mdast-util-to-string: + specifier: ^4.0.0 + version: 4.0.0 mime: specifier: ^4.0.7 version: 4.1.0