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
31 changes: 31 additions & 0 deletions packages/repl-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
3 changes: 2 additions & 1 deletion packages/repl-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand All @@ -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",
Expand Down
49 changes: 6 additions & 43 deletions packages/repl-sdk/src/compilers/markdown/heading-id.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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];

Expand All @@ -81,7 +44,7 @@ export function headingId(options = { defaults: false }) {
}
}

setNodeId(node, getDefaultId(node.children));
setNodeId(node, slugger.slug(toString(node)));
});
};
}
60 changes: 48 additions & 12 deletions packages/repl-sdk/src/compilers/markdown/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,30 +230,66 @@ describe('default features', () => {
expect(result).toMatchInlineSnapshot(`
{
"codeBlocks": [],
"text": "<h2 id="hello-foo-two"><code>&#x3C;Hello @foo="two" /></code></h2>",
"text": "<h2 id="hello-footwo-"><code>&#x3C;Hello @foo="two" /></code></h2>",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this extra dash about?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's github-slugger's real output for <Hello @foo="two" /> — the space before the / becomes a dash, and the / itself is stripped, leaving the dash trailing. GitHub produces the same thing for that heading, so I updated the snapshot rather than working around it.

Drafted by Claude, reviewed before posting.

}
`);
});
});
});

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('<h2 id="setupmirage">setupMirage</h2>');
});

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('<h2 id="v2-jsonapi">V2 JSON:API</h2>');
});

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('<h2 id="usage">Usage</h2>');
});

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 });

Expand Down
9 changes: 6 additions & 3 deletions pnpm-lock.yaml

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

Loading