summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Ben Holmes <hey@bholmes.dev> 2023-05-17 09:13:10 -0400
committerGravatar GitHub <noreply@github.com> 2023-05-17 09:13:10 -0400
commitfb84622af04f795de8d17f24192de105f70fe910 (patch)
tree11a99efdb90c17207d3adc1095e88fa8daddd7e4
parentc91e837e961043e92253148f0f4291856653b993 (diff)
downloadastro-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
-rw-r--r--.changeset/pretty-students-try.md6
-rw-r--r--packages/astro/src/core/config/vite-load.ts1
-rw-r--r--packages/integrations/markdoc/README.md15
-rw-r--r--packages/integrations/markdoc/package.json4
-rw-r--r--packages/integrations/markdoc/src/config.ts6
-rw-r--r--packages/integrations/markdoc/src/default-config.ts18
-rw-r--r--packages/integrations/markdoc/src/experimental-assets-config.ts2
-rw-r--r--packages/integrations/markdoc/src/index.ts72
-rw-r--r--packages/integrations/markdoc/src/nodes/heading.ts42
-rw-r--r--packages/integrations/markdoc/src/nodes/index.ts4
-rw-r--r--packages/integrations/markdoc/src/runtime.ts78
-rw-r--r--packages/integrations/markdoc/test/fixtures/headings-custom/astro.config.mjs7
-rw-r--r--packages/integrations/markdoc/test/fixtures/headings-custom/markdoc.config.mjs11
-rw-r--r--packages/integrations/markdoc/test/fixtures/headings-custom/package.json9
-rw-r--r--packages/integrations/markdoc/test/fixtures/headings-custom/src/components/Heading.astro14
-rw-r--r--packages/integrations/markdoc/test/fixtures/headings-custom/src/content/docs/headings.mdoc11
-rw-r--r--packages/integrations/markdoc/test/fixtures/headings-custom/src/pages/index.astro28
-rw-r--r--packages/integrations/markdoc/test/fixtures/headings/astro.config.mjs7
-rw-r--r--packages/integrations/markdoc/test/fixtures/headings/markdoc.config.mjs3
-rw-r--r--packages/integrations/markdoc/test/fixtures/headings/package.json9
-rw-r--r--packages/integrations/markdoc/test/fixtures/headings/src/content/docs/headings.mdoc11
-rw-r--r--packages/integrations/markdoc/test/fixtures/headings/src/pages/index.astro28
-rw-r--r--packages/integrations/markdoc/test/headings.test.js192
-rw-r--r--pnpm-lock.yaml24
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':