Skip to content

Commit

Permalink
[Markdoc] headings and heading IDs (#7095)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
bholmesdev authored May 17, 2023
1 parent c91e837 commit fb84622
Show file tree
Hide file tree
Showing 24 changed files with 542 additions and 60 deletions.
6 changes: 6 additions & 0 deletions .changeset/pretty-students-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astrojs/markdoc': minor
'astro': patch
---

Generate heading `id`s and populate the `headings` property for all Markdoc files
1 change: 1 addition & 0 deletions packages/astro/src/core/config/vite-load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) })],
Expand Down
15 changes: 7 additions & 8 deletions packages/integrations/markdoc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 3 additions & 1 deletion packages/integrations/markdoc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/integrations/markdoc/src/config.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
18 changes: 0 additions & 18 deletions packages/integrations/markdoc/src/default-config.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
72 changes: 41 additions & 31 deletions packages/integrations/markdoc/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'> & {
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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),
Expand Down
42 changes: 42 additions & 0 deletions packages/integrations/markdoc/src/nodes/heading.ts
Original file line number Diff line number Diff line change
@@ -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);
},
};
4 changes: 4 additions & 0 deletions packages/integrations/markdoc/src/nodes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { heading } from './heading.js';
export { headingSlugger } from './heading.js';

export const nodes = { heading };
78 changes: 78 additions & 0 deletions packages/integrations/markdoc/src/runtime.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from 'astro/config';
import markdoc from '@astrojs/markdoc';

// https://astro.build/config
export default defineConfig({
integrations: [markdoc()],
});
Original file line number Diff line number Diff line change
@@ -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,
}
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@test/headings-custom",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/markdoc": "workspace:*",
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit fb84622

Please sign in to comment.