summaryrefslogtreecommitdiff
path: root/packages/integrations/markdoc/src
diff options
context:
space:
mode:
authorGravatar Ben Holmes <hey@bholmes.dev> 2023-06-27 15:05:17 -0400
committerGravatar GitHub <noreply@github.com> 2023-06-27 15:05:17 -0400
commitfb7af551148f5ca6c4f98a4e556c8948c5690919 (patch)
tree7c65c758ead74ee129bc958279a6cf02e931cb4d /packages/integrations/markdoc/src
parent8821f0504845b351247ce6c1e2ae581a71806209 (diff)
downloadastro-fb7af551148f5ca6c4f98a4e556c8948c5690919.tar.gz
astro-fb7af551148f5ca6c4f98a4e556c8948c5690919.tar.zst
astro-fb7af551148f5ca6c4f98a4e556c8948c5690919.zip
feat: New Markdoc `render` API (#7468)
* feat: URL support for markdoc tags * refactor: move to separate file * feat: support URL for markdoc nodes * feat: support `extends` with URL * chore: changeset * fix: bad AstroMarkdocConfig type * fix: experimentalAssetsConfig missing * fix: correctly merge runtime config * chore: formatting * deps: astro internal helpers * feat: component() util, new astro bundling * chore: remove now unused code * todo: missing hint * fix: import.meta.url type error * wip: test nested collection calls * feat: resolve paths from project root * refactor: move getHeadings() to runtime module * fix: broken collectHeadings * test: update fixture configs * chore: remove suggestions. Out of scope! * fix: throw outside esbuild * refactor: shuffle imports around * Revert "wip: test nested collection calls" This reverts commit 9354b3cf9222fd65b974b0cddf4e7a95ab3cd2b2. * chore: revert back to mjs config * chore: add jsdocs to stringified helpers * fix: restore updated changeset --------- Co-authored-by: bholmesdev <bholmesdev@gmail.com>
Diffstat (limited to 'packages/integrations/markdoc/src')
-rw-r--r--packages/integrations/markdoc/src/config.ts25
-rw-r--r--packages/integrations/markdoc/src/content-entry-type.ts278
-rw-r--r--packages/integrations/markdoc/src/index.ts251
-rw-r--r--packages/integrations/markdoc/src/load-config.ts17
-rw-r--r--packages/integrations/markdoc/src/runtime.ts100
-rw-r--r--packages/integrations/markdoc/src/utils.ts33
6 files changed, 411 insertions, 293 deletions
diff --git a/packages/integrations/markdoc/src/config.ts b/packages/integrations/markdoc/src/config.ts
index 04a81c612..0a2870e23 100644
--- a/packages/integrations/markdoc/src/config.ts
+++ b/packages/integrations/markdoc/src/config.ts
@@ -5,11 +5,19 @@ import type {
NodeType,
Schema,
} from '@markdoc/markdoc';
-import _Markdoc from '@markdoc/markdoc';
import type { AstroInstance } from 'astro';
+import _Markdoc from '@markdoc/markdoc';
import { heading } from './heading-ids.js';
+import { isRelativePath } from '@astrojs/internal-helpers/path';
+import { componentConfigSymbol } from './utils.js';
-type Render = AstroInstance['default'] | string;
+export type Render = ComponentConfig | AstroInstance['default'] | string;
+export type ComponentConfig = {
+ type: 'package' | 'local';
+ path: string;
+ namedExport?: string;
+ [componentConfigSymbol]: true;
+};
export type AstroMarkdocConfig<C extends Record<string, any> = Record<string, any>> = Omit<
MarkdocConfig,
@@ -30,3 +38,16 @@ export const nodes = { ...Markdoc.nodes, heading };
export function defineMarkdocConfig(config: AstroMarkdocConfig): AstroMarkdocConfig {
return config;
}
+
+export function component(pathnameOrPkgName: string, namedExport?: string): ComponentConfig {
+ return {
+ type: isNpmPackageName(pathnameOrPkgName) ? 'package' : 'local',
+ path: pathnameOrPkgName,
+ namedExport,
+ [componentConfigSymbol]: true,
+ };
+}
+
+function isNpmPackageName(pathname: string) {
+ return !isRelativePath(pathname) && !pathname.startsWith('/');
+}
diff --git a/packages/integrations/markdoc/src/content-entry-type.ts b/packages/integrations/markdoc/src/content-entry-type.ts
new file mode 100644
index 000000000..1997a0a2f
--- /dev/null
+++ b/packages/integrations/markdoc/src/content-entry-type.ts
@@ -0,0 +1,278 @@
+/* eslint-disable no-console */
+import type { Config as MarkdocConfig, Node } from '@markdoc/markdoc';
+import type { ErrorPayload as ViteErrorPayload } from 'vite';
+import matter from 'gray-matter';
+import Markdoc from '@markdoc/markdoc';
+import type { AstroConfig, ContentEntryType } from 'astro';
+import fs from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { isValidUrl, MarkdocError, prependForwardSlash, isComponentConfig } from './utils.js';
+import type { ComponentConfig } from './config.js';
+// @ts-expect-error Cannot find module 'astro/assets' or its corresponding type declarations.
+import { emitESMImage } from 'astro/assets';
+import path from 'node:path';
+import type * as rollup from 'rollup';
+import { setupConfig } from './runtime.js';
+import type { MarkdocConfigResult } from './load-config.js';
+
+export async function getContentEntryType({
+ markdocConfigResult,
+ astroConfig,
+}: {
+ astroConfig: AstroConfig;
+ markdocConfigResult?: MarkdocConfigResult;
+}): Promise<ContentEntryType> {
+ return {
+ extensions: ['.mdoc'],
+ getEntryInfo,
+ handlePropagation: true,
+ async getRenderModule({ contents, fileUrl, viteId }) {
+ const entry = getEntryInfo({ contents, fileUrl });
+ const tokens = markdocTokenizer.tokenize(entry.body);
+ const ast = Markdoc.parse(tokens);
+ const usedTags = getUsedTags(ast);
+ const userMarkdocConfig = markdocConfigResult?.config ?? {};
+ const markdocConfigUrl = markdocConfigResult?.fileUrl;
+
+ let componentConfigByTagMap: Record<string, ComponentConfig> = {};
+ // Only include component imports for tags used in the document.
+ // Avoids style and script bleed.
+ for (const tag of usedTags) {
+ const render = userMarkdocConfig.tags?.[tag]?.render;
+ if (isComponentConfig(render)) {
+ componentConfigByTagMap[tag] = render;
+ }
+ }
+ let componentConfigByNodeMap: Record<string, ComponentConfig> = {};
+ for (const [nodeType, schema] of Object.entries(userMarkdocConfig.nodes ?? {})) {
+ const render = schema?.render;
+ if (isComponentConfig(render)) {
+ componentConfigByNodeMap[nodeType] = render;
+ }
+ }
+
+ const pluginContext = this;
+ const markdocConfig = await setupConfig(userMarkdocConfig);
+
+ const filePath = fileURLToPath(fileUrl);
+
+ const validationErrors = Markdoc.validate(
+ ast,
+ /* Raised generics issue with Markdoc core https://github.com/markdoc/markdoc/discussions/400 */
+ markdocConfig as MarkdocConfig
+ ).filter((e) => {
+ return (
+ // Ignore `variable-undefined` errors.
+ // Variables can be configured at runtime,
+ // so we cannot validate them at build time.
+ e.error.id !== 'variable-undefined' &&
+ (e.error.level === 'error' || e.error.level === 'critical')
+ );
+ });
+ if (validationErrors.length) {
+ // Heuristic: take number of newlines for `rawData` and add 2 for the `---` fences
+ const frontmatterBlockOffset = entry.rawData.split('\n').length + 2;
+ const rootRelativePath = path.relative(fileURLToPath(astroConfig.root), filePath);
+ throw new MarkdocError({
+ message: [
+ `**${String(rootRelativePath)}** contains invalid content:`,
+ ...validationErrors.map((e) => `- ${e.error.message}`),
+ ].join('\n'),
+ location: {
+ // Error overlay does not support multi-line or ranges.
+ // Just point to the first line.
+ line: frontmatterBlockOffset + validationErrors[0].lines[0],
+ file: viteId,
+ },
+ });
+ }
+
+ if (astroConfig.experimental.assets) {
+ await emitOptimizedImages(ast.children, {
+ astroConfig,
+ pluginContext,
+ filePath,
+ });
+ }
+
+ const res = `import { Renderer } from '@astrojs/markdoc/components';
+import { createGetHeadings, createContentComponent } from '@astrojs/markdoc/runtime';
+${
+ markdocConfigUrl
+ ? `import markdocConfig from ${JSON.stringify(markdocConfigUrl.pathname)};`
+ : 'const markdocConfig = {};'
+}${
+ astroConfig.experimental.assets
+ ? `\nimport { experimentalAssetsConfig } from '@astrojs/markdoc/experimental-assets-config';
+markdocConfig.nodes = { ...experimentalAssetsConfig.nodes, ...markdocConfig.nodes };`
+ : ''
+ }
+
+${getStringifiedImports(componentConfigByTagMap, 'Tag', astroConfig.root)}
+${getStringifiedImports(componentConfigByNodeMap, 'Node', astroConfig.root)}
+
+const tagComponentMap = ${getStringifiedMap(componentConfigByTagMap, 'Tag')};
+const nodeComponentMap = ${getStringifiedMap(componentConfigByNodeMap, 'Node')};
+
+const stringifiedAst = ${JSON.stringify(
+ /* Double stringify to encode *as* stringified JSON */ JSON.stringify(ast)
+ )};
+
+export const getHeadings = createGetHeadings(stringifiedAst, markdocConfig);
+export const Content = createContentComponent(
+ Renderer,
+ stringifiedAst,
+ markdocConfig,
+ tagComponentMap,
+ nodeComponentMap,
+)`;
+ return { code: res };
+ },
+ contentModuleTypes: await fs.promises.readFile(
+ new URL('../template/content-module-types.d.ts', import.meta.url),
+ 'utf-8'
+ ),
+ };
+}
+
+const markdocTokenizer = new Markdoc.Tokenizer({
+ // Strip <!-- comments --> from rendered output
+ // Without this, they're rendered as strings!
+ allowComments: true,
+});
+
+function getUsedTags(markdocAst: Node) {
+ const tags = new Set<string>();
+ const validationErrors = Markdoc.validate(markdocAst);
+ // Hack: run the validator with an empty config and look for 'tag-undefined'.
+ // This is our signal that a tag is being used!
+ for (const { error } of validationErrors) {
+ if (error.id === 'tag-undefined') {
+ const [, tagName] = error.message.match(/Undefined tag: '(.*)'/) ?? [];
+ tags.add(tagName);
+ }
+ }
+ return tags;
+}
+
+function getEntryInfo({ fileUrl, contents }: { fileUrl: URL; contents: string }) {
+ const parsed = parseFrontmatter(contents, fileURLToPath(fileUrl));
+ return {
+ data: parsed.data,
+ body: parsed.content,
+ slug: parsed.data.slug,
+ rawData: parsed.matter,
+ };
+}
+
+/**
+ * Emits optimized images, and appends the generated `src` to each AST node
+ * via the `__optimizedSrc` attribute.
+ */
+async function emitOptimizedImages(
+ nodeChildren: Node[],
+ ctx: {
+ pluginContext: rollup.PluginContext;
+ filePath: string;
+ astroConfig: AstroConfig;
+ }
+) {
+ for (const node of nodeChildren) {
+ if (
+ node.type === 'image' &&
+ typeof node.attributes.src === 'string' &&
+ shouldOptimizeImage(node.attributes.src)
+ ) {
+ // Attempt to resolve source with Vite.
+ // This handles relative paths and configured aliases
+ const resolved = await ctx.pluginContext.resolve(node.attributes.src, ctx.filePath);
+
+ if (resolved?.id && fs.existsSync(new URL(prependForwardSlash(resolved.id), 'file://'))) {
+ const src = await emitESMImage(
+ resolved.id,
+ ctx.pluginContext.meta.watchMode,
+ ctx.pluginContext.emitFile,
+ { config: ctx.astroConfig }
+ );
+ node.attributes.__optimizedSrc = src;
+ } else {
+ throw new MarkdocError({
+ message: `Could not resolve image ${JSON.stringify(
+ node.attributes.src
+ )} from ${JSON.stringify(ctx.filePath)}. Does the file exist?`,
+ });
+ }
+ }
+ await emitOptimizedImages(node.children, ctx);
+ }
+}
+
+function shouldOptimizeImage(src: string) {
+ // Optimize anything that is NOT external or an absolute path to `public/`
+ return !isValidUrl(src) && !src.startsWith('/');
+}
+
+/**
+ * Get stringified import statements for configured tags or nodes.
+ * `componentNamePrefix` is appended to the import name for namespacing.
+ *
+ * Example output: `import Tagaside from '/Users/.../src/components/Aside.astro';`
+ */
+function getStringifiedImports(
+ componentConfigMap: Record<string, ComponentConfig>,
+ componentNamePrefix: string,
+ root: URL
+) {
+ let stringifiedComponentImports = '';
+ for (const [key, config] of Object.entries(componentConfigMap)) {
+ const importName = config.namedExport
+ ? `{ ${config.namedExport} as ${componentNamePrefix + key} }`
+ : componentNamePrefix + key;
+ const resolvedPath =
+ config.type === 'local' ? new URL(config.path, root).pathname : config.path;
+
+ stringifiedComponentImports += `import ${importName} from ${JSON.stringify(resolvedPath)};\n`;
+ }
+ return stringifiedComponentImports;
+}
+
+/**
+ * Get a stringified map from tag / node name to component import name.
+ * This uses the same `componentNamePrefix` used by `getStringifiedImports()`.
+ *
+ * Example output: `{ aside: Tagaside, heading: Tagheading }`
+ */
+function getStringifiedMap(
+ componentConfigMap: Record<string, ComponentConfig>,
+ componentNamePrefix: string
+) {
+ let stringifiedComponentMap = '{';
+ for (const key in componentConfigMap) {
+ stringifiedComponentMap += `${key}: ${componentNamePrefix + key},\n`;
+ }
+ stringifiedComponentMap += '}';
+ return stringifiedComponentMap;
+}
+
+/**
+ * Match YAML exception handling from Astro core errors
+ * @see 'astro/src/core/errors.ts'
+ */
+function parseFrontmatter(fileContents: string, filePath: string) {
+ try {
+ // `matter` is empty string on cache results
+ // clear cache to prevent this
+ (matter as any).clearCache();
+ return matter(fileContents);
+ } catch (e: any) {
+ if (e.name === 'YAMLException') {
+ const err: Error & ViteErrorPayload['err'] = e;
+ err.id = filePath;
+ err.loc = { file: e.id, line: e.mark.line + 1, column: e.mark.column };
+ err.message = e.reason;
+ throw err;
+ } else {
+ throw e;
+ }
+ }
+}
diff --git a/packages/integrations/markdoc/src/index.ts b/packages/integrations/markdoc/src/index.ts
index 8f48dec41..cafc76be5 100644
--- a/packages/integrations/markdoc/src/index.ts
+++ b/packages/integrations/markdoc/src/index.ts
@@ -1,30 +1,14 @@
/* eslint-disable no-console */
-import type { Config as MarkdocConfig, Node } from '@markdoc/markdoc';
-import Markdoc from '@markdoc/markdoc';
-import type { AstroConfig, AstroIntegration, ContentEntryType, HookParameters } from 'astro';
-import crypto from 'node:crypto';
-import fs from 'node:fs';
+import type { AstroIntegration, ContentEntryType, HookParameters, AstroConfig } from 'astro';
import { fileURLToPath } from 'node:url';
-import {
- hasContentFlag,
- isValidUrl,
- MarkdocError,
- parseFrontmatter,
- prependForwardSlash,
- PROPAGATED_ASSET_FLAG,
-} from './utils.js';
-// @ts-expect-error Cannot find module 'astro/assets' or its corresponding type declarations.
-import { emitESMImage } from 'astro/assets';
import { bold, red } from 'kleur/colors';
-import path from 'node:path';
-import type * as rollup from 'rollup';
import { normalizePath } from 'vite';
import {
loadMarkdocConfig,
- SUPPORTED_MARKDOC_CONFIG_FILES,
type MarkdocConfigResult,
+ SUPPORTED_MARKDOC_CONFIG_FILES,
} from './load-config.js';
-import { setupConfig } from './runtime.js';
+import { getContentEntryType } from './content-entry-type.js';
type SetupHookParams = HookParameters<'astro:config:setup'> & {
// `contentEntryType` is not a public API
@@ -32,12 +16,6 @@ type SetupHookParams = HookParameters<'astro:config:setup'> & {
addContentEntryType: (contentEntryType: ContentEntryType) => void;
};
-const markdocTokenizer = new Markdoc.Tokenizer({
- // Strip <!-- comments --> from rendered output
- // Without this, they're rendered as strings!
- allowComments: true,
-});
-
export default function markdocIntegration(legacyConfig?: any): AstroIntegration {
if (legacyConfig) {
console.log(
@@ -61,173 +39,14 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration
if (markdocConfigResult) {
markdocConfigResultId = normalizePath(fileURLToPath(markdocConfigResult.fileUrl));
}
- const userMarkdocConfig = markdocConfigResult?.config ?? {};
-
- function getEntryInfo({ fileUrl, contents }: { fileUrl: URL; contents: string }) {
- const parsed = parseFrontmatter(contents, fileURLToPath(fileUrl));
- return {
- data: parsed.data,
- body: parsed.content,
- slug: parsed.data.slug,
- rawData: parsed.matter,
- };
- }
- addContentEntryType({
- extensions: ['.mdoc'],
- getEntryInfo,
- // Markdoc handles script / style propagation
- // for Astro components internally
- handlePropagation: false,
- async getRenderModule({ contents, fileUrl, viteId }) {
- const entry = getEntryInfo({ contents, fileUrl });
- const tokens = markdocTokenizer.tokenize(entry.body);
- const ast = Markdoc.parse(tokens);
- const pluginContext = this;
- const markdocConfig = await setupConfig(userMarkdocConfig);
-
- const filePath = fileURLToPath(fileUrl);
-
- const validationErrors = Markdoc.validate(
- ast,
- /* Raised generics issue with Markdoc core https://github.com/markdoc/markdoc/discussions/400 */
- markdocConfig as MarkdocConfig
- ).filter((e) => {
- return (
- // Ignore `variable-undefined` errors.
- // Variables can be configured at runtime,
- // so we cannot validate them at build time.
- e.error.id !== 'variable-undefined' &&
- (e.error.level === 'error' || e.error.level === 'critical')
- );
- });
- if (validationErrors.length) {
- // Heuristic: take number of newlines for `rawData` and add 2 for the `---` fences
- const frontmatterBlockOffset = entry.rawData.split('\n').length + 2;
- const rootRelativePath = path.relative(fileURLToPath(astroConfig.root), filePath);
- throw new MarkdocError({
- message: [
- `**${String(rootRelativePath)}** contains invalid content:`,
- ...validationErrors.map((e) => `- ${e.error.message}`),
- ].join('\n'),
- location: {
- // Error overlay does not support multi-line or ranges.
- // Just point to the first line.
- line: frontmatterBlockOffset + validationErrors[0].lines[0],
- file: viteId,
- },
- });
- }
- if (astroConfig.experimental.assets) {
- await emitOptimizedImages(ast.children, {
- astroConfig,
- pluginContext,
- filePath,
- });
- }
-
- const res = `import {
- createComponent,
- renderComponent,
- } from 'astro/runtime/server/index.js';
- import { Renderer } from '@astrojs/markdoc/components';
- import { collectHeadings, setupConfig, setupConfigSync, Markdoc } from '@astrojs/markdoc/runtime';
-${
- markdocConfigResult
- ? `import _userConfig from ${JSON.stringify(
- markdocConfigResultId
- )};\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. */
- ''
- }
- const headingConfig = userConfig.nodes?.heading;
- const config = setupConfigSync(headingConfig ? { nodes: { heading: headingConfig } } : {});
- const ast = Markdoc.Ast.fromJSON(stringifiedAst);
- const content = Markdoc.transform(ast, config);
- return collectHeadings(Array.isArray(content) ? content : content.children);
-}
-
-export const Content = createComponent({
- async factory(result, props) {
- const config = await setupConfig({
- ...userConfig,
- variables: { ...userConfig.variables, ...props },
- });
-
- return renderComponent(
- result,
- Renderer.name,
- Renderer,
- { stringifiedAst, config },
- {}
- );
- },
- propagation: 'self',
-});`;
- return { code: res };
- },
- contentModuleTypes: await fs.promises.readFile(
- new URL('../template/content-module-types.d.ts', import.meta.url),
- 'utf-8'
- ),
- });
-
- let rollupOptions: rollup.RollupOptions = {};
- if (markdocConfigResult) {
- rollupOptions = {
- output: {
- // Split Astro components from your `markdoc.config`
- // to only inject component styles and scripts at runtime.
- manualChunks(id, { getModuleInfo }) {
- if (
- markdocConfigResult &&
- hasContentFlag(id, PROPAGATED_ASSET_FLAG) &&
- getModuleInfo(id)?.importers?.includes(markdocConfigResultId)
- ) {
- return createNameHash(id, [id]);
- }
- },
- },
- };
- }
+ addContentEntryType(await getContentEntryType({ markdocConfigResult, astroConfig }));
updateConfig({
vite: {
ssr: {
external: ['@astrojs/markdoc/prism', '@astrojs/markdoc/shiki'],
},
- build: {
- rollupOptions,
- },
- plugins: [
- {
- name: '@astrojs/markdoc:astro-propagated-assets',
- enforce: 'pre',
- // Astro component styles and scripts should only be injected
- // When a given Markdoc file actually uses that component.
- // Add the `astroPropagatedAssets` flag to inject only when rendered.
- resolveId(this: rollup.TransformPluginContext, id: string, importer: string) {
- if (importer === markdocConfigResultId && id.endsWith('.astro')) {
- return this.resolve(id + '?astroPropagatedAssets', importer, {
- skipSelf: true,
- });
- }
- },
- },
- ],
},
});
},
@@ -241,65 +60,3 @@ export const Content = createComponent({
},
};
}
-
-/**
- * Emits optimized images, and appends the generated `src` to each AST node
- * via the `__optimizedSrc` attribute.
- */
-async function emitOptimizedImages(
- nodeChildren: Node[],
- ctx: {
- pluginContext: rollup.PluginContext;
- filePath: string;
- astroConfig: AstroConfig;
- }
-) {
- for (const node of nodeChildren) {
- if (
- node.type === 'image' &&
- typeof node.attributes.src === 'string' &&
- shouldOptimizeImage(node.attributes.src)
- ) {
- // Attempt to resolve source with Vite.
- // This handles relative paths and configured aliases
- const resolved = await ctx.pluginContext.resolve(node.attributes.src, ctx.filePath);
-
- if (resolved?.id && fs.existsSync(new URL(prependForwardSlash(resolved.id), 'file://'))) {
- const src = await emitESMImage(
- resolved.id,
- ctx.pluginContext.meta.watchMode,
- ctx.pluginContext.emitFile,
- { config: ctx.astroConfig }
- );
- node.attributes.__optimizedSrc = src;
- } else {
- throw new MarkdocError({
- message: `Could not resolve image ${JSON.stringify(
- node.attributes.src
- )} from ${JSON.stringify(ctx.filePath)}. Does the file exist?`,
- });
- }
- }
- await emitOptimizedImages(node.children, ctx);
- }
-}
-
-function shouldOptimizeImage(src: string) {
- // Optimize anything that is NOT external or an absolute path to `public/`
- return !isValidUrl(src) && !src.startsWith('/');
-}
-
-/**
- * Create build hash for manual Rollup chunks.
- * @see 'packages/astro/src/core/build/plugins/plugin-css.ts'
- */
-function createNameHash(baseId: string, hashIds: string[]): string {
- const baseName = baseId ? path.parse(baseId).name : 'index';
- const hash = crypto.createHash('sha256');
- for (const id of hashIds) {
- hash.update(id, 'utf-8');
- }
- const h = hash.digest('hex').slice(0, 8);
- const proposedName = baseName + '.' + h;
- return proposedName;
-}
diff --git a/packages/integrations/markdoc/src/load-config.ts b/packages/integrations/markdoc/src/load-config.ts
index a912051b5..207749251 100644
--- a/packages/integrations/markdoc/src/load-config.ts
+++ b/packages/integrations/markdoc/src/load-config.ts
@@ -3,6 +3,7 @@ import { build as esbuild } from 'esbuild';
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import type { AstroMarkdocConfig } from './config.js';
+import { MarkdocError } from './utils.js';
export const SUPPORTED_MARKDOC_CONFIG_FILES = [
'markdoc.config.js',
@@ -42,9 +43,8 @@ export async function loadMarkdocConfig(
}
/**
- * Forked from Vite's `bundleConfigFile` function
- * with added handling for `.astro` imports,
- * and removed unused Deno patches.
+ * Bundle config file to support `.ts` files.
+ * Simplified fork from Vite's `bundleConfigFile` function:
* @see https://github.com/vitejs/vite/blob/main/packages/vite/src/node/config.ts#L961
*/
async function bundleConfigFile({
@@ -54,6 +54,8 @@ async function bundleConfigFile({
markdocConfigUrl: URL;
astroConfig: Pick<AstroConfig, 'root'>;
}): Promise<{ code: string; dependencies: string[] }> {
+ let markdocError: MarkdocError | undefined;
+
const result = await esbuild({
absWorkingDir: fileURLToPath(astroConfig.root),
entryPoints: [fileURLToPath(markdocConfigUrl)],
@@ -71,8 +73,14 @@ async function bundleConfigFile({
name: 'stub-astro-imports',
setup(build) {
build.onResolve({ filter: /.*\.astro$/ }, () => {
+ // Avoid throwing within esbuild.
+ // This swallows the `hint` and blows up the stacktrace.
+ markdocError = new MarkdocError({
+ message: '`.astro` files are no longer supported in the Markdoc config.',
+ hint: 'Use the `component()` utility to specify a component path instead.',
+ });
return {
- // Stub with an unused default export
+ // Stub with an unused default export.
path: 'data:text/javascript,export default true',
external: true,
};
@@ -81,6 +89,7 @@ async function bundleConfigFile({
},
],
});
+ if (markdocError) throw markdocError;
const { text } = result.outputFiles[0];
return {
code: text,
diff --git a/packages/integrations/markdoc/src/runtime.ts b/packages/integrations/markdoc/src/runtime.ts
index b0e8f2554..d710f1bd8 100644
--- a/packages/integrations/markdoc/src/runtime.ts
+++ b/packages/integrations/markdoc/src/runtime.ts
@@ -1,19 +1,25 @@
import type { MarkdownHeading } from '@astrojs/markdown-remark';
-import Markdoc, { type RenderableTreeNode } from '@markdoc/markdoc';
+import type { AstroInstance } from 'astro';
+import {
+ createComponent,
+ renderComponent,
+ // @ts-expect-error Cannot find module 'astro/runtime/server/index.js' or its corresponding type declarations.
+} from 'astro/runtime/server/index.js';
+import Markdoc, {
+ type ConfigType,
+ type Node,
+ type NodeType,
+ type RenderableTreeNode,
+} from '@markdoc/markdoc';
import type { AstroMarkdocConfig } from './config.js';
import { setupHeadingConfig } from './heading-ids.js';
-/** Used to call `Markdoc.transform()` and `Markdoc.Ast` in runtime modules */
-export { default as Markdoc } from '@markdoc/markdoc';
-
/**
* Merge user config with default config and set up context (ex. heading ID slugger)
* Called on each file's individual transform.
* TODO: virtual module to merge configs per-build instead of per-file?
*/
-export async function setupConfig(
- userConfig: AstroMarkdocConfig
-): Promise<Omit<AstroMarkdocConfig, 'extends'>> {
+export async function setupConfig(userConfig: AstroMarkdocConfig = {}): Promise<MergedConfig> {
let defaultConfig: AstroMarkdocConfig = setupHeadingConfig();
if (userConfig.extends) {
@@ -30,16 +36,19 @@ export async function setupConfig(
}
/** Used for synchronous `getHeadings()` function */
-export function setupConfigSync(
- userConfig: AstroMarkdocConfig
-): Omit<AstroMarkdocConfig, 'extends'> {
+export function setupConfigSync(userConfig: AstroMarkdocConfig = {}): MergedConfig {
const defaultConfig: AstroMarkdocConfig = setupHeadingConfig();
return mergeConfig(defaultConfig, userConfig);
}
+type MergedConfig = Required<Omit<AstroMarkdocConfig, 'extends'>>;
+
/** Merge function from `@markdoc/markdoc` internals */
-function mergeConfig(configA: AstroMarkdocConfig, configB: AstroMarkdocConfig): AstroMarkdocConfig {
+export function mergeConfig(
+ configA: AstroMarkdocConfig,
+ configB: AstroMarkdocConfig
+): MergedConfig {
return {
...configA,
...configB,
@@ -63,9 +72,33 @@ function mergeConfig(configA: AstroMarkdocConfig, configB: AstroMarkdocConfig):
...configA.variables,
...configB.variables,
},
+ partials: {
+ ...configA.partials,
+ ...configB.partials,
+ },
+ validation: {
+ ...configA.validation,
+ ...configB.validation,
+ },
};
}
+export function resolveComponentImports(
+ markdocConfig: Required<Pick<AstroMarkdocConfig, 'tags' | 'nodes'>>,
+ tagComponentMap: Record<string, AstroInstance['default']>,
+ nodeComponentMap: Record<NodeType, AstroInstance['default']>
+) {
+ for (const [tag, render] of Object.entries(tagComponentMap)) {
+ const config = markdocConfig.tags[tag];
+ if (config) config.render = render;
+ }
+ for (const [node, render] of Object.entries(nodeComponentMap)) {
+ const config = markdocConfig.nodes[node as NodeType];
+ if (config) config.render = render;
+ }
+ return markdocConfig;
+}
+
/**
* Get text content as a string from a Markdoc transform AST
*/
@@ -87,8 +120,10 @@ 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[] = [];
+export function collectHeadings(
+ children: RenderableTreeNode[],
+ collectedHeadings: MarkdownHeading[]
+) {
for (const node of children) {
if (typeof node !== 'object' || !Markdoc.Tag.isTag(node)) continue;
@@ -110,7 +145,42 @@ export function collectHeadings(children: RenderableTreeNode[]): MarkdownHeading
});
}
}
- collectedHeadings.concat(collectHeadings(node.children));
+ collectHeadings(node.children, collectedHeadings);
}
- return collectedHeadings;
+}
+
+export function createGetHeadings(stringifiedAst: string, userConfig: AstroMarkdocConfig) {
+ return 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. */
+ const config = setupConfigSync(userConfig);
+ const ast = Markdoc.Ast.fromJSON(stringifiedAst);
+ const content = Markdoc.transform(ast as Node, config as ConfigType);
+ let collectedHeadings: MarkdownHeading[] = [];
+ collectHeadings(Array.isArray(content) ? content : [content], collectedHeadings);
+ return collectedHeadings;
+ };
+}
+
+export function createContentComponent(
+ Renderer: AstroInstance['default'],
+ stringifiedAst: string,
+ userConfig: AstroMarkdocConfig,
+ tagComponentMap: Record<string, AstroInstance['default']>,
+ nodeComponentMap: Record<NodeType, AstroInstance['default']>
+) {
+ return createComponent({
+ async factory(result: any, props: Record<string, any>) {
+ const withVariables = mergeConfig(userConfig, { variables: props });
+ const config = resolveComponentImports(
+ await setupConfig(withVariables),
+ tagComponentMap,
+ nodeComponentMap
+ );
+
+ return renderComponent(result, Renderer.name, Renderer, { stringifiedAst, config }, {});
+ },
+ propagation: 'self',
+ });
}
diff --git a/packages/integrations/markdoc/src/utils.ts b/packages/integrations/markdoc/src/utils.ts
index 002d2238f..1fd896d52 100644
--- a/packages/integrations/markdoc/src/utils.ts
+++ b/packages/integrations/markdoc/src/utils.ts
@@ -1,28 +1,4 @@
-import matter from 'gray-matter';
-import type { ErrorPayload as ViteErrorPayload } from 'vite';
-
-/**
- * Match YAML exception handling from Astro core errors
- * @see 'astro/src/core/errors.ts'
- */
-export function parseFrontmatter(fileContents: string, filePath: string) {
- try {
- // `matter` is empty string on cache results
- // clear cache to prevent this
- (matter as any).clearCache();
- return matter(fileContents);
- } catch (e: any) {
- if (e.name === 'YAMLException') {
- const err: Error & ViteErrorPayload['err'] = e;
- err.id = filePath;
- err.loc = { file: e.id, line: e.mark.line + 1, column: e.mark.column };
- err.message = e.reason;
- throw err;
- } else {
- throw e;
- }
- }
-}
+import type { ComponentConfig } from './config.js';
/**
* Matches AstroError object with types like error codes stubbed out
@@ -97,3 +73,10 @@ export function hasContentFlag(viteId: string, flag: string): boolean {
const flags = new URLSearchParams(viteId.split('?')[1] ?? '');
return flags.has(flag);
}
+
+/** Identifier for components imports passed as `tags` or `nodes` configuration. */
+export const componentConfigSymbol = Symbol.for('@astrojs/markdoc/component-config');
+
+export function isComponentConfig(value: unknown): value is ComponentConfig {
+ return typeof value === 'object' && value !== null && componentConfigSymbol in value;
+}