diff --git a/.changeset/eleven-tables-speak.md b/.changeset/eleven-tables-speak.md new file mode 100644 index 000000000..44aff3211 --- /dev/null +++ b/.changeset/eleven-tables-speak.md @@ -0,0 +1,5 @@ +--- +'@astrojs/markdoc': patch +--- + +Fix inconsistent Markdoc heading IDs for documents with the same headings. diff --git a/packages/integrations/markdoc/src/index.ts b/packages/integrations/markdoc/src/index.ts index 4360800a0..627f08c77 100644 --- a/packages/integrations/markdoc/src/index.ts +++ b/packages/integrations/markdoc/src/index.ts @@ -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); diff --git a/packages/integrations/markdoc/src/nodes/heading.ts b/packages/integrations/markdoc/src/nodes/heading.ts index 8adf57612..19a988b63 100644 --- a/packages/integrations/markdoc/src/nodes/heading.ts +++ b/packages/integrations/markdoc/src/nodes/heading.ts @@ -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, children: RenderableTreeNode[]): string { +function getSlug( + attributes: Record, + 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, + }, + }; +} diff --git a/packages/integrations/markdoc/src/nodes/index.ts b/packages/integrations/markdoc/src/nodes/index.ts index c25b03f27..4cd7e3667 100644 --- a/packages/integrations/markdoc/src/nodes/index.ts +++ b/packages/integrations/markdoc/src/nodes/index.ts @@ -1,4 +1,4 @@ import { heading } from './heading.js'; -export { headingSlugger } from './heading.js'; +export { setupHeadingConfig } from './heading.js'; export const nodes = { heading }; diff --git a/packages/integrations/markdoc/src/runtime.ts b/packages/integrations/markdoc/src/runtime.ts index 61b38fd02..3164cda13 100644 --- a/packages/integrations/markdoc/src/runtime.ts +++ b/packages/integrations/markdoc/src/runtime.ts @@ -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 }; } diff --git a/packages/integrations/markdoc/test/fixtures/headings-custom/src/content/docs/headings-stale-cache-check.mdoc b/packages/integrations/markdoc/test/fixtures/headings-custom/src/content/docs/headings-stale-cache-check.mdoc new file mode 100644 index 000000000..75cd52884 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/headings-custom/src/content/docs/headings-stale-cache-check.mdoc @@ -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 diff --git a/packages/integrations/markdoc/test/fixtures/headings-custom/src/pages/index.astro b/packages/integrations/markdoc/test/fixtures/headings-custom/src/pages/[slug].astro similarity index 58% rename from packages/integrations/markdoc/test/fixtures/headings-custom/src/pages/index.astro rename to packages/integrations/markdoc/test/fixtures/headings-custom/src/pages/[slug].astro index 5880be0e3..2baef9d69 100644 --- a/packages/integrations/markdoc/test/fixtures/headings-custom/src/pages/index.astro +++ b/packages/integrations/markdoc/test/fixtures/headings-custom/src/pages/[slug].astro @@ -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(); --- diff --git a/packages/integrations/markdoc/test/fixtures/headings/src/content/docs/headings-stale-cache-check.mdoc b/packages/integrations/markdoc/test/fixtures/headings/src/content/docs/headings-stale-cache-check.mdoc new file mode 100644 index 000000000..75cd52884 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/headings/src/content/docs/headings-stale-cache-check.mdoc @@ -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 diff --git a/packages/integrations/markdoc/test/fixtures/headings/src/pages/index.astro b/packages/integrations/markdoc/test/fixtures/headings/src/pages/[slug].astro similarity index 58% rename from packages/integrations/markdoc/test/fixtures/headings/src/pages/index.astro rename to packages/integrations/markdoc/test/fixtures/headings/src/pages/[slug].astro index 5880be0e3..2baef9d69 100644 --- a/packages/integrations/markdoc/test/fixtures/headings/src/pages/index.astro +++ b/packages/integrations/markdoc/test/fixtures/headings/src/pages/[slug].astro @@ -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(); --- diff --git a/packages/integrations/markdoc/test/headings.test.js b/packages/integrations/markdoc/test/headings.test.js index 5db50065c..5468e8c6b 100644 --- a/packages/integrations/markdoc/test/headings.test.js +++ b/packages/integrations/markdoc/test/headings.test.js @@ -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);