[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
This commit is contained in:
Ben Holmes 2023-05-17 09:13:10 -04:00 committed by GitHub
parent c91e837e96
commit fb84622af0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 542 additions and 60 deletions

View file

@ -0,0 +1,6 @@
---
'@astrojs/markdoc': minor
'astro': patch
---
Generate heading `id`s and populate the `headings` property for all Markdoc files

View file

@ -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) })],

View file

@ -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)

View file

@ -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",

View file

@ -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;

View file

@ -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
};
}

View file

@ -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: {

View file

@ -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),

View file

@ -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);
},
};

View file

@ -0,0 +1,4 @@
import { heading } from './heading.js';
export { headingSlugger } from './heading.js';
export const nodes = { heading };

View file

@ -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;
}

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'astro/config';
import markdoc from '@astrojs/markdoc';
// https://astro.build/config
export default defineConfig({
integrations: [markdoc()],
});

View file

@ -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,
}
}
});

View file

@ -0,0 +1,9 @@
{
"name": "@test/headings-custom",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/markdoc": "workspace:*",
"astro": "workspace:*"
}
}

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'astro/config';
import markdoc from '@astrojs/markdoc';
// https://astro.build/config
export default defineConfig({
integrations: [markdoc()],
});

View file

@ -0,0 +1,3 @@
import { defineMarkdocConfig } from '@astrojs/markdoc/config';
export default defineMarkdocConfig({});

View file

@ -0,0 +1,9 @@
{
"name": "@test/headings",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/markdoc": "workspace:*",
"astro": "workspace:*"
}
}

View file

@ -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

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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':