Skip to content

Commit

Permalink
Fix MDX heading IDs generation when using a frontmatter reference (#5978
Browse files Browse the repository at this point in the history
)

* Fix MDX heading IDs generation when using a frontmatter reference

* Hoist safelyGetAstroData() call and add statement null check
  • Loading branch information
HiDeoo committed Jan 26, 2023
1 parent e16958f commit 7abb1e9
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 3 deletions.
6 changes: 6 additions & 0 deletions .changeset/eighty-knives-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astrojs/mdx': patch
'@astrojs/markdown-remark': patch
---

Fix MDX heading IDs generation when using a frontmatter reference
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 43 additions & 0 deletions packages/integrations/mdx/test/mdx-get-headings.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
])
);
});
});
2 changes: 2 additions & 0 deletions packages/markdown/remark/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@
},
"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",
"@types/mocha": "^9.1.1",
"@types/unist": "^2.0.6",
"astro-scripts": "workspace:*",
"chai": "^4.3.6",
"mdast-util-mdx-expression": "^1.3.1",
"mocha": "^9.2.2"
}
}
75 changes: 72 additions & 3 deletions packages/markdown/remark/src/rehype-collect-headings.ts
Original file line number Diff line number Diff line change
@@ -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']);
Expand All @@ -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;
Expand All @@ -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, '${');
}
Expand All @@ -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']];
4 changes: 4 additions & 0 deletions pnpm-lock.yaml

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

0 comments on commit 7abb1e9

Please sign in to comment.