Fix: Heading ID CI flakiness (#7141)
* feat: use `ctx` object instead of leaky global * test: heading IDs stale caches * chore: changeset
This commit is contained in:
parent
147373722b
commit
a9e1cd7e58
10 changed files with 154 additions and 43 deletions
5
.changeset/eleven-tables-speak.md
Normal file
5
.changeset/eleven-tables-speak.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@astrojs/markdoc': patch
|
||||
---
|
||||
|
||||
Fix inconsistent Markdoc heading IDs for documents with the same headings.
|
|
@ -10,7 +10,7 @@ import { emitESMImage } from 'astro/assets';
|
|||
import { bold, red, yellow } from 'kleur/colors';
|
||||
import type * as rollup from 'rollup';
|
||||
import { loadMarkdocConfig, type MarkdocConfigResult } from './load-config.js';
|
||||
import { applyDefaultConfig } from './runtime.js';
|
||||
import { setupConfig } from './runtime.js';
|
||||
|
||||
type SetupHookParams = HookParameters<'astro:config:setup'> & {
|
||||
// `contentEntryType` is not a public API
|
||||
|
@ -52,7 +52,7 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration
|
|||
async getRenderModule({ entry, viteId }) {
|
||||
const ast = Markdoc.parse(entry.body);
|
||||
const pluginContext = this;
|
||||
const markdocConfig = applyDefaultConfig(userMarkdocConfig, entry);
|
||||
const markdocConfig = setupConfig(userMarkdocConfig, entry);
|
||||
|
||||
const validationErrors = Markdoc.validate(ast, markdocConfig).filter((e) => {
|
||||
return (
|
||||
|
@ -90,7 +90,7 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration
|
|||
|
||||
const res = `import { jsx as h } from 'astro/jsx-runtime';
|
||||
import { Renderer } from '@astrojs/markdoc/components';
|
||||
import { collectHeadings, applyDefaultConfig, Markdoc, headingSlugger } from '@astrojs/markdoc/runtime';
|
||||
import { collectHeadings, setupConfig, Markdoc } from '@astrojs/markdoc/runtime';
|
||||
import * as entry from ${JSON.stringify(viteId + '?astroContentCollectionEntry')};
|
||||
${
|
||||
markdocConfigResult
|
||||
|
@ -113,16 +113,14 @@ export function getHeadings() {
|
|||
instead of the Content component. Would remove double-transform and unlock variable resolution in heading slugs. */
|
||||
''
|
||||
}
|
||||
headingSlugger.reset();
|
||||
const headingConfig = userConfig.nodes?.heading;
|
||||
const config = applyDefaultConfig(headingConfig ? { nodes: { heading: headingConfig } } : {}, entry);
|
||||
const config = setupConfig(headingConfig ? { nodes: { heading: headingConfig } } : {}, entry);
|
||||
const ast = Markdoc.Ast.fromJSON(stringifiedAst);
|
||||
const content = Markdoc.transform(ast, config);
|
||||
return collectHeadings(Array.isArray(content) ? content : content.children);
|
||||
}
|
||||
export async function Content (props) {
|
||||
headingSlugger.reset();
|
||||
const config = applyDefaultConfig({
|
||||
const config = setupConfig({
|
||||
...userConfig,
|
||||
variables: { ...userConfig.variables, ...props },
|
||||
}, entry);
|
||||
|
|
|
@ -1,10 +1,19 @@
|
|||
import Markdoc, { type RenderableTreeNode, type Schema } from '@markdoc/markdoc';
|
||||
import Markdoc, { type RenderableTreeNode, type Schema, type ConfigType } from '@markdoc/markdoc';
|
||||
import Slugger from 'github-slugger';
|
||||
import { getTextContent } from '../runtime.js';
|
||||
|
||||
export const headingSlugger = new Slugger();
|
||||
type ConfigTypeWithCtx = ConfigType & {
|
||||
// TODO: decide on `ctx` as a convention for config merging
|
||||
ctx: {
|
||||
headingSlugger: Slugger;
|
||||
};
|
||||
};
|
||||
|
||||
function getSlug(attributes: Record<string, any>, children: RenderableTreeNode[]): string {
|
||||
function getSlug(
|
||||
attributes: Record<string, any>,
|
||||
children: RenderableTreeNode[],
|
||||
headingSlugger: Slugger
|
||||
): string {
|
||||
if (attributes.id && typeof attributes.id === 'string') {
|
||||
return attributes.id;
|
||||
}
|
||||
|
@ -21,11 +30,11 @@ export const heading: Schema = {
|
|||
id: { type: String },
|
||||
level: { type: Number, required: true, default: 1 },
|
||||
},
|
||||
transform(node, config) {
|
||||
transform(node, config: ConfigTypeWithCtx) {
|
||||
const { level, ...attributes } = node.transformAttributes(config);
|
||||
const children = node.transformChildren(config);
|
||||
|
||||
const slug = getSlug(attributes, children);
|
||||
const slug = getSlug(attributes, children, config.ctx.headingSlugger);
|
||||
|
||||
const render = config.nodes?.heading?.render ?? `h${level}`;
|
||||
const tagProps =
|
||||
|
@ -39,3 +48,16 @@ export const heading: Schema = {
|
|||
return new Markdoc.Tag(render, tagProps, children);
|
||||
},
|
||||
};
|
||||
|
||||
export function setupHeadingConfig(): ConfigTypeWithCtx {
|
||||
const headingSlugger = new Slugger();
|
||||
|
||||
return {
|
||||
ctx: {
|
||||
headingSlugger,
|
||||
},
|
||||
nodes: {
|
||||
heading,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { heading } from './heading.js';
|
||||
export { headingSlugger } from './heading.js';
|
||||
export { setupHeadingConfig } from './heading.js';
|
||||
|
||||
export const nodes = { heading };
|
||||
|
|
|
@ -4,27 +4,45 @@ import Markdoc, {
|
|||
type RenderableTreeNode,
|
||||
} from '@markdoc/markdoc';
|
||||
import type { ContentEntryModule } from 'astro';
|
||||
import { nodes as astroNodes } from './nodes/index.js';
|
||||
import { setupHeadingConfig } from './nodes/index.js';
|
||||
|
||||
/** Used to reset Slugger cache on each build at runtime */
|
||||
/** Used to call `Markdoc.transform()` and `Markdoc.Ast` in runtime modules */
|
||||
export { default as Markdoc } from '@markdoc/markdoc';
|
||||
export { headingSlugger } from './nodes/index.js';
|
||||
|
||||
export function applyDefaultConfig(
|
||||
config: MarkdocConfig,
|
||||
entry: ContentEntryModule
|
||||
): MarkdocConfig {
|
||||
/**
|
||||
* Merge user config with default config and set up context (ex. heading ID slugger)
|
||||
* Called on each file's individual transform
|
||||
*/
|
||||
export function setupConfig(userConfig: MarkdocConfig, entry: ContentEntryModule): MarkdocConfig {
|
||||
const defaultConfig: MarkdocConfig = {
|
||||
// `setupXConfig()` could become a "plugin" convention as well?
|
||||
...setupHeadingConfig(),
|
||||
variables: { entry },
|
||||
};
|
||||
return mergeConfig(defaultConfig, userConfig);
|
||||
}
|
||||
|
||||
/** Merge function from `@markdoc/markdoc` internals */
|
||||
function mergeConfig(configA: MarkdocConfig, configB: MarkdocConfig): MarkdocConfig {
|
||||
return {
|
||||
...config,
|
||||
variables: {
|
||||
entry,
|
||||
...config.variables,
|
||||
...configA,
|
||||
...configB,
|
||||
tags: {
|
||||
...configA.tags,
|
||||
...configB.tags,
|
||||
},
|
||||
nodes: {
|
||||
...astroNodes,
|
||||
...config.nodes,
|
||||
...configA.nodes,
|
||||
...configB.nodes,
|
||||
},
|
||||
functions: {
|
||||
...configA.functions,
|
||||
...configB.functions,
|
||||
},
|
||||
variables: {
|
||||
...configA.variables,
|
||||
...configB.variables,
|
||||
},
|
||||
// TODO: Syntax highlighting
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
Our heading ID generator can have a stale cache for duplicates. Let's check for those!
|
||||
|
||||
# Level 1 heading
|
||||
|
||||
## Level **2 heading**
|
||||
|
||||
### Level _3 heading_
|
||||
|
||||
#### Level [4 heading](/with-a-link)
|
||||
|
||||
##### Level 5 heading with override {% #id-override %}
|
||||
|
||||
###### Level 6 heading
|
|
@ -1,8 +1,14 @@
|
|||
---
|
||||
import { getEntryBySlug } from "astro:content";
|
||||
import { getCollection, CollectionEntry } from "astro:content";
|
||||
|
||||
const post = await getEntryBySlug('docs', 'headings');
|
||||
const { Content, headings } = await post.render();
|
||||
export async function getStaticPaths() {
|
||||
const docs = await getCollection('docs');
|
||||
return docs.map(doc => ({ params: { slug: doc.slug }, props: doc }));
|
||||
}
|
||||
|
||||
type Props = CollectionEntry<'docs'>;
|
||||
|
||||
const { Content, headings } = await Astro.props.render();
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
|
@ -0,0 +1,13 @@
|
|||
Our heading ID generator can have a stale cache for duplicates. Let's check for those!
|
||||
|
||||
# Level 1 heading
|
||||
|
||||
## Level **2 heading**
|
||||
|
||||
### Level _3 heading_
|
||||
|
||||
#### Level [4 heading](/with-a-link)
|
||||
|
||||
##### Level 5 heading with override {% #id-override %}
|
||||
|
||||
###### Level 6 heading
|
|
@ -1,8 +1,14 @@
|
|||
---
|
||||
import { getEntryBySlug } from "astro:content";
|
||||
import { getCollection, CollectionEntry } from "astro:content";
|
||||
|
||||
const post = await getEntryBySlug('docs', 'headings');
|
||||
const { Content, headings } = await post.render();
|
||||
export async function getStaticPaths() {
|
||||
const docs = await getCollection('docs');
|
||||
return docs.map(doc => ({ params: { slug: doc.slug }, props: doc }));
|
||||
}
|
||||
|
||||
type Props = CollectionEntry<'docs'>;
|
||||
|
||||
const { Content, headings } = await Astro.props.render();
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
|
@ -27,7 +27,15 @@ describe('Markdoc - Headings', () => {
|
|||
});
|
||||
|
||||
it('applies IDs to headings', async () => {
|
||||
const res = await fixture.fetch('/');
|
||||
const res = await fixture.fetch('/headings');
|
||||
const html = await res.text();
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
idTest(document);
|
||||
});
|
||||
|
||||
it('generates the same IDs for other documents with the same headings', async () => {
|
||||
const res = await fixture.fetch('/headings-stale-cache-check');
|
||||
const html = await res.text();
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
|
@ -35,7 +43,7 @@ describe('Markdoc - Headings', () => {
|
|||
});
|
||||
|
||||
it('generates a TOC with correct info', async () => {
|
||||
const res = await fixture.fetch('/');
|
||||
const res = await fixture.fetch('/headings');
|
||||
const html = await res.text();
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
|
@ -49,14 +57,21 @@ describe('Markdoc - Headings', () => {
|
|||
});
|
||||
|
||||
it('applies IDs to headings', async () => {
|
||||
const html = await fixture.readFile('/index.html');
|
||||
const html = await fixture.readFile('/headings/index.html');
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
idTest(document);
|
||||
});
|
||||
|
||||
it('generates the same IDs for other documents with the same headings', async () => {
|
||||
const html = await fixture.readFile('/headings-stale-cache-check/index.html');
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
idTest(document);
|
||||
});
|
||||
|
||||
it('generates a TOC with correct info', async () => {
|
||||
const html = await fixture.readFile('/index.html');
|
||||
const html = await fixture.readFile('/headings/index.html');
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
tocTest(document);
|
||||
|
@ -83,7 +98,15 @@ describe('Markdoc - Headings with custom Astro renderer', () => {
|
|||
});
|
||||
|
||||
it('applies IDs to headings', async () => {
|
||||
const res = await fixture.fetch('/');
|
||||
const res = await fixture.fetch('/headings');
|
||||
const html = await res.text();
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
idTest(document);
|
||||
});
|
||||
|
||||
it('generates the same IDs for other documents with the same headings', async () => {
|
||||
const res = await fixture.fetch('/headings-stale-cache-check');
|
||||
const html = await res.text();
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
|
@ -91,7 +114,7 @@ describe('Markdoc - Headings with custom Astro renderer', () => {
|
|||
});
|
||||
|
||||
it('generates a TOC with correct info', async () => {
|
||||
const res = await fixture.fetch('/');
|
||||
const res = await fixture.fetch('/headings');
|
||||
const html = await res.text();
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
|
@ -99,7 +122,7 @@ describe('Markdoc - Headings with custom Astro renderer', () => {
|
|||
});
|
||||
|
||||
it('renders Astro component for each heading', async () => {
|
||||
const res = await fixture.fetch('/');
|
||||
const res = await fixture.fetch('/headings');
|
||||
const html = await res.text();
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
|
@ -113,21 +136,28 @@ describe('Markdoc - Headings with custom Astro renderer', () => {
|
|||
});
|
||||
|
||||
it('applies IDs to headings', async () => {
|
||||
const html = await fixture.readFile('/index.html');
|
||||
const html = await fixture.readFile('/headings/index.html');
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
idTest(document);
|
||||
});
|
||||
|
||||
it('generates the same IDs for other documents with the same headings', async () => {
|
||||
const html = await fixture.readFile('/headings-stale-cache-check/index.html');
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
idTest(document);
|
||||
});
|
||||
|
||||
it('generates a TOC with correct info', async () => {
|
||||
const html = await fixture.readFile('/index.html');
|
||||
const html = await fixture.readFile('/headings/index.html');
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
tocTest(document);
|
||||
});
|
||||
|
||||
it('renders Astro component for each heading', async () => {
|
||||
const html = await fixture.readFile('/index.html');
|
||||
const html = await fixture.readFile('/headings/index.html');
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
astroComponentTest(document);
|
||||
|
|
Loading…
Reference in a new issue