diff --git a/.changeset/pretty-students-try.md b/.changeset/pretty-students-try.md new file mode 100644 index 000000000000..657d6b6d8df6 --- /dev/null +++ b/.changeset/pretty-students-try.md @@ -0,0 +1,6 @@ +--- +'@astrojs/markdoc': minor +'astro': patch +--- + +Generate heading `id`s and populate the `headings` property for all Markdoc files diff --git a/packages/astro/src/core/config/vite-load.ts b/packages/astro/src/core/config/vite-load.ts index a0d4ee913e1f..df9cfffe95f4 100644 --- a/packages/astro/src/core/config/vite-load.ts +++ b/packages/astro/src/core/config/vite-load.ts @@ -24,6 +24,7 @@ async function createViteLoader(root: string, fs: typeof fsType): Promise & { @@ -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 = applyDefaultConfig(userMarkdocConfig, entry); const validationErrors = Markdoc.validate(ast, markdocConfig).filter((e) => { return ( @@ -88,36 +88,46 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration }); } - return { - code: `import { jsx as h } from 'astro/jsx-runtime'; -import { applyDefaultConfig } from '@astrojs/markdoc/default-config'; -import { Renderer } from '@astrojs/markdoc/components'; -import * as entry from ${JSON.stringify(viteId + '?astroContent')};${ - markdocConfigResult - ? `\nimport userConfig from ${JSON.stringify( - markdocConfigResult.fileUrl.pathname - )};` - : '' - }${ - astroConfig.experimental.assets - ? `\nimport { experimentalAssetsConfig } from '@astrojs/markdoc/experimental-assets-config';` - : '' - } -const stringifiedAst = ${JSON.stringify( - /* Double stringify to encode *as* stringified JSON */ JSON.stringify(ast) - )}; + 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 * as entry from ${JSON.stringify(viteId + '?astroContent')}; +${ + markdocConfigResult + ? `import _userConfig from ${JSON.stringify( + markdocConfigResult.fileUrl.pathname + )};\nconst userConfig = _userConfig ?? {};` + : 'const userConfig = {};' +}${ + astroConfig.experimental.assets + ? `\nimport { experimentalAssetsConfig } from '@astrojs/markdoc/experimental-assets-config';\nuserConfig.nodes = { ...experimentalAssetsConfig.nodes, ...userConfig.nodes };` + : '' + } +const stringifiedAst = ${JSON.stringify(/* Double stringify to encode *as* stringified JSON */ JSON.stringify(ast))}; +export function getHeadings() { + ${ + /* Yes, we are transforming twice (once from `getHeadings()` and again from in case of variables). + TODO: propose new `render()` API to allow Markdoc variable passing to `render()` itself, + 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 ast = Markdoc.Ast.fromJSON(stringifiedAst); + const content = Markdoc.transform(ast, config); + return collectHeadings(Array.isArray(content) ? content : content.children); +} export async function Content (props) { - const config = applyDefaultConfig(${ - markdocConfigResult - ? '{ ...userConfig, variables: { ...userConfig.variables, ...props } }' - : '{ variables: props }' - }, { entry });${ - astroConfig.experimental.assets - ? `\nconfig.nodes = { ...experimentalAssetsConfig.nodes, ...config.nodes };` - : '' - } - return h(Renderer, { stringifiedAst, config }); };`, - }; + headingSlugger.reset(); + const config = applyDefaultConfig({ + ...userConfig, + variables: { ...userConfig.variables, ...props }, + }, entry); + + return h(Renderer, { config, stringifiedAst }); +}`; + return { code: res }; }, contentModuleTypes: await fs.promises.readFile( new URL('../template/content-module-types.d.ts', import.meta.url), diff --git a/packages/integrations/markdoc/src/nodes/heading.ts b/packages/integrations/markdoc/src/nodes/heading.ts new file mode 100644 index 000000000000..81a9181c7a83 --- /dev/null +++ b/packages/integrations/markdoc/src/nodes/heading.ts @@ -0,0 +1,42 @@ +import Markdoc, { type RenderableTreeNode, type Schema } from '@markdoc/markdoc'; +import { getTextContent } from '../runtime.js'; +import Slugger from 'github-slugger'; + +export const headingSlugger = new Slugger(); + +function getSlug(attributes: Record, children: RenderableTreeNode[]): string { + if (attributes.id && typeof attributes.id === 'string') { + return attributes.id; + } + const textContent = attributes.content ?? getTextContent(children); + let slug = headingSlugger.slug(textContent); + + if (slug.endsWith('-')) slug = slug.slice(0, -1); + return slug; +} + +export const heading: Schema = { + children: ['inline'], + attributes: { + id: { type: String }, + level: { type: Number, required: true, default: 1 }, + }, + transform(node, config) { + const { level, ...attributes } = node.transformAttributes(config); + const children = node.transformChildren(config); + + + const slug = getSlug(attributes, children); + + const render = config.nodes?.heading?.render ?? `h${level}`; + const tagProps = + // For components, pass down `level` as a prop, + // alongside `__collectHeading` for our `headings` collector. + // Avoid accidentally rendering `level` as an HTML attribute otherwise! + typeof render === 'function' + ? { ...attributes, id: slug, __collectHeading: true, level } + : { ...attributes, id: slug }; + + return new Markdoc.Tag(render, tagProps, children); + }, +}; diff --git a/packages/integrations/markdoc/src/nodes/index.ts b/packages/integrations/markdoc/src/nodes/index.ts new file mode 100644 index 000000000000..c25b03f27563 --- /dev/null +++ b/packages/integrations/markdoc/src/nodes/index.ts @@ -0,0 +1,4 @@ +import { heading } from './heading.js'; +export { headingSlugger } from './heading.js'; + +export const nodes = { heading }; diff --git a/packages/integrations/markdoc/src/runtime.ts b/packages/integrations/markdoc/src/runtime.ts new file mode 100644 index 000000000000..dadb73cd6601 --- /dev/null +++ b/packages/integrations/markdoc/src/runtime.ts @@ -0,0 +1,78 @@ +import type { MarkdownHeading } from '@astrojs/markdown-remark'; +import Markdoc, { + type RenderableTreeNode, + type ConfigType as MarkdocConfig, +} from '@markdoc/markdoc'; +import type { ContentEntryModule } from 'astro'; +import { nodes as astroNodes } from './nodes/index.js'; + +/** Used to reset Slugger cache on each build at runtime */ +export { headingSlugger } from './nodes/index.js'; +export { default as Markdoc } from '@markdoc/markdoc'; + +export function applyDefaultConfig( + config: MarkdocConfig, + entry: ContentEntryModule +): MarkdocConfig { + return { + ...config, + variables: { + entry, + ...config.variables, + }, + nodes: { + ...astroNodes, + ...config.nodes, + }, + // TODO: Syntax highlighting + }; +} + +/** + * Get text content as a string from a Markdoc transform AST + */ +export function getTextContent(childNodes: RenderableTreeNode[]): string { + let text = ''; + for (const node of childNodes) { + if (typeof node === 'string' || typeof node === 'number') { + text += node; + } else if (typeof node === 'object' && Markdoc.Tag.isTag(node)) { + text += getTextContent(node.children); + } + } + return text; +} + +const headingLevels = [1, 2, 3, 4, 5, 6] as const; + +/** + * Collect headings from Markdoc transform AST + * for `headings` result on `render()` return value + */ +export function collectHeadings(children: RenderableTreeNode[]): MarkdownHeading[] { + let collectedHeadings: MarkdownHeading[] = []; + for (const node of children) { + if (typeof node !== 'object' || !Markdoc.Tag.isTag(node)) continue; + + if (node.attributes.__collectHeading === true && typeof node.attributes.level === 'number') { + collectedHeadings.push({ + slug: node.attributes.id, + depth: node.attributes.level, + text: getTextContent(node.children), + }); + continue; + } + + for (const level of headingLevels) { + if (node.name === 'h' + level) { + collectedHeadings.push({ + slug: node.attributes.id, + depth: level, + text: getTextContent(node.children), + }); + } + } + collectedHeadings.concat(collectHeadings(node.children)); + } + return collectedHeadings; +} diff --git a/packages/integrations/markdoc/test/fixtures/headings-custom/astro.config.mjs b/packages/integrations/markdoc/test/fixtures/headings-custom/astro.config.mjs new file mode 100644 index 000000000000..29d846359bb2 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/headings-custom/astro.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from 'astro/config'; +import markdoc from '@astrojs/markdoc'; + +// https://astro.build/config +export default defineConfig({ + integrations: [markdoc()], +}); diff --git a/packages/integrations/markdoc/test/fixtures/headings-custom/markdoc.config.mjs b/packages/integrations/markdoc/test/fixtures/headings-custom/markdoc.config.mjs new file mode 100644 index 000000000000..32fcf61e2056 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/headings-custom/markdoc.config.mjs @@ -0,0 +1,11 @@ +import { defineMarkdocConfig, nodes } from '@astrojs/markdoc/config'; +import Heading from './src/components/Heading.astro'; + +export default defineMarkdocConfig({ + nodes: { + heading: { + ...nodes.heading, + render: Heading, + } + } +}); diff --git a/packages/integrations/markdoc/test/fixtures/headings-custom/package.json b/packages/integrations/markdoc/test/fixtures/headings-custom/package.json new file mode 100644 index 000000000000..67a974912ec3 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/headings-custom/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/headings-custom", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/markdoc": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/markdoc/test/fixtures/headings-custom/src/components/Heading.astro b/packages/integrations/markdoc/test/fixtures/headings-custom/src/components/Heading.astro new file mode 100644 index 000000000000..ec6fa83050cd --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/headings-custom/src/components/Heading.astro @@ -0,0 +1,14 @@ +--- +type Props = { + level: number; + id: string; +}; + +const { level, id }: Props = Astro.props; + +const Tag = `h${level}`; +--- + + + + diff --git a/packages/integrations/markdoc/test/fixtures/headings-custom/src/content/docs/headings.mdoc b/packages/integrations/markdoc/test/fixtures/headings-custom/src/content/docs/headings.mdoc new file mode 100644 index 000000000000..3eb66580a43a --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/headings-custom/src/content/docs/headings.mdoc @@ -0,0 +1,11 @@ +# 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/index.astro new file mode 100644 index 000000000000..5880be0e3db7 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/headings-custom/src/pages/index.astro @@ -0,0 +1,28 @@ +--- +import { getEntryBySlug } from "astro:content"; + +const post = await getEntryBySlug('docs', 'headings'); +const { Content, headings } = await post.render(); +--- + + + + + + + + Content + + + + + + diff --git a/packages/integrations/markdoc/test/fixtures/headings/astro.config.mjs b/packages/integrations/markdoc/test/fixtures/headings/astro.config.mjs new file mode 100644 index 000000000000..29d846359bb2 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/headings/astro.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from 'astro/config'; +import markdoc from '@astrojs/markdoc'; + +// https://astro.build/config +export default defineConfig({ + integrations: [markdoc()], +}); diff --git a/packages/integrations/markdoc/test/fixtures/headings/markdoc.config.mjs b/packages/integrations/markdoc/test/fixtures/headings/markdoc.config.mjs new file mode 100644 index 000000000000..a5863ec1295f --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/headings/markdoc.config.mjs @@ -0,0 +1,3 @@ +import { defineMarkdocConfig } from '@astrojs/markdoc/config'; + +export default defineMarkdocConfig({}); diff --git a/packages/integrations/markdoc/test/fixtures/headings/package.json b/packages/integrations/markdoc/test/fixtures/headings/package.json new file mode 100644 index 000000000000..1daaae400569 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/headings/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/headings", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/markdoc": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/markdoc/test/fixtures/headings/src/content/docs/headings.mdoc b/packages/integrations/markdoc/test/fixtures/headings/src/content/docs/headings.mdoc new file mode 100644 index 000000000000..3eb66580a43a --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/headings/src/content/docs/headings.mdoc @@ -0,0 +1,11 @@ +# 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/index.astro new file mode 100644 index 000000000000..5880be0e3db7 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/headings/src/pages/index.astro @@ -0,0 +1,28 @@ +--- +import { getEntryBySlug } from "astro:content"; + +const post = await getEntryBySlug('docs', 'headings'); +const { Content, headings } = await post.render(); +--- + + + + + + + + Content + + + + + + diff --git a/packages/integrations/markdoc/test/headings.test.js b/packages/integrations/markdoc/test/headings.test.js new file mode 100644 index 000000000000..5db50065cb62 --- /dev/null +++ b/packages/integrations/markdoc/test/headings.test.js @@ -0,0 +1,192 @@ +import { parseHTML } from 'linkedom'; +import { expect } from 'chai'; +import { loadFixture } from '../../../astro/test/test-utils.js'; + +async function getFixture(name) { + return await loadFixture({ + root: new URL(`./fixtures/${name}/`, import.meta.url), + }); +} + +describe('Markdoc - Headings', () => { + let fixture; + + before(async () => { + fixture = await getFixture('headings'); + }); + + describe('dev', () => { + let devServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('applies IDs to headings', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + const { document } = parseHTML(html); + + idTest(document); + }); + + it('generates a TOC with correct info', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + const { document } = parseHTML(html); + + tocTest(document); + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('applies IDs to headings', async () => { + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + idTest(document); + }); + + it('generates a TOC with correct info', async () => { + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + tocTest(document); + }); + }); +}); + +describe('Markdoc - Headings with custom Astro renderer', () => { + let fixture; + + before(async () => { + fixture = await getFixture('headings-custom'); + }); + + describe('dev', () => { + let devServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('applies IDs to headings', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + const { document } = parseHTML(html); + + idTest(document); + }); + + it('generates a TOC with correct info', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + const { document } = parseHTML(html); + + tocTest(document); + }); + + it('renders Astro component for each heading', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + const { document } = parseHTML(html); + + astroComponentTest(document); + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('applies IDs to headings', async () => { + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + idTest(document); + }); + + it('generates a TOC with correct info', async () => { + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + tocTest(document); + }); + + it('renders Astro component for each heading', async () => { + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + astroComponentTest(document); + }); + }); +}); + +const depthToHeadingMap = { + 1: { + slug: 'level-1-heading', + text: 'Level 1 heading', + }, + 2: { + slug: 'level-2-heading', + text: 'Level 2 heading', + }, + 3: { + slug: 'level-3-heading', + text: 'Level 3 heading', + }, + 4: { + slug: 'level-4-heading', + text: 'Level 4 heading', + }, + 5: { + slug: 'id-override', + text: 'Level 5 heading with override', + }, + 6: { + slug: 'level-6-heading', + text: 'Level 6 heading', + }, +}; + +/** @param {Document} document */ +function idTest(document) { + for (const [depth, info] of Object.entries(depthToHeadingMap)) { + expect(document.querySelector(`h${depth}`)?.getAttribute('id')).to.equal(info.slug); + } +} + +/** @param {Document} document */ +function tocTest(document) { + const toc = document.querySelector('[data-toc] > ul'); + expect(toc.children).to.have.lengthOf(Object.keys(depthToHeadingMap).length); + + for (const [depth, info] of Object.entries(depthToHeadingMap)) { + const linkEl = toc.querySelector(`a[href="#${info.slug}"]`); + expect(linkEl).to.exist; + expect(linkEl.getAttribute('data-depth')).to.equal(depth); + expect(linkEl.textContent.trim()).to.equal(info.text); + } +} + +/** @param {Document} document */ +function astroComponentTest(document) { + const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6'); + + for (const heading of headings) { + expect(heading.hasAttribute('data-custom-heading')).to.be.true; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1af7cc696b2..f4e6aec1c0a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3913,6 +3913,9 @@ importers: esbuild: specifier: ^0.17.12 version: 0.17.12 + github-slugger: + specifier: ^2.0.0 + version: 2.0.0 gray-matter: specifier: ^4.0.3 version: 4.0.3 @@ -3923,6 +3926,9 @@ importers: specifier: ^3.17.3 version: 3.20.6 devDependencies: + '@astrojs/markdown-remark': + specifier: ^2.2.0 + version: link:../../markdown/remark '@types/chai': specifier: ^4.3.1 version: 4.3.3 @@ -3975,6 +3981,24 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/markdoc/test/fixtures/headings: + dependencies: + '@astrojs/markdoc': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + + packages/integrations/markdoc/test/fixtures/headings-custom: + dependencies: + '@astrojs/markdoc': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/integrations/markdoc/test/fixtures/image-assets: dependencies: '@astrojs/markdoc':