summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar HiDeoo <494699+HiDeoo@users.noreply.github.com> 2023-01-26 18:52:50 +0100
committerGravatar GitHub <noreply@github.com> 2023-01-26 12:52:50 -0500
commit7abb1e9056c4b4fd0abfced347df32a41cdfbf28 (patch)
tree69e217859792b1c01ac8bfa885165270e8b6b009
parente16958f35fc24c4bd3d7e1502183eb8c67d79686 (diff)
downloadastro-7abb1e9056c4b4fd0abfced347df32a41cdfbf28.tar.gz
astro-7abb1e9056c4b4fd0abfced347df32a41cdfbf28.tar.zst
astro-7abb1e9056c4b4fd0abfced347df32a41cdfbf28.zip
Fix MDX heading IDs generation when using a frontmatter reference (#5978)
* Fix MDX heading IDs generation when using a frontmatter reference * Hoist safelyGetAstroData() call and add statement null check
-rw-r--r--.changeset/eighty-knives-remain.md6
-rw-r--r--packages/integrations/mdx/test/fixtures/mdx-get-headings/src/pages/test-with-frontmatter.mdx45
-rw-r--r--packages/integrations/mdx/test/mdx-get-headings.test.js43
-rw-r--r--packages/markdown/remark/package.json2
-rw-r--r--packages/markdown/remark/src/rehype-collect-headings.ts75
-rw-r--r--pnpm-lock.yaml4
6 files changed, 172 insertions, 3 deletions
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<RehypePlugin> {
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<RehypePlugin> {
}
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<RehypePlugin> {
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: