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
This commit is contained in:
parent
e16958f35f
commit
7abb1e9056
6 changed files with 172 additions and 3 deletions
6
.changeset/eighty-knives-remain.md
Normal file
6
.changeset/eighty-knives-remain.md
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
'@astrojs/mdx': patch
|
||||||
|
'@astrojs/markdown-remark': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix MDX heading IDs generation when using a frontmatter reference
|
45
packages/integrations/mdx/test/fixtures/mdx-get-headings/src/pages/test-with-frontmatter.mdx
vendored
Normal file
45
packages/integrations/mdx/test/fixtures/mdx-get-headings/src/pages/test-with-frontmatter.mdx
vendored
Normal file
|
@ -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.
|
|
@ -149,3 +149,46 @@ describe('MDX heading IDs can be injected before user plugins', () => {
|
||||||
expect(h1?.id).to.equal('heading-test');
|
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' },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -44,6 +44,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chai": "^4.3.1",
|
"@types/chai": "^4.3.1",
|
||||||
|
"@types/estree": "^1.0.0",
|
||||||
"@types/github-slugger": "^1.3.0",
|
"@types/github-slugger": "^1.3.0",
|
||||||
"@types/hast": "^2.3.4",
|
"@types/hast": "^2.3.4",
|
||||||
"@types/mdast": "^3.0.10",
|
"@types/mdast": "^3.0.10",
|
||||||
|
@ -51,6 +52,7 @@
|
||||||
"@types/unist": "^2.0.6",
|
"@types/unist": "^2.0.6",
|
||||||
"astro-scripts": "workspace:*",
|
"astro-scripts": "workspace:*",
|
||||||
"chai": "^4.3.6",
|
"chai": "^4.3.6",
|
||||||
|
"mdast-util-mdx-expression": "^1.3.1",
|
||||||
"mocha": "^9.2.2"
|
"mocha": "^9.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
|
import { type Expression, type Super } from 'estree';
|
||||||
import Slugger from 'github-slugger';
|
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 rawNodeTypes = new Set(['text', 'raw', 'mdxTextExpression']);
|
||||||
const codeTagNames = new Set(['code', 'pre']);
|
const codeTagNames = new Set(['code', 'pre']);
|
||||||
|
@ -11,6 +14,7 @@ export function rehypeHeadingIds(): ReturnType<RehypePlugin> {
|
||||||
const headings: MarkdownHeading[] = [];
|
const headings: MarkdownHeading[] = [];
|
||||||
const slugger = new Slugger();
|
const slugger = new Slugger();
|
||||||
const isMDX = isMDXFile(file);
|
const isMDX = isMDXFile(file);
|
||||||
|
const astroData = safelyGetAstroData(file.data);
|
||||||
visit(tree, (node) => {
|
visit(tree, (node) => {
|
||||||
if (node.type !== 'element') return;
|
if (node.type !== 'element') return;
|
||||||
const { tagName } = node;
|
const { tagName } = node;
|
||||||
|
@ -31,7 +35,17 @@ export function rehypeHeadingIds(): ReturnType<RehypePlugin> {
|
||||||
}
|
}
|
||||||
if (rawNodeTypes.has(child.type)) {
|
if (rawNodeTypes.has(child.type)) {
|
||||||
if (isMDX || codeTagNames.has(parent.tagName)) {
|
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 {
|
} else {
|
||||||
text += child.value.replace(/\{/g, '${');
|
text += child.value.replace(/\{/g, '${');
|
||||||
}
|
}
|
||||||
|
@ -57,3 +71,58 @@ export function rehypeHeadingIds(): ReturnType<RehypePlugin> {
|
||||||
function isMDXFile(file: MarkdownVFile) {
|
function isMDXFile(file: MarkdownVFile) {
|
||||||
return Boolean(file.history[0]?.endsWith('.mdx'));
|
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']];
|
||||||
|
|
|
@ -3508,6 +3508,7 @@ importers:
|
||||||
specifiers:
|
specifiers:
|
||||||
'@astrojs/prism': ^2.0.0
|
'@astrojs/prism': ^2.0.0
|
||||||
'@types/chai': ^4.3.1
|
'@types/chai': ^4.3.1
|
||||||
|
'@types/estree': ^1.0.0
|
||||||
'@types/github-slugger': ^1.3.0
|
'@types/github-slugger': ^1.3.0
|
||||||
'@types/hast': ^2.3.4
|
'@types/hast': ^2.3.4
|
||||||
'@types/mdast': ^3.0.10
|
'@types/mdast': ^3.0.10
|
||||||
|
@ -3517,6 +3518,7 @@ importers:
|
||||||
chai: ^4.3.6
|
chai: ^4.3.6
|
||||||
github-slugger: ^1.4.0
|
github-slugger: ^1.4.0
|
||||||
import-meta-resolve: ^2.1.0
|
import-meta-resolve: ^2.1.0
|
||||||
|
mdast-util-mdx-expression: ^1.3.1
|
||||||
mocha: ^9.2.2
|
mocha: ^9.2.2
|
||||||
rehype-raw: ^6.1.1
|
rehype-raw: ^6.1.1
|
||||||
rehype-stringify: ^9.0.3
|
rehype-stringify: ^9.0.3
|
||||||
|
@ -3544,6 +3546,7 @@ importers:
|
||||||
vfile: 5.3.6
|
vfile: 5.3.6
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/chai': 4.3.4
|
'@types/chai': 4.3.4
|
||||||
|
'@types/estree': 1.0.0
|
||||||
'@types/github-slugger': 1.3.0
|
'@types/github-slugger': 1.3.0
|
||||||
'@types/hast': 2.3.4
|
'@types/hast': 2.3.4
|
||||||
'@types/mdast': 3.0.10
|
'@types/mdast': 3.0.10
|
||||||
|
@ -3551,6 +3554,7 @@ importers:
|
||||||
'@types/unist': 2.0.6
|
'@types/unist': 2.0.6
|
||||||
astro-scripts: link:../../../scripts
|
astro-scripts: link:../../../scripts
|
||||||
chai: 4.3.7
|
chai: 4.3.7
|
||||||
|
mdast-util-mdx-expression: 1.3.1
|
||||||
mocha: 9.2.2
|
mocha: 9.2.2
|
||||||
|
|
||||||
packages/telemetry:
|
packages/telemetry:
|
||||||
|
|
Loading…
Reference in a new issue