diff options
author | 2023-05-17 09:13:10 -0400 | |
---|---|---|
committer | 2023-05-17 09:13:10 -0400 | |
commit | fb84622af04f795de8d17f24192de105f70fe910 (patch) | |
tree | 11a99efdb90c17207d3adc1095e88fa8daddd7e4 | |
parent | c91e837e961043e92253148f0f4291856653b993 (diff) | |
download | astro-fb84622af04f795de8d17f24192de105f70fe910.tar.gz astro-fb84622af04f795de8d17f24192de105f70fe910.tar.zst astro-fb84622af04f795de8d17f24192de105f70fe910.zip |
[Markdoc] `headings` and heading IDs (#7095)
* deps: markdown-remark
* wip: heading-ids function
* chore: add `@astrojs/markdoc` to external
* feat: `headings` support
* fix: allow `render` config on headings
* fix: nonexistent `userConfig`
* test: headings, toc, astro component render
* docs: README
* chore: changeset
* refactor: expose Markdoc helpers from runtime
* fix: bad named exports (commonjsssss)
* refactor: defaultNodes -> nodes
* deps: github-slugger
* fix: reset slugger cache on each render
* fix: bad astroNodes import
* docs: explain headingSlugger export
* docs: add back double stringify comment
* chore: bump to minor for internal exports change
24 files changed, 542 insertions, 60 deletions
diff --git a/.changeset/pretty-students-try.md b/.changeset/pretty-students-try.md new file mode 100644 index 000000000..657d6b6d8 --- /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 a0d4ee913..df9cfffe9 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<ViteLo '@astrojs/react', '@astrojs/preact', '@astrojs/sitemap', + '@astrojs/markdoc', ], }, plugins: [loadFallbackPlugin({ fs, root: pathToFileURL(root) })], diff --git a/packages/integrations/markdoc/README.md b/packages/integrations/markdoc/README.md index 9a8bda3bb..e3cec5499 100644 --- a/packages/integrations/markdoc/README.md +++ b/packages/integrations/markdoc/README.md @@ -143,30 +143,29 @@ Use tags like this fancy "aside" to add some *flair* to your docs. #### Render Markdoc nodes / HTML elements as Astro components -You may also want to map standard HTML elements like headings and paragraphs to components. For this, you can configure a custom [Markdoc node][markdoc-nodes]. This example overrides Markdoc's `heading` node to render a `Heading` component, and passes through [Markdoc's default attributes for headings](https://markdoc.dev/docs/nodes#built-in-nodes). +You may also want to map standard HTML elements like headings and paragraphs to components. For this, you can configure a custom [Markdoc node][markdoc-nodes]. This example overrides Markdoc's `heading` node to render a `Heading` component, and passes through Astro's default heading properties to define attributes and generate heading ids / slugs: ```js // markdoc.config.mjs -import { defineMarkdocConfig, Markdoc } from '@astrojs/markdoc/config'; +import { defineMarkdocConfig, nodes } from '@astrojs/markdoc/config'; import Heading from './src/components/Heading.astro'; export default defineMarkdocConfig({ nodes: { heading: { render: Heading, - attributes: Markdoc.nodes.heading.attributes, + ...nodes.heading, }, }, }) ``` -Now, all Markdown headings will render with the `Heading.astro` component, and pass these `attributes` as component props. For headings, Markdoc provides a `level` attribute containing the numeric heading level. +All Markdown headings will render the `Heading.astro` component and pass `attributes` as component props. For headings, Astro provides the following attributes by default: -This example uses a level 3 heading, automatically passing `level: 3` as the component prop: +- `level: number` The heading level 1 - 6 +- `id: string` An `id` generated from the heading's text contents. This corresponds to the `slug` generated by the [content `render()` function](https://docs.astro.build/en/guides/content-collections/#rendering-content-to-html). -```md -### I'm a level 3 heading! -``` +For example, the heading `### Level 3 heading!` will pass `level: 3` and `id: 'level-3-heading'` as component props. 📚 [Find all of Markdoc's built-in nodes and node attributes on their documentation.](https://markdoc.dev/docs/nodes#built-in-nodes) diff --git a/packages/integrations/markdoc/package.json b/packages/integrations/markdoc/package.json index b048ba2e9..5ea8895a8 100644 --- a/packages/integrations/markdoc/package.json +++ b/packages/integrations/markdoc/package.json @@ -21,7 +21,7 @@ "exports": { ".": "./dist/index.js", "./components": "./components/index.ts", - "./default-config": "./dist/default-config.js", + "./runtime": "./dist/runtime.js", "./config": "./dist/config.js", "./experimental-assets-config": "./dist/experimental-assets-config.js", "./package.json": "./package.json" @@ -41,6 +41,7 @@ "dependencies": { "@markdoc/markdoc": "^0.2.2", "esbuild": "^0.17.12", + "github-slugger": "^2.0.0", "gray-matter": "^4.0.3", "kleur": "^4.1.5", "zod": "^3.17.3" @@ -49,6 +50,7 @@ "astro": "workspace:^2.4.5" }, "devDependencies": { + "@astrojs/markdown-remark": "^2.2.0", "@types/chai": "^4.3.1", "@types/html-escaper": "^3.0.0", "@types/mocha": "^9.1.1", diff --git a/packages/integrations/markdoc/src/config.ts b/packages/integrations/markdoc/src/config.ts index 09bbead12..1a20b7431 100644 --- a/packages/integrations/markdoc/src/config.ts +++ b/packages/integrations/markdoc/src/config.ts @@ -1,5 +1,9 @@ import type { ConfigType as MarkdocConfig } from '@markdoc/markdoc'; -export { default as Markdoc } from '@markdoc/markdoc'; +import { nodes as astroNodes } from './nodes/index.js'; +import _Markdoc from '@markdoc/markdoc'; + +export const Markdoc = _Markdoc; +export const nodes = { ...Markdoc.nodes, ...astroNodes }; export function defineMarkdocConfig(config: MarkdocConfig): MarkdocConfig { return config; diff --git a/packages/integrations/markdoc/src/default-config.ts b/packages/integrations/markdoc/src/default-config.ts deleted file mode 100644 index 16bd2c41f..000000000 --- a/packages/integrations/markdoc/src/default-config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ConfigType as MarkdocConfig } from '@markdoc/markdoc'; -import type { ContentEntryModule } from 'astro'; - -export function applyDefaultConfig( - config: MarkdocConfig, - ctx: { - entry: ContentEntryModule; - } -): MarkdocConfig { - return { - ...config, - variables: { - entry: ctx.entry, - ...config.variables, - }, - // TODO: heading ID calculation, Shiki syntax highlighting - }; -} diff --git a/packages/integrations/markdoc/src/experimental-assets-config.ts b/packages/integrations/markdoc/src/experimental-assets-config.ts index 962755355..2eb96ec99 100644 --- a/packages/integrations/markdoc/src/experimental-assets-config.ts +++ b/packages/integrations/markdoc/src/experimental-assets-config.ts @@ -5,7 +5,7 @@ import { Image } from 'astro:assets'; // Separate module to only import `astro:assets` when // `experimental.assets` flag is set in a project. -// TODO: merge with `./default-config.ts` when `experimental.assets` is baselined. +// TODO: merge with `./runtime.ts` when `experimental.assets` is baselined. export const experimentalAssetsConfig: MarkdocConfig = { nodes: { image: { diff --git a/packages/integrations/markdoc/src/index.ts b/packages/integrations/markdoc/src/index.ts index 5b3568992..65f81644a 100644 --- a/packages/integrations/markdoc/src/index.ts +++ b/packages/integrations/markdoc/src/index.ts @@ -9,7 +9,7 @@ import { isValidUrl, MarkdocError, parseFrontmatter, prependForwardSlash } from import { emitESMImage } from 'astro/assets'; import { bold, red, yellow } from 'kleur/colors'; import type * as rollup from 'rollup'; -import { applyDefaultConfig } from './default-config.js'; +import { applyDefaultConfig } from './runtime.js'; import { loadMarkdocConfig, type MarkdocConfigResult } from './load-config.js'; type SetupHookParams = HookParameters<'astro:config:setup'> & { @@ -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 <Content /> 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 000000000..81a9181c7 --- /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<string, any>, 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 000000000..c25b03f27 --- /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 000000000..dadb73cd6 --- /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 000000000..29d846359 --- /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 000000000..32fcf61e2 --- /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 000000000..67a974912 --- /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 000000000..ec6fa8305 --- /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}`; +--- + +<Tag data-custom-heading {id}> + <slot /> +</Tag> 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 000000000..3eb66580a --- /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 000000000..5880be0e3 --- /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(); +--- + +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Content</title> +</head> +<body> + <nav data-toc> + <ul> + {headings.map(heading => ( + <li> + <a href={`#${heading.slug}`} data-depth={heading.depth}>{heading.text}</a> + </li> + ))} + </ul> + </nav> + <Content /> +</body> +</html> 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 000000000..29d846359 --- /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 000000000..a5863ec12 --- /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 000000000..1daaae400 --- /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 000000000..3eb66580a --- /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 000000000..5880be0e3 --- /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(); +--- + +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Content</title> +</head> +<body> + <nav data-toc> + <ul> + {headings.map(heading => ( + <li> + <a href={`#${heading.slug}`} data-depth={heading.depth}>{heading.text}</a> + </li> + ))} + </ul> + </nav> + <Content /> +</body> +</html> diff --git a/packages/integrations/markdoc/test/headings.test.js b/packages/integrations/markdoc/test/headings.test.js new file mode 100644 index 000000000..5db50065c --- /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 f1af7cc69..f4e6aec1c 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': |