diff options
author | 2023-05-17 09:13:10 -0400 | |
---|---|---|
committer | 2023-05-17 09:13:10 -0400 | |
commit | fb84622af04f795de8d17f24192de105f70fe910 (patch) | |
tree | 11a99efdb90c17207d3adc1095e88fa8daddd7e4 /packages/integrations/markdoc/src | |
parent | c91e837e961043e92253148f0f4291856653b993 (diff) | |
download | astro-fb84622af04f795de8d17f24192de105f70fe910.tar.gz astro-fb84622af04f795de8d17f24192de105f70fe910.tar.zst astro-fb84622af04f795de8d17f24192de105f70fe910.zip |
[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
Diffstat (limited to 'packages/integrations/markdoc/src')
-rw-r--r-- | packages/integrations/markdoc/src/config.ts | 6 | ||||
-rw-r--r-- | packages/integrations/markdoc/src/default-config.ts | 18 | ||||
-rw-r--r-- | packages/integrations/markdoc/src/experimental-assets-config.ts | 2 | ||||
-rw-r--r-- | packages/integrations/markdoc/src/index.ts | 72 | ||||
-rw-r--r-- | packages/integrations/markdoc/src/nodes/heading.ts | 42 | ||||
-rw-r--r-- | packages/integrations/markdoc/src/nodes/index.ts | 4 | ||||
-rw-r--r-- | packages/integrations/markdoc/src/runtime.ts | 78 |
7 files changed, 171 insertions, 51 deletions
diff --git a/packages/integrations/markdoc/src/config.ts b/packages/integrations/markdoc/src/config.ts index 09bbead12..1a20b7431 100644 --- a/packages/integrations/markdoc/src/config.ts +++ b/packages/integrations/markdoc/src/config.ts @@ -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; diff --git a/packages/integrations/markdoc/src/default-config.ts b/packages/integrations/markdoc/src/default-config.ts deleted file mode 100644 index 16bd2c41f..000000000 --- a/packages/integrations/markdoc/src/default-config.ts +++ /dev/null @@ -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 - }; -} diff --git a/packages/integrations/markdoc/src/experimental-assets-config.ts b/packages/integrations/markdoc/src/experimental-assets-config.ts index 962755355..2eb96ec99 100644 --- a/packages/integrations/markdoc/src/experimental-assets-config.ts +++ b/packages/integrations/markdoc/src/experimental-assets-config.ts @@ -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: { diff --git a/packages/integrations/markdoc/src/index.ts b/packages/integrations/markdoc/src/index.ts index 5b3568992..65f81644a 100644 --- a/packages/integrations/markdoc/src/index.ts +++ b/packages/integrations/markdoc/src/index.ts @@ -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), diff --git a/packages/integrations/markdoc/src/nodes/heading.ts b/packages/integrations/markdoc/src/nodes/heading.ts new file mode 100644 index 000000000..81a9181c7 --- /dev/null +++ b/packages/integrations/markdoc/src/nodes/heading.ts @@ -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); + }, +}; diff --git a/packages/integrations/markdoc/src/nodes/index.ts b/packages/integrations/markdoc/src/nodes/index.ts new file mode 100644 index 000000000..c25b03f27 --- /dev/null +++ b/packages/integrations/markdoc/src/nodes/index.ts @@ -0,0 +1,4 @@ +import { heading } from './heading.js'; +export { headingSlugger } from './heading.js'; + +export const nodes = { heading }; diff --git a/packages/integrations/markdoc/src/runtime.ts b/packages/integrations/markdoc/src/runtime.ts new file mode 100644 index 000000000..dadb73cd6 --- /dev/null +++ b/packages/integrations/markdoc/src/runtime.ts @@ -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; +} |