From fb7af551148f5ca6c4f98a4e556c8948c5690919 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Tue, 27 Jun 2023 15:05:17 -0400 Subject: [PATCH] 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 --- .changeset/sour-starfishes-behave.md | 27 ++ examples/with-markdoc/markdoc.config.mjs | 5 +- packages/integrations/markdoc/package.json | 1 + packages/integrations/markdoc/src/config.ts | 25 +- .../markdoc/src/content-entry-type.ts | 278 ++++++++++++++++++ packages/integrations/markdoc/src/index.ts | 251 +--------------- .../integrations/markdoc/src/load-config.ts | 17 +- packages/integrations/markdoc/src/runtime.ts | 100 ++++++- packages/integrations/markdoc/src/utils.ts | 33 +-- .../headings-custom/markdoc.config.mjs | 5 +- .../propagated-assets/markdoc.config.mjs | 8 +- .../render-with-components/markdoc.config.ts | 8 +- pnpm-lock.yaml | 3 + 13 files changed, 452 insertions(+), 309 deletions(-) create mode 100644 .changeset/sour-starfishes-behave.md create mode 100644 packages/integrations/markdoc/src/content-entry-type.ts diff --git a/.changeset/sour-starfishes-behave.md b/.changeset/sour-starfishes-behave.md new file mode 100644 index 000000000..f5843dfd8 --- /dev/null +++ b/.changeset/sour-starfishes-behave.md @@ -0,0 +1,27 @@ +--- +'@astrojs/markdoc': minor +--- + +Updates the Markdoc config object for rendering Astro components as tags or nodes. Rather than importing components directly, Astro includes a new `component()` function to specify your component path. This unlocks using Astro components from npm packages and `.ts` files. + +### Migration + +Update all component imports to instead import the new `component()` function and use it to render your Astro components: + +```diff +// markdoc.config.mjs +import { + defineMarkdocConfig, ++ component, +} from '@astrojs/markdoc/config'; +- import Aside from './src/components/Aside.astro'; + +export default defineMarkdocConfig({ + tags: { + aside: { + render: Aside, ++ render: component('./src/components/Aside.astro'), + } + } +}); +``` diff --git a/examples/with-markdoc/markdoc.config.mjs b/examples/with-markdoc/markdoc.config.mjs index 0ae63d4ee..90608d564 100644 --- a/examples/with-markdoc/markdoc.config.mjs +++ b/examples/with-markdoc/markdoc.config.mjs @@ -1,10 +1,9 @@ -import { defineMarkdocConfig } from '@astrojs/markdoc/config'; -import Aside from './src/components/Aside.astro'; +import { defineMarkdocConfig, component } from '@astrojs/markdoc/config'; export default defineMarkdocConfig({ tags: { aside: { - render: Aside, + render: component('./src/components/Aside.astro'), attributes: { type: { type: String }, title: { type: String }, diff --git a/packages/integrations/markdoc/package.json b/packages/integrations/markdoc/package.json index dafdfcc86..98a02a96a 100644 --- a/packages/integrations/markdoc/package.json +++ b/packages/integrations/markdoc/package.json @@ -63,6 +63,7 @@ "test:match": "mocha --timeout 20000 -g" }, "dependencies": { + "@astrojs/internal-helpers": "^0.1.0", "@astrojs/prism": "^2.1.2", "@markdoc/markdoc": "^0.3.0", "esbuild": "^0.17.19", 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 = Record> = 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 { + 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 = {}; + // 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 = {}; + 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 from rendered output + // Without this, they're rendered as strings! + allowComments: true, +}); + +function getUsedTags(markdocAst: Node) { + const tags = new Set(); + 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, + 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, + 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 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 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; }): 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> { +export async function setupConfig(userConfig: AstroMarkdocConfig = {}): Promise { 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 { +export function setupConfigSync(userConfig: AstroMarkdocConfig = {}): MergedConfig { const defaultConfig: AstroMarkdocConfig = setupHeadingConfig(); return mergeConfig(defaultConfig, userConfig); } +type MergedConfig = Required>; + /** 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>, + tagComponentMap: Record, + nodeComponentMap: Record +) { + 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 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, + nodeComponentMap: Record +) { + return createComponent({ + async factory(result: any, props: Record) { + 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; +} diff --git a/packages/integrations/markdoc/test/fixtures/headings-custom/markdoc.config.mjs b/packages/integrations/markdoc/test/fixtures/headings-custom/markdoc.config.mjs index 32fcf61e2..f1bea70e8 100644 --- a/packages/integrations/markdoc/test/fixtures/headings-custom/markdoc.config.mjs +++ b/packages/integrations/markdoc/test/fixtures/headings-custom/markdoc.config.mjs @@ -1,11 +1,10 @@ -import { defineMarkdocConfig, nodes } from '@astrojs/markdoc/config'; -import Heading from './src/components/Heading.astro'; +import { defineMarkdocConfig, component, nodes } from '@astrojs/markdoc/config'; export default defineMarkdocConfig({ nodes: { heading: { ...nodes.heading, - render: Heading, + render: component('./src/components/Heading.astro'), } } }); diff --git a/packages/integrations/markdoc/test/fixtures/propagated-assets/markdoc.config.mjs b/packages/integrations/markdoc/test/fixtures/propagated-assets/markdoc.config.mjs index 5389eb99d..368d30ebd 100644 --- a/packages/integrations/markdoc/test/fixtures/propagated-assets/markdoc.config.mjs +++ b/packages/integrations/markdoc/test/fixtures/propagated-assets/markdoc.config.mjs @@ -1,18 +1,16 @@ -import Aside from './src/components/Aside.astro'; -import LogHello from './src/components/LogHello.astro'; -import { defineMarkdocConfig } from '@astrojs/markdoc/config'; +import { defineMarkdocConfig, component } from '@astrojs/markdoc/config'; export default defineMarkdocConfig({ tags: { aside: { - render: Aside, + render: component('./src/components/Aside.astro'), attributes: { type: { type: String }, title: { type: String }, } }, logHello: { - render: LogHello, + render: component('./src/components/LogHello.astro'), } }, }) diff --git a/packages/integrations/markdoc/test/fixtures/render-with-components/markdoc.config.ts b/packages/integrations/markdoc/test/fixtures/render-with-components/markdoc.config.ts index ada03f5f4..b7845d182 100644 --- a/packages/integrations/markdoc/test/fixtures/render-with-components/markdoc.config.ts +++ b/packages/integrations/markdoc/test/fixtures/render-with-components/markdoc.config.ts @@ -1,11 +1,9 @@ -import Code from './src/components/Code.astro'; -import CustomMarquee from './src/components/CustomMarquee.astro'; -import { defineMarkdocConfig } from '@astrojs/markdoc/config'; +import { defineMarkdocConfig, component } from '@astrojs/markdoc/config'; export default defineMarkdocConfig({ nodes: { fence: { - render: Code, + render: component('./src/components/Code.astro'), attributes: { language: { type: String }, content: { type: String }, @@ -14,7 +12,7 @@ export default defineMarkdocConfig({ }, tags: { mq: { - render: CustomMarquee, + render: component('./src/components/CustomMarquee.astro'), attributes: { direction: { type: String, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c35572463..84f0c51bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3971,6 +3971,9 @@ importers: packages/integrations/markdoc: dependencies: + '@astrojs/internal-helpers': + specifier: ^0.1.0 + version: link:../../internal-helpers '@astrojs/prism': specifier: ^2.1.2 version: link:../../astro-prism