Skip to content

Commit fb84622

Browse files
authored
[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
1 parent c91e837 commit fb84622

File tree

24 files changed

+542
-60
lines changed

24 files changed

+542
-60
lines changed

.changeset/pretty-students-try.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@astrojs/markdoc': minor
3+
'astro': patch
4+
---
5+
6+
Generate heading `id`s and populate the `headings` property for all Markdoc files

packages/astro/src/core/config/vite-load.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ async function createViteLoader(root: string, fs: typeof fsType): Promise<ViteLo
2424
'@astrojs/react',
2525
'@astrojs/preact',
2626
'@astrojs/sitemap',
27+
'@astrojs/markdoc',
2728
],
2829
},
2930
plugins: [loadFallbackPlugin({ fs, root: pathToFileURL(root) })],

packages/integrations/markdoc/README.md

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -143,30 +143,29 @@ Use tags like this fancy "aside" to add some *flair* to your docs.
143143

144144
#### Render Markdoc nodes / HTML elements as Astro components
145145

146-
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).
146+
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:
147147

148148
```js
149149
// markdoc.config.mjs
150-
import { defineMarkdocConfig, Markdoc } from '@astrojs/markdoc/config';
150+
import { defineMarkdocConfig, nodes } from '@astrojs/markdoc/config';
151151
import Heading from './src/components/Heading.astro';
152152

153153
export default defineMarkdocConfig({
154154
nodes: {
155155
heading: {
156156
render: Heading,
157-
attributes: Markdoc.nodes.heading.attributes,
157+
...nodes.heading,
158158
},
159159
},
160160
})
161161
```
162162

163-
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.
163+
All Markdown headings will render the `Heading.astro` component and pass `attributes` as component props. For headings, Astro provides the following attributes by default:
164164

165-
This example uses a level 3 heading, automatically passing `level: 3` as the component prop:
165+
- `level: number` The heading level 1 - 6
166+
- `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).
166167

167-
```md
168-
### I'm a level 3 heading!
169-
```
168+
For example, the heading `### Level 3 heading!` will pass `level: 3` and `id: 'level-3-heading'` as component props.
170169

171170
📚 [Find all of Markdoc's built-in nodes and node attributes on their documentation.](https://markdoc.dev/docs/nodes#built-in-nodes)
172171

packages/integrations/markdoc/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"exports": {
2222
".": "./dist/index.js",
2323
"./components": "./components/index.ts",
24-
"./default-config": "./dist/default-config.js",
24+
"./runtime": "./dist/runtime.js",
2525
"./config": "./dist/config.js",
2626
"./experimental-assets-config": "./dist/experimental-assets-config.js",
2727
"./package.json": "./package.json"
@@ -41,6 +41,7 @@
4141
"dependencies": {
4242
"@markdoc/markdoc": "^0.2.2",
4343
"esbuild": "^0.17.12",
44+
"github-slugger": "^2.0.0",
4445
"gray-matter": "^4.0.3",
4546
"kleur": "^4.1.5",
4647
"zod": "^3.17.3"
@@ -49,6 +50,7 @@
4950
"astro": "workspace:^2.4.5"
5051
},
5152
"devDependencies": {
53+
"@astrojs/markdown-remark": "^2.2.0",
5254
"@types/chai": "^4.3.1",
5355
"@types/html-escaper": "^3.0.0",
5456
"@types/mocha": "^9.1.1",

packages/integrations/markdoc/src/config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type { ConfigType as MarkdocConfig } from '@markdoc/markdoc';
2-
export { default as Markdoc } from '@markdoc/markdoc';
2+
import { nodes as astroNodes } from './nodes/index.js';
3+
import _Markdoc from '@markdoc/markdoc';
4+
5+
export const Markdoc = _Markdoc;
6+
export const nodes = { ...Markdoc.nodes, ...astroNodes };
37

48
export function defineMarkdocConfig(config: MarkdocConfig): MarkdocConfig {
59
return config;

packages/integrations/markdoc/src/default-config.ts

Lines changed: 0 additions & 18 deletions
This file was deleted.

packages/integrations/markdoc/src/experimental-assets-config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Image } from 'astro:assets';
55

66
// Separate module to only import `astro:assets` when
77
// `experimental.assets` flag is set in a project.
8-
// TODO: merge with `./default-config.ts` when `experimental.assets` is baselined.
8+
// TODO: merge with `./runtime.ts` when `experimental.assets` is baselined.
99
export const experimentalAssetsConfig: MarkdocConfig = {
1010
nodes: {
1111
image: {

packages/integrations/markdoc/src/index.ts

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { isValidUrl, MarkdocError, parseFrontmatter, prependForwardSlash } from
99
import { emitESMImage } from 'astro/assets';
1010
import { bold, red, yellow } from 'kleur/colors';
1111
import type * as rollup from 'rollup';
12-
import { applyDefaultConfig } from './default-config.js';
12+
import { applyDefaultConfig } from './runtime.js';
1313
import { loadMarkdocConfig, type MarkdocConfigResult } from './load-config.js';
1414

1515
type SetupHookParams = HookParameters<'astro:config:setup'> & {
@@ -52,7 +52,7 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration
5252
async getRenderModule({ entry, viteId }) {
5353
const ast = Markdoc.parse(entry.body);
5454
const pluginContext = this;
55-
const markdocConfig = applyDefaultConfig(userMarkdocConfig, { entry });
55+
const markdocConfig = applyDefaultConfig(userMarkdocConfig, entry);
5656

5757
const validationErrors = Markdoc.validate(ast, markdocConfig).filter((e) => {
5858
return (
@@ -88,36 +88,46 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration
8888
});
8989
}
9090

91-
return {
92-
code: `import { jsx as h } from 'astro/jsx-runtime';
93-
import { applyDefaultConfig } from '@astrojs/markdoc/default-config';
94-
import { Renderer } from '@astrojs/markdoc/components';
95-
import * as entry from ${JSON.stringify(viteId + '?astroContent')};${
96-
markdocConfigResult
97-
? `\nimport userConfig from ${JSON.stringify(
98-
markdocConfigResult.fileUrl.pathname
99-
)};`
100-
: ''
101-
}${
102-
astroConfig.experimental.assets
103-
? `\nimport { experimentalAssetsConfig } from '@astrojs/markdoc/experimental-assets-config';`
104-
: ''
105-
}
106-
const stringifiedAst = ${JSON.stringify(
107-
/* Double stringify to encode *as* stringified JSON */ JSON.stringify(ast)
108-
)};
91+
const res = `import { jsx as h } from 'astro/jsx-runtime';
92+
import { Renderer } from '@astrojs/markdoc/components';
93+
import { collectHeadings, applyDefaultConfig, Markdoc, headingSlugger } from '@astrojs/markdoc/runtime';
94+
import * as entry from ${JSON.stringify(viteId + '?astroContent')};
95+
${
96+
markdocConfigResult
97+
? `import _userConfig from ${JSON.stringify(
98+
markdocConfigResult.fileUrl.pathname
99+
)};\nconst userConfig = _userConfig ?? {};`
100+
: 'const userConfig = {};'
101+
}${
102+
astroConfig.experimental.assets
103+
? `\nimport { experimentalAssetsConfig } from '@astrojs/markdoc/experimental-assets-config';\nuserConfig.nodes = { ...experimentalAssetsConfig.nodes, ...userConfig.nodes };`
104+
: ''
105+
}
106+
const stringifiedAst = ${JSON.stringify(/* Double stringify to encode *as* stringified JSON */ JSON.stringify(ast))};
107+
export function getHeadings() {
108+
${
109+
/* Yes, we are transforming twice (once from `getHeadings()` and again from <Content /> in case of variables).
110+
TODO: propose new `render()` API to allow Markdoc variable passing to `render()` itself,
111+
instead of the Content component. Would remove double-transform and unlock variable resolution in heading slugs. */
112+
''
113+
}
114+
headingSlugger.reset();
115+
const headingConfig = userConfig.nodes?.heading;
116+
const config = applyDefaultConfig(headingConfig ? { nodes: { heading: headingConfig } } : {}, entry);
117+
const ast = Markdoc.Ast.fromJSON(stringifiedAst);
118+
const content = Markdoc.transform(ast, config);
119+
return collectHeadings(Array.isArray(content) ? content : content.children);
120+
}
109121
export async function Content (props) {
110-
const config = applyDefaultConfig(${
111-
markdocConfigResult
112-
? '{ ...userConfig, variables: { ...userConfig.variables, ...props } }'
113-
: '{ variables: props }'
114-
}, { entry });${
115-
astroConfig.experimental.assets
116-
? `\nconfig.nodes = { ...experimentalAssetsConfig.nodes, ...config.nodes };`
117-
: ''
118-
}
119-
return h(Renderer, { stringifiedAst, config }); };`,
120-
};
122+
headingSlugger.reset();
123+
const config = applyDefaultConfig({
124+
...userConfig,
125+
variables: { ...userConfig.variables, ...props },
126+
}, entry);
127+
128+
return h(Renderer, { config, stringifiedAst });
129+
}`;
130+
return { code: res };
121131
},
122132
contentModuleTypes: await fs.promises.readFile(
123133
new URL('../template/content-module-types.d.ts', import.meta.url),
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import Markdoc, { type RenderableTreeNode, type Schema } from '@markdoc/markdoc';
2+
import { getTextContent } from '../runtime.js';
3+
import Slugger from 'github-slugger';
4+
5+
export const headingSlugger = new Slugger();
6+
7+
function getSlug(attributes: Record<string, any>, children: RenderableTreeNode[]): string {
8+
if (attributes.id && typeof attributes.id === 'string') {
9+
return attributes.id;
10+
}
11+
const textContent = attributes.content ?? getTextContent(children);
12+
let slug = headingSlugger.slug(textContent);
13+
14+
if (slug.endsWith('-')) slug = slug.slice(0, -1);
15+
return slug;
16+
}
17+
18+
export const heading: Schema = {
19+
children: ['inline'],
20+
attributes: {
21+
id: { type: String },
22+
level: { type: Number, required: true, default: 1 },
23+
},
24+
transform(node, config) {
25+
const { level, ...attributes } = node.transformAttributes(config);
26+
const children = node.transformChildren(config);
27+
28+
29+
const slug = getSlug(attributes, children);
30+
31+
const render = config.nodes?.heading?.render ?? `h${level}`;
32+
const tagProps =
33+
// For components, pass down `level` as a prop,
34+
// alongside `__collectHeading` for our `headings` collector.
35+
// Avoid accidentally rendering `level` as an HTML attribute otherwise!
36+
typeof render === 'function'
37+
? { ...attributes, id: slug, __collectHeading: true, level }
38+
: { ...attributes, id: slug };
39+
40+
return new Markdoc.Tag(render, tagProps, children);
41+
},
42+
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { heading } from './heading.js';
2+
export { headingSlugger } from './heading.js';
3+
4+
export const nodes = { heading };

0 commit comments

Comments
 (0)