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 { bold, red, yellow } from 'kleur/colors';
|
||||||
import type * as rollup from 'rollup';
|
import type * as rollup from 'rollup';
|
||||||
import { loadMarkdocConfig, type MarkdocConfigResult } from './load-config.js';
|
import { loadMarkdocConfig, type MarkdocConfigResult } from './load-config.js';
|
||||||
import { applyDefaultConfig } from './runtime.js';
|
import { setupConfig } from './runtime.js';
|
||||||
|
|
||||||
type SetupHookParams = HookParameters<'astro:config:setup'> & {
|
type SetupHookParams = HookParameters<'astro:config:setup'> & {
|
||||||
// `contentEntryType` is not a public API
|
// `contentEntryType` is not a public API
|
||||||
|
@ -52,7 +52,7 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration
|
||||||
async getRenderModule({ entry, viteId }) {
|
async getRenderModule({ entry, viteId }) {
|
||||||
const ast = Markdoc.parse(entry.body);
|
const ast = Markdoc.parse(entry.body);
|
||||||
const pluginContext = this;
|
const pluginContext = this;
|
||||||
const markdocConfig = applyDefaultConfig(userMarkdocConfig, entry);
|
const markdocConfig = setupConfig(userMarkdocConfig, entry);
|
||||||
|
|
||||||
const validationErrors = Markdoc.validate(ast, markdocConfig).filter((e) => {
|
const validationErrors = Markdoc.validate(ast, markdocConfig).filter((e) => {
|
||||||
return (
|
return (
|
||||||
|
@ -90,7 +90,7 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration
|
||||||
|
|
||||||
const res = `import { jsx as h } from 'astro/jsx-runtime';
|
const res = `import { jsx as h } from 'astro/jsx-runtime';
|
||||||
import { Renderer } from '@astrojs/markdoc/components';
|
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')};
|
import * as entry from ${JSON.stringify(viteId + '?astroContentCollectionEntry')};
|
||||||
${
|
${
|
||||||
markdocConfigResult
|
markdocConfigResult
|
||||||
|
@ -113,16 +113,14 @@ export function getHeadings() {
|
||||||
instead of the Content component. Would remove double-transform and unlock variable resolution in heading slugs. */
|
instead of the Content component. Would remove double-transform and unlock variable resolution in heading slugs. */
|
||||||
''
|
''
|
||||||
}
|
}
|
||||||
headingSlugger.reset();
|
|
||||||
const headingConfig = userConfig.nodes?.heading;
|
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 ast = Markdoc.Ast.fromJSON(stringifiedAst);
|
||||||
const content = Markdoc.transform(ast, config);
|
const content = Markdoc.transform(ast, config);
|
||||||
return collectHeadings(Array.isArray(content) ? content : content.children);
|
return collectHeadings(Array.isArray(content) ? content : content.children);
|
||||||
}
|
}
|
||||||
export async function Content (props) {
|
export async function Content (props) {
|
||||||
headingSlugger.reset();
|
const config = setupConfig({
|
||||||
const config = applyDefaultConfig({
|
|
||||||
...userConfig,
|
...userConfig,
|
||||||
variables: { ...userConfig.variables, ...props },
|
variables: { ...userConfig.variables, ...props },
|
||||||
}, entry);
|
}, 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 Slugger from 'github-slugger';
|
||||||
import { getTextContent } from '../runtime.js';
|
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') {
|
if (attributes.id && typeof attributes.id === 'string') {
|
||||||
return attributes.id;
|
return attributes.id;
|
||||||
}
|
}
|
||||||
|
@ -21,11 +30,11 @@ export const heading: Schema = {
|
||||||
id: { type: String },
|
id: { type: String },
|
||||||
level: { type: Number, required: true, default: 1 },
|
level: { type: Number, required: true, default: 1 },
|
||||||
},
|
},
|
||||||
transform(node, config) {
|
transform(node, config: ConfigTypeWithCtx) {
|
||||||
const { level, ...attributes } = node.transformAttributes(config);
|
const { level, ...attributes } = node.transformAttributes(config);
|
||||||
const children = node.transformChildren(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 render = config.nodes?.heading?.render ?? `h${level}`;
|
||||||
const tagProps =
|
const tagProps =
|
||||||
|
@ -39,3 +48,16 @@ export const heading: Schema = {
|
||||||
return new Markdoc.Tag(render, tagProps, children);
|
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';
|
import { heading } from './heading.js';
|
||||||
export { headingSlugger } from './heading.js';
|
export { setupHeadingConfig } from './heading.js';
|
||||||
|
|
||||||
export const nodes = { heading };
|
export const nodes = { heading };
|
||||||
|
|
|
@ -4,27 +4,45 @@ import Markdoc, {
|
||||||
type RenderableTreeNode,
|
type RenderableTreeNode,
|
||||||
} from '@markdoc/markdoc';
|
} from '@markdoc/markdoc';
|
||||||
import type { ContentEntryModule } from 'astro';
|
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 { default as Markdoc } from '@markdoc/markdoc';
|
||||||
export { headingSlugger } from './nodes/index.js';
|
|
||||||
|
|
||||||
export function applyDefaultConfig(
|
/**
|
||||||
config: MarkdocConfig,
|
* Merge user config with default config and set up context (ex. heading ID slugger)
|
||||||
entry: ContentEntryModule
|
* Called on each file's individual transform
|
||||||
): MarkdocConfig {
|
*/
|
||||||
|
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 {
|
return {
|
||||||
...config,
|
...configA,
|
||||||
variables: {
|
...configB,
|
||||||
entry,
|
tags: {
|
||||||
...config.variables,
|
...configA.tags,
|
||||||
|
...configB.tags,
|
||||||
},
|
},
|
||||||
nodes: {
|
nodes: {
|
||||||
...astroNodes,
|
...configA.nodes,
|
||||||
...config.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');
|
export async function getStaticPaths() {
|
||||||
const { Content, headings } = await post.render();
|
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>
|
<!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');
|
export async function getStaticPaths() {
|
||||||
const { Content, headings } = await post.render();
|
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>
|
<!DOCTYPE html>
|
|
@ -27,7 +27,15 @@ describe('Markdoc - Headings', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies IDs to headings', async () => {
|
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 html = await res.text();
|
||||||
const { document } = parseHTML(html);
|
const { document } = parseHTML(html);
|
||||||
|
|
||||||
|
@ -35,7 +43,7 @@ describe('Markdoc - Headings', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates a TOC with correct info', async () => {
|
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 html = await res.text();
|
||||||
const { document } = parseHTML(html);
|
const { document } = parseHTML(html);
|
||||||
|
|
||||||
|
@ -49,14 +57,21 @@ describe('Markdoc - Headings', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies IDs to headings', async () => {
|
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);
|
const { document } = parseHTML(html);
|
||||||
|
|
||||||
idTest(document);
|
idTest(document);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates a TOC with correct info', async () => {
|
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);
|
const { document } = parseHTML(html);
|
||||||
|
|
||||||
tocTest(document);
|
tocTest(document);
|
||||||
|
@ -83,7 +98,15 @@ describe('Markdoc - Headings with custom Astro renderer', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies IDs to headings', async () => {
|
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 html = await res.text();
|
||||||
const { document } = parseHTML(html);
|
const { document } = parseHTML(html);
|
||||||
|
|
||||||
|
@ -91,7 +114,7 @@ describe('Markdoc - Headings with custom Astro renderer', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates a TOC with correct info', async () => {
|
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 html = await res.text();
|
||||||
const { document } = parseHTML(html);
|
const { document } = parseHTML(html);
|
||||||
|
|
||||||
|
@ -99,7 +122,7 @@ describe('Markdoc - Headings with custom Astro renderer', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders Astro component for each heading', async () => {
|
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 html = await res.text();
|
||||||
const { document } = parseHTML(html);
|
const { document } = parseHTML(html);
|
||||||
|
|
||||||
|
@ -113,21 +136,28 @@ describe('Markdoc - Headings with custom Astro renderer', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies IDs to headings', async () => {
|
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);
|
const { document } = parseHTML(html);
|
||||||
|
|
||||||
idTest(document);
|
idTest(document);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates a TOC with correct info', async () => {
|
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);
|
const { document } = parseHTML(html);
|
||||||
|
|
||||||
tocTest(document);
|
tocTest(document);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders Astro component for each heading', async () => {
|
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);
|
const { document } = parseHTML(html);
|
||||||
|
|
||||||
astroComponentTest(document);
|
astroComponentTest(document);
|
||||||
|
|
Loading…
Add table
Reference in a new issue