summaryrefslogtreecommitdiff
path: root/packages/integrations/markdoc/src
diff options
context:
space:
mode:
authorGravatar Ben Holmes <hey@bholmes.dev> 2023-05-17 09:13:10 -0400
committerGravatar GitHub <noreply@github.com> 2023-05-17 09:13:10 -0400
commitfb84622af04f795de8d17f24192de105f70fe910 (patch)
tree11a99efdb90c17207d3adc1095e88fa8daddd7e4 /packages/integrations/markdoc/src
parentc91e837e961043e92253148f0f4291856653b993 (diff)
downloadastro-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.ts6
-rw-r--r--packages/integrations/markdoc/src/default-config.ts18
-rw-r--r--packages/integrations/markdoc/src/experimental-assets-config.ts2
-rw-r--r--packages/integrations/markdoc/src/index.ts72
-rw-r--r--packages/integrations/markdoc/src/nodes/heading.ts42
-rw-r--r--packages/integrations/markdoc/src/nodes/index.ts4
-rw-r--r--packages/integrations/markdoc/src/runtime.ts78
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;
+}