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:
Ben Holmes 2023-05-19 14:12:45 -04:00 committed by GitHub
parent 147373722b
commit a9e1cd7e58
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 154 additions and 43 deletions

View file

@ -0,0 +1,5 @@
---
'@astrojs/markdoc': patch
---
Fix inconsistent Markdoc heading IDs for documents with the same headings.

View file

@ -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);

View file

@ -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,
},
};
}

View file

@ -1,4 +1,4 @@
import { heading } from './heading.js';
export { headingSlugger } from './heading.js';
export { setupHeadingConfig } from './heading.js';
export const nodes = { heading };

View file

@ -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
};
}

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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);