Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Markdoc] headings and heading IDs #7095

Merged
merged 18 commits into from
May 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
ematipico marked this conversation as resolved.
Show resolved Hide resolved
"./config": "./dist/config.js",
"./experimental-assets-config": "./dist/experimental-assets-config.js",
"./package.json": "./package.json"
Expand All @@ -36,6 +36,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 @@ -44,6 +45,7 @@
"astro": "workspace:^2.4.3"
},
"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);
ematipico marked this conversation as resolved.
Show resolved Hide resolved
}
}
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),
ematipico marked this conversation as resolved.
Show resolved Hide resolved
});
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));
ematipico marked this conversation as resolved.
Show resolved Hide resolved
}
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