Skip to content
Open
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
16 changes: 16 additions & 0 deletions packages/docusaurus-mdx-loader/src/__tests__/processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,22 @@ Body
expect(result.content).toMatchSnapshot();
});

it('extracts explicit heading ID from HTML comments when mdx1Compat.comments is enabled', async () => {
const mdResult = await processContent(
'## Hello World <!-- #my-explicit-id -->',
createOptions({markdownConfig: {format: 'md'}}),
);
expect(mdResult.content).toContain('id: "my-explicit-id"');
expect(mdResult.content).toContain('children: "Hello World"');

const mdxResult = await processContent(
'## Hello World <!-- #my-explicit-id -->',
createOptions({markdownConfig: {format: 'mdx'}}),
);
expect(mdxResult.content).toContain('id: "my-explicit-id"');
expect(mdxResult.content).toContain('children: "Hello World"');
});

it('preserves legacy heading IDs', async () => {
const options = createOptions();
const result = await processContent('## Heading {#custom-id}', options);
Expand Down
4 changes: 3 additions & 1 deletion packages/docusaurus-mdx-loader/src/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ function createProcessorUncached({
} satisfies TransformLinksOptions,
],
gfm,
options.markdownConfig.mdx1Compat.comments ? remarkComment : null,
options.markdownConfig.mdx1Compat.comments
? [remarkComment, {ast: true}]
: null,
...(options.remarkPlugins ?? []),
[
unusedDirectives,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import {describe, expect, it} from 'vitest';
import {remark} from 'remark';
import mdx from 'remark-mdx';
import remarkComment from '@slorber/remark-comment';
import {u} from 'unist-builder';
import {visit} from 'unist-util-visit';
import {escapeMarkdownHeadingIds} from '@docusaurus/utils';
Expand Down Expand Up @@ -357,6 +358,149 @@ describe('headings remark plugin', () => {
await expect(
headingIdFor('# {#text-after} custom ID', format),
).resolves.toEqual('text-after-custom-id');

await expect(
process(
'## Heading {#legacy}<!-- note -->',
[[remarkComment, {ast: true}]],
{},
format,
),
).resolves.toEqual(
u('root', [
u(
'heading',
{
depth: 2,
data: {
hProperties: {id: 'legacy'},
id: 'legacy',
},
},
[
u('text', 'Heading'),
{
type: 'comment',
value: '',
commentValue: ' note ',
},
],
),
]),
);

await expect(
process(
'## Heading *with emphasis {#nested-legacy}*',
[],
{},
format,
),
).resolves.toEqual(
u('root', [
u(
'heading',
{
depth: 2,
data: {
hProperties: {id: 'nested-legacy'},
id: 'nested-legacy',
},
},
[
u('text', 'Heading '),
u('emphasis', [u('text', 'with emphasis')]),
],
),
]),
);

await expect(
process(
'## Heading {#custom-id}',
[
function splitTextPlugin() {
return (root) => {
const heading = (root as Parent).children[0] as Heading;
heading.children = [
u('text', 'Heading '),
u('text', '{#custom'),
u('text', '-id}'),
];
};
},
],
{},
format,
),
).resolves.toEqual(
u('root', [
u(
'heading',
{
depth: 2,
data: {
hProperties: {id: 'custom-id'},
id: 'custom-id',
},
},
[u('text', 'Heading')],
),
]),
);

await expect(
process('## Title `code` {#custom-id}', [], {}, format),
).resolves.toEqual(
u('root', [
u(
'heading',
{
depth: 2,
data: {
hProperties: {id: 'custom-id'},
id: 'custom-id',
},
},
[u('text', 'Title '), u('inlineCode', 'code')],
),
]),
);

await expect(
process(
'## Title {#`custom`}',
[
function inlineCodeIdPlugin() {
return (root) => {
const heading = (root as Parent).children[0] as Heading;
heading.children = [
u('text', 'Title '),
u('text', '{#'),
u('inlineCode', 'custom'),
u('text', '}'),
];
};
},
],
{},
format,
),
).resolves.toEqual(
u('root', [
u(
'heading',
{
depth: 2,
data: {
hProperties: {id: 'custom'},
id: 'custom',
},
},
[u('text', 'Title')],
),
]),
);
}
it('works for format CommonMark', async () => {
await testHeadingIds('md');
Expand Down Expand Up @@ -434,6 +578,17 @@ describe('headings remark plugin', () => {
expect(heading).toEqual(h(null, 2, 'id-only'));
});

it('supports HTML comment syntax when remark-comment is enabled', async () => {
const result = await process(
'## Heading <!-- #my-id -->',
[[remarkComment, {ast: true}]],
{},
'md',
);
const heading = result.children[0] as Heading;
expect(heading).toEqual(h('Heading', 2, 'my-id'));
});

it('does NOT support MDX comment syntax {/* #id */} in CommonMark', async () => {
// In CommonMark (no remark-mdx), {/* #id */} is regular text
const id = await headingIdFor('# Heading {/* #my-id */}', 'md');
Expand Down
81 changes: 64 additions & 17 deletions packages/docusaurus-mdx-loader/src/remark/headings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ function getCommentHeadingId(heading: Heading): string | undefined {
}
}

// HTML comment parsed by @slorber/remark-comment when mdx1Compat.comments is enabled
if ((lastChild?.type as string) === 'comment') {
const commentContent = (lastChild as unknown as {commentValue?: string})
.commentValue;
if (commentContent) {
return getCommentContentHeadingId(commentContent);
}
}

return undefined;
}

Expand All @@ -80,28 +89,66 @@ function extractCommentId(heading: Heading) {
return undefined;
}

// Recursively finds all text-bearing leaf nodes contributing to visible heading text
function findAllTextNodes(parent: {children?: unknown[]}): {
node: {value: string};
parent: {children: unknown[]};
index: number;
}[] {
const result: {
node: {value: string};
parent: {children: unknown[]};
index: number;
}[] = [];

function walk(node: {children?: unknown[]}) {
if (!node.children || !Array.isArray(node.children)) {
return;
}
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i] as {
type: string;
value?: string;
children?: unknown[];
};
if (['html', 'jsx', 'comment'].includes(child.type)) {
continue;
}
if (typeof child.value === 'string') {
result.push({
node: child as {value: string},
parent: node as {children: unknown[]},
index: i,
});
} else if (child.children) {
walk(child);
}
}
}

walk(parent);
return result;
}

// Try to find an explicit id in the heading text (classic {#id} syntax)
function extractClassicSyntaxHeadingId(heading: Heading, headingText: string) {
const parsedHeading = parseMarkdownHeadingId(headingText, 'classic');
// Remove the heading text from its id (legacy syntax)
if (parsedHeading.id) {
// When there's an id, it is always in the last child node
const lastNode = heading.children.at(-1) as Text;
if (heading.children.length > 1) {
const lastNodeText = parseMarkdownHeadingId(
lastNode.value,
'classic',
).text;
// When the last part contains text + id, remove the id
if (lastNodeText) {
lastNode.value = lastNodeText;
}
// When last part contains only the id: completely remove that node
else {
heading.children.pop();
const suffixLength = headingText.length - parsedHeading.text.length;
if (suffixLength > 0) {
const textNodes = findAllTextNodes(heading);
let remaining = suffixLength;
for (let i = textNodes.length - 1; i >= 0 && remaining > 0; i--) {
const {node, parent, index} = textNodes[i]!;
if (node.value.length <= remaining) {
remaining -= node.value.length;
parent.children.splice(index, 1);
} else {
node.value = node.value.slice(0, node.value.length - remaining);
remaining = 0;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
} else {
lastNode.value = parsedHeading.text;
}
return parsedHeading.id;
}
Expand All @@ -114,7 +161,7 @@ const plugin: Plugin<PluginOptions[], Root> = function plugin({
return (root) => {
function getHeadingText(heading: Heading) {
const headingTextNodes = heading.children.filter(
({type}) => !['html', 'jsx'].includes(type),
({type}) => !['html', 'jsx', 'comment'].includes(type),
);
return toString(headingTextNodes.length > 0 ? headingTextNodes : heading);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Expand Down