diff --git a/.changeset/eighty-knives-remain.md b/.changeset/eighty-knives-remain.md new file mode 100644 index 000000000..72cd5c76f --- /dev/null +++ b/.changeset/eighty-knives-remain.md @@ -0,0 +1,6 @@ +--- +'@astrojs/mdx': patch +'@astrojs/markdown-remark': patch +--- + +Fix MDX heading IDs generation when using a frontmatter reference diff --git a/packages/integrations/mdx/test/fixtures/mdx-get-headings/src/pages/test-with-frontmatter.mdx b/packages/integrations/mdx/test/fixtures/mdx-get-headings/src/pages/test-with-frontmatter.mdx new file mode 100644 index 000000000..d40537eb8 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-get-headings/src/pages/test-with-frontmatter.mdx @@ -0,0 +1,45 @@ +--- +title: The Frontmatter Title +keywords: [Keyword 1, Keyword 2, Keyword 3] +tags: + - Tag 1 + - Tag 2 + - Tag 3 +items: + - value: Item 1 + - value: Item 2 + - value: Item 3 +nested_items: + nested: + - value: Nested Item 1 + - value: Nested Item 2 + - value: Nested Item 3 +--- + +# {frontmatter.title} + +This ID should be the frontmatter title. + +## frontmatter.title + +The ID should not be the frontmatter title. + +### {frontmatter.keywords[1]} + +The ID should be the frontmatter keyword #2. + +### {frontmatter.tags[0]} + +The ID should be the frontmatter tag #1. + +#### {frontmatter.items[1].value} + +The ID should be the frontmatter item #2. + +##### {frontmatter.nested_items.nested[2].value} + +The ID should be the frontmatter nested item #3. + +###### {frontmatter.unknown} + +This ID should not reference the frontmatter. diff --git a/packages/integrations/mdx/test/mdx-get-headings.test.js b/packages/integrations/mdx/test/mdx-get-headings.test.js index 03290abc5..1b1987e77 100644 --- a/packages/integrations/mdx/test/mdx-get-headings.test.js +++ b/packages/integrations/mdx/test/mdx-get-headings.test.js @@ -149,3 +149,46 @@ describe('MDX heading IDs can be injected before user plugins', () => { expect(h1?.id).to.equal('heading-test'); }); }); + +describe('MDX headings with frontmatter', () => { + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/mdx-get-headings/', import.meta.url), + integrations: [mdx()], + }); + + await fixture.build(); + }); + + it('adds anchor IDs to headings', async () => { + const html = await fixture.readFile('/test-with-frontmatter/index.html'); + const { document } = parseHTML(html); + + const h3Ids = document.querySelectorAll('h3').map((el) => el?.id); + + expect(document.querySelector('h1').id).to.equal('the-frontmatter-title'); + expect(document.querySelector('h2').id).to.equal('frontmattertitle'); + expect(h3Ids).to.contain('keyword-2'); + expect(h3Ids).to.contain('tag-1'); + expect(document.querySelector('h4').id).to.equal('item-2'); + expect(document.querySelector('h5').id).to.equal('nested-item-3'); + expect(document.querySelector('h6').id).to.equal('frontmatterunknown'); + }); + + it('generates correct getHeadings() export', async () => { + const { headingsByPage } = JSON.parse(await fixture.readFile('/pages.json')); + expect(JSON.stringify(headingsByPage['./test-with-frontmatter.mdx'])).to.equal( + JSON.stringify([ + { depth: 1, slug: 'the-frontmatter-title', text: 'The Frontmatter Title' }, + { depth: 2, slug: 'frontmattertitle', text: 'frontmatter.title' }, + { depth: 3, slug: 'keyword-2', text: 'Keyword 2' }, + { depth: 3, slug: 'tag-1', text: 'Tag 1' }, + { depth: 4, slug: 'item-2', text: 'Item 2' }, + { depth: 5, slug: 'nested-item-3', text: 'Nested Item 3' }, + { depth: 6, slug: 'frontmatterunknown', text: 'frontmatter.unknown' }, + ]) + ); + }); +}); diff --git a/packages/markdown/remark/package.json b/packages/markdown/remark/package.json index 9d4641a36..9ed26a5dc 100644 --- a/packages/markdown/remark/package.json +++ b/packages/markdown/remark/package.json @@ -44,6 +44,7 @@ }, "devDependencies": { "@types/chai": "^4.3.1", + "@types/estree": "^1.0.0", "@types/github-slugger": "^1.3.0", "@types/hast": "^2.3.4", "@types/mdast": "^3.0.10", @@ -51,6 +52,7 @@ "@types/unist": "^2.0.6", "astro-scripts": "workspace:*", "chai": "^4.3.6", + "mdast-util-mdx-expression": "^1.3.1", "mocha": "^9.2.2" } } diff --git a/packages/markdown/remark/src/rehype-collect-headings.ts b/packages/markdown/remark/src/rehype-collect-headings.ts index 97fe30401..a1083f609 100644 --- a/packages/markdown/remark/src/rehype-collect-headings.ts +++ b/packages/markdown/remark/src/rehype-collect-headings.ts @@ -1,7 +1,10 @@ +import { type Expression, type Super } from 'estree'; import Slugger from 'github-slugger'; -import { visit } from 'unist-util-visit'; +import { type MdxTextExpression } from 'mdast-util-mdx-expression'; +import { visit, type Node } from 'unist-util-visit'; -import type { MarkdownHeading, MarkdownVFile, RehypePlugin } from './types.js'; +import { InvalidAstroDataError, safelyGetAstroData } from './frontmatter-injection.js'; +import type { MarkdownAstroData, MarkdownHeading, MarkdownVFile, RehypePlugin } from './types.js'; const rawNodeTypes = new Set(['text', 'raw', 'mdxTextExpression']); const codeTagNames = new Set(['code', 'pre']); @@ -11,6 +14,7 @@ export function rehypeHeadingIds(): ReturnType { const headings: MarkdownHeading[] = []; const slugger = new Slugger(); const isMDX = isMDXFile(file); + const astroData = safelyGetAstroData(file.data); visit(tree, (node) => { if (node.type !== 'element') return; const { tagName } = node; @@ -31,7 +35,17 @@ export function rehypeHeadingIds(): ReturnType { } if (rawNodeTypes.has(child.type)) { if (isMDX || codeTagNames.has(parent.tagName)) { - text += child.value; + let value = child.value; + if (isMdxTextExpression(child) && !(astroData instanceof InvalidAstroDataError)) { + const frontmatterPath = getMdxFrontmatterVariablePath(child); + if (Array.isArray(frontmatterPath) && frontmatterPath.length > 0) { + const frontmatterValue = getMdxFrontmatterVariableValue(astroData, frontmatterPath); + if (typeof frontmatterValue === 'string') { + value = frontmatterValue; + } + } + } + text += value; } else { text += child.value.replace(/\{/g, '${'); } @@ -57,3 +71,58 @@ export function rehypeHeadingIds(): ReturnType { function isMDXFile(file: MarkdownVFile) { return Boolean(file.history[0]?.endsWith('.mdx')); } + +/** + * Check if an ESTree entry is `frontmatter.*.VARIABLE`. + * If it is, return the variable path (i.e. `["*", ..., "VARIABLE"]`) minus the `frontmatter` prefix. + */ +function getMdxFrontmatterVariablePath(node: MdxTextExpression): string[] | Error { + if (!node.data?.estree || node.data.estree.body.length !== 1) return new Error(); + + const statement = node.data.estree.body[0]; + + // Check for "[ANYTHING].[ANYTHING]". + if (statement?.type !== 'ExpressionStatement' || statement.expression.type !== 'MemberExpression') + return new Error(); + + let expression: Expression | Super = statement.expression; + const expressionPath: string[] = []; + + // Traverse the expression, collecting the variable path. + while ( + expression.type === 'MemberExpression' && + expression.property.type === (expression.computed ? 'Literal' : 'Identifier') + ) { + expressionPath.push( + expression.property.type === 'Literal' + ? String(expression.property.value) + : expression.property.name + ); + + expression = expression.object; + } + + // Check for "frontmatter.[ANYTHING]". + if (expression.type !== 'Identifier' || expression.name !== 'frontmatter') return new Error(); + + return expressionPath.reverse(); +} + +function getMdxFrontmatterVariableValue(astroData: MarkdownAstroData, path: string[]) { + let value: MdxFrontmatterVariableValue = astroData.frontmatter; + + for (const key of path) { + if (!value[key]) return undefined; + + value = value[key]; + } + + return value; +} + +function isMdxTextExpression(node: Node): node is MdxTextExpression { + return node.type === 'mdxTextExpression'; +} + +type MdxFrontmatterVariableValue = + MarkdownAstroData['frontmatter'][keyof MarkdownAstroData['frontmatter']]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3b3920a8..9a4d0f645 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3508,6 +3508,7 @@ importers: specifiers: '@astrojs/prism': ^2.0.0 '@types/chai': ^4.3.1 + '@types/estree': ^1.0.0 '@types/github-slugger': ^1.3.0 '@types/hast': ^2.3.4 '@types/mdast': ^3.0.10 @@ -3517,6 +3518,7 @@ importers: chai: ^4.3.6 github-slugger: ^1.4.0 import-meta-resolve: ^2.1.0 + mdast-util-mdx-expression: ^1.3.1 mocha: ^9.2.2 rehype-raw: ^6.1.1 rehype-stringify: ^9.0.3 @@ -3544,6 +3546,7 @@ importers: vfile: 5.3.6 devDependencies: '@types/chai': 4.3.4 + '@types/estree': 1.0.0 '@types/github-slugger': 1.3.0 '@types/hast': 2.3.4 '@types/mdast': 3.0.10 @@ -3551,6 +3554,7 @@ importers: '@types/unist': 2.0.6 astro-scripts: link:../../../scripts chai: 4.3.7 + mdast-util-mdx-expression: 1.3.1 mocha: 9.2.2 packages/telemetry: