[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:
parent
c91e837e96
commit
fb84622af0
24 changed files with 542 additions and 60 deletions
6
.changeset/pretty-students-try.md
Normal file
6
.changeset/pretty-students-try.md
Normal file
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
'@astrojs/markdoc': minor
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
Generate heading `id`s and populate the `headings` property for all Markdoc files
|
|
@ -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) })],
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
|
@ -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: {
|
||||
|
|
|
@ -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),
|
||||
|
|
42
packages/integrations/markdoc/src/nodes/heading.ts
Normal file
42
packages/integrations/markdoc/src/nodes/heading.ts
Normal 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);
|
||||
},
|
||||
};
|
4
packages/integrations/markdoc/src/nodes/index.ts
Normal file
4
packages/integrations/markdoc/src/nodes/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { heading } from './heading.js';
|
||||
export { headingSlugger } from './heading.js';
|
||||
|
||||
export const nodes = { heading };
|
78
packages/integrations/markdoc/src/runtime.ts
Normal file
78
packages/integrations/markdoc/src/runtime.ts
Normal 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;
|
||||
}
|
7
packages/integrations/markdoc/test/fixtures/headings-custom/astro.config.mjs
vendored
Normal file
7
packages/integrations/markdoc/test/fixtures/headings-custom/astro.config.mjs
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import markdoc from '@astrojs/markdoc';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [markdoc()],
|
||||
});
|
11
packages/integrations/markdoc/test/fixtures/headings-custom/markdoc.config.mjs
vendored
Normal file
11
packages/integrations/markdoc/test/fixtures/headings-custom/markdoc.config.mjs
vendored
Normal 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,
|
||||
}
|
||||
}
|
||||
});
|
9
packages/integrations/markdoc/test/fixtures/headings-custom/package.json
vendored
Normal file
9
packages/integrations/markdoc/test/fixtures/headings-custom/package.json
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "@test/headings-custom",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@astrojs/markdoc": "workspace:*",
|
||||
"astro": "workspace:*"
|
||||
}
|
||||
}
|
14
packages/integrations/markdoc/test/fixtures/headings-custom/src/components/Heading.astro
vendored
Normal file
14
packages/integrations/markdoc/test/fixtures/headings-custom/src/components/Heading.astro
vendored
Normal 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>
|
11
packages/integrations/markdoc/test/fixtures/headings-custom/src/content/docs/headings.mdoc
vendored
Normal file
11
packages/integrations/markdoc/test/fixtures/headings-custom/src/content/docs/headings.mdoc
vendored
Normal 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
|
28
packages/integrations/markdoc/test/fixtures/headings-custom/src/pages/index.astro
vendored
Normal file
28
packages/integrations/markdoc/test/fixtures/headings-custom/src/pages/index.astro
vendored
Normal 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>
|
7
packages/integrations/markdoc/test/fixtures/headings/astro.config.mjs
vendored
Normal file
7
packages/integrations/markdoc/test/fixtures/headings/astro.config.mjs
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import markdoc from '@astrojs/markdoc';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [markdoc()],
|
||||
});
|
3
packages/integrations/markdoc/test/fixtures/headings/markdoc.config.mjs
vendored
Normal file
3
packages/integrations/markdoc/test/fixtures/headings/markdoc.config.mjs
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { defineMarkdocConfig } from '@astrojs/markdoc/config';
|
||||
|
||||
export default defineMarkdocConfig({});
|
9
packages/integrations/markdoc/test/fixtures/headings/package.json
vendored
Normal file
9
packages/integrations/markdoc/test/fixtures/headings/package.json
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "@test/headings",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@astrojs/markdoc": "workspace:*",
|
||||
"astro": "workspace:*"
|
||||
}
|
||||
}
|
11
packages/integrations/markdoc/test/fixtures/headings/src/content/docs/headings.mdoc
vendored
Normal file
11
packages/integrations/markdoc/test/fixtures/headings/src/content/docs/headings.mdoc
vendored
Normal 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
|
28
packages/integrations/markdoc/test/fixtures/headings/src/pages/index.astro
vendored
Normal file
28
packages/integrations/markdoc/test/fixtures/headings/src/pages/index.astro
vendored
Normal 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>
|
192
packages/integrations/markdoc/test/headings.test.js
Normal file
192
packages/integrations/markdoc/test/headings.test.js
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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':
|
||||
|
|
Loading…
Reference in a new issue