diff options
Diffstat (limited to 'packages/integrations/markdoc/src')
-rw-r--r-- | packages/integrations/markdoc/src/config.ts | 5 | ||||
-rw-r--r-- | packages/integrations/markdoc/src/default-config.ts | 18 | ||||
-rw-r--r-- | packages/integrations/markdoc/src/experimental-assets-config.ts | 29 | ||||
-rw-r--r-- | packages/integrations/markdoc/src/index.ts | 208 | ||||
-rw-r--r-- | packages/integrations/markdoc/src/load-config.ts | 102 | ||||
-rw-r--r-- | packages/integrations/markdoc/src/utils.ts | 56 |
6 files changed, 219 insertions, 199 deletions
diff --git a/packages/integrations/markdoc/src/config.ts b/packages/integrations/markdoc/src/config.ts new file mode 100644 index 000000000..4c20e311f --- /dev/null +++ b/packages/integrations/markdoc/src/config.ts @@ -0,0 +1,5 @@ +import type { ConfigType as MarkdocConfig } from '@markdoc/markdoc'; + +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 new file mode 100644 index 000000000..16bd2c41f --- /dev/null +++ b/packages/integrations/markdoc/src/default-config.ts @@ -0,0 +1,18 @@ +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 new file mode 100644 index 000000000..962755355 --- /dev/null +++ b/packages/integrations/markdoc/src/experimental-assets-config.ts @@ -0,0 +1,29 @@ +import type { Config as MarkdocConfig } from '@markdoc/markdoc'; +import Markdoc from '@markdoc/markdoc'; +//@ts-expect-error Cannot find module 'astro:assets' or its corresponding type declarations. +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. +export const experimentalAssetsConfig: MarkdocConfig = { + nodes: { + image: { + attributes: { + ...Markdoc.nodes.image.attributes, + __optimizedSrc: { type: 'Object' }, + }, + transform(node, config) { + const attributes = node.transformAttributes(config); + const children = node.transformChildren(config); + + if (node.type === 'image' && '__optimizedSrc' in node.attributes) { + const { __optimizedSrc, ...rest } = node.attributes; + return new Markdoc.Tag(Image, { ...rest, src: __optimizedSrc }, children); + } else { + return new Markdoc.Tag('img', attributes, children); + } + }, + }, + }, +}; diff --git a/packages/integrations/markdoc/src/index.ts b/packages/integrations/markdoc/src/index.ts index ebfc09ba7..feb9a501c 100644 --- a/packages/integrations/markdoc/src/index.ts +++ b/packages/integrations/markdoc/src/index.ts @@ -1,23 +1,15 @@ -import type { - Config as ReadonlyMarkdocConfig, - ConfigType as MarkdocConfig, - Node, -} from '@markdoc/markdoc'; +import type { Node } from '@markdoc/markdoc'; import Markdoc from '@markdoc/markdoc'; import type { AstroConfig, AstroIntegration, ContentEntryType, HookParameters } from 'astro'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; -import type * as rollup from 'rollup'; -import { - getAstroConfigPath, - isValidUrl, - MarkdocError, - parseFrontmatter, - prependForwardSlash, -} from './utils.js'; +import { isValidUrl, MarkdocError, parseFrontmatter, prependForwardSlash } from './utils.js'; // @ts-expect-error Cannot find module 'astro/assets' or its corresponding type declarations. import { emitESMImage } from 'astro/assets'; -import type { Plugin as VitePlugin } from 'vite'; +import { loadMarkdocConfig } from './load-config.js'; +import { applyDefaultConfig } from './default-config.js'; +import { bold, red } from 'kleur/colors'; +import type * as rollup from 'rollup'; type SetupHookParams = HookParameters<'astro:config:setup'> & { // `contentEntryType` is not a public API @@ -25,24 +17,24 @@ type SetupHookParams = HookParameters<'astro:config:setup'> & { addContentEntryType: (contentEntryType: ContentEntryType) => void; }; -export default function markdocIntegration( - userMarkdocConfig: ReadonlyMarkdocConfig = {} -): AstroIntegration { +export default function markdocIntegration(legacyConfig: any): AstroIntegration { + if (legacyConfig) { + // eslint-disable-next-line no-console + console.log( + `${red( + bold('[Markdoc]') + )} Passing Markdoc config from your \`astro.config\` is no longer supported. Configuration should be exported from a \`markdoc.config.mjs\` file. See the configuration docs for more: https://docs.astro.build/en/guides/integrations-guide/markdoc/#configuration` + ); + process.exit(0); + } return { name: '@astrojs/markdoc', hooks: { 'astro:config:setup': async (params) => { - const { - updateConfig, - config: astroConfig, - addContentEntryType, - } = params as SetupHookParams; + const { config: astroConfig, addContentEntryType } = params as SetupHookParams; - updateConfig({ - vite: { - plugins: [safeAssetsVirtualModulePlugin({ astroConfig })], - }, - }); + const configLoadResult = await loadMarkdocConfig(astroConfig); + const userMarkdocConfig = configLoadResult?.config ?? {}; function getEntryInfo({ fileUrl, contents }: { fileUrl: URL; contents: string }) { const parsed = parseFrontmatter(contents, fileURLToPath(fileUrl)); @@ -56,49 +48,63 @@ export default function markdocIntegration( addContentEntryType({ extensions: ['.mdoc'], getEntryInfo, - async getRenderModule({ entry }) { - validateRenderProperties(userMarkdocConfig, astroConfig); + async getRenderModule({ entry, viteId }) { const ast = Markdoc.parse(entry.body); const pluginContext = this; - const markdocConfig: MarkdocConfig = { - ...userMarkdocConfig, - variables: { - ...userMarkdocConfig.variables, - entry, - }, - }; + const markdocConfig = applyDefaultConfig(userMarkdocConfig, { entry }); + + const validationErrors = Markdoc.validate(ast, markdocConfig).filter((e) => { + // Ignore `variable-undefined` errors. + // Variables can be configured at runtime, + // so we cannot validate them at build time. + return e.error.id !== 'variable-undefined'; + }); + if (validationErrors.length) { + throw new MarkdocError({ + message: [ + `**${String(entry.collection)} → ${String(entry.id)}** failed to validate:`, + ...validationErrors.map((e) => e.error.id), + ].join('\n'), + }); + } - if (astroConfig.experimental?.assets) { + if (astroConfig.experimental.assets) { await emitOptimizedImages(ast.children, { astroConfig, pluginContext, filePath: entry._internal.filePath, }); - - markdocConfig.nodes ??= {}; - markdocConfig.nodes.image = { - ...Markdoc.nodes.image, - transform(node, config) { - const attributes = node.transformAttributes(config); - const children = node.transformChildren(config); - - if (node.type === 'image' && '__optimizedSrc' in node.attributes) { - const { __optimizedSrc, ...rest } = node.attributes; - return new Markdoc.Tag('Image', { ...rest, src: __optimizedSrc }, children); - } else { - return new Markdoc.Tag('img', attributes, children); - } - }, - }; } - const content = Markdoc.transform(ast, markdocConfig); - - return { - code: `import { jsx as h } from 'astro/jsx-runtime';\nimport { Renderer } from '@astrojs/markdoc/components';\nconst transformedContent = ${JSON.stringify( - content - )};\nexport async function Content ({ components }) { return h(Renderer, { content: transformedContent, components }); }\nContent[Symbol.for('astro.needsHeadRendering')] = true;`, + const code = { + 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')};${ + configLoadResult + ? `\nimport userConfig from ${JSON.stringify(configLoadResult.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) + )}; +export async function Content (props) { + const config = applyDefaultConfig(${ + configLoadResult + ? '{ ...userConfig, variables: { ...userConfig.variables, ...props } }' + : '{ variables: props }' + }, { entry });${ + astroConfig.experimental.assets + ? `\nconfig.nodes = { ...experimentalAssetsConfig.nodes, ...config.nodes };` + : '' + } + return h(Renderer, { stringifiedAst, config }); };`, }; + return code; }, contentModuleTypes: await fs.promises.readFile( new URL('../template/content-module-types.d.ts', import.meta.url), @@ -156,87 +162,3 @@ function shouldOptimizeImage(src: string) { // Optimize anything that is NOT external or an absolute path to `public/` return !isValidUrl(src) && !src.startsWith('/'); } - -function validateRenderProperties(markdocConfig: ReadonlyMarkdocConfig, astroConfig: AstroConfig) { - const tags = markdocConfig.tags ?? {}; - const nodes = markdocConfig.nodes ?? {}; - - for (const [name, config] of Object.entries(tags)) { - validateRenderProperty({ type: 'tag', name, config, astroConfig }); - } - for (const [name, config] of Object.entries(nodes)) { - validateRenderProperty({ type: 'node', name, config, astroConfig }); - } -} - -function validateRenderProperty({ - name, - config, - type, - astroConfig, -}: { - name: string; - config: { render?: string }; - type: 'node' | 'tag'; - astroConfig: Pick<AstroConfig, 'root'>; -}) { - if (typeof config.render === 'string' && config.render.length === 0) { - throw new Error( - `Invalid ${type} configuration: ${JSON.stringify( - name - )}. The "render" property cannot be an empty string.` - ); - } - if (typeof config.render === 'string' && !isCapitalized(config.render)) { - const astroConfigPath = getAstroConfigPath(fs, fileURLToPath(astroConfig.root)); - throw new MarkdocError({ - message: `Invalid ${type} configuration: ${JSON.stringify( - name - )}. The "render" property must reference a capitalized component name.`, - hint: 'If you want to render to an HTML element, see our docs on rendering Markdoc manually: https://docs.astro.build/en/guides/integrations-guide/markdoc/#render-markdoc-nodes--html-elements-as-astro-components', - location: astroConfigPath - ? { - file: astroConfigPath, - } - : undefined, - }); - } -} - -function isCapitalized(str: string) { - return str.length > 0 && str[0] === str[0].toUpperCase(); -} - -/** - * TODO: remove when `experimental.assets` is baselined. - * - * `astro:assets` will fail to resolve if the `experimental.assets` flag is not enabled. - * This ensures a fallback for the Markdoc renderer to safely import at the top level. - * @see ../components/TreeNode.ts - */ -function safeAssetsVirtualModulePlugin({ - astroConfig, -}: { - astroConfig: Pick<AstroConfig, 'experimental'>; -}): VitePlugin { - const virtualModuleId = 'astro:markdoc-assets'; - const resolvedVirtualModuleId = '\0' + virtualModuleId; - - return { - name: 'astro:markdoc-safe-assets-virtual-module', - resolveId(id) { - if (id === virtualModuleId) { - return resolvedVirtualModuleId; - } - }, - load(id) { - if (id !== resolvedVirtualModuleId) return; - - if (astroConfig.experimental?.assets) { - return `export { Image } from 'astro:assets';`; - } else { - return `export const Image = () => { throw new Error('Cannot use the Image component without the \`experimental.assets\` flag.'); }`; - } - }, - }; -} diff --git a/packages/integrations/markdoc/src/load-config.ts b/packages/integrations/markdoc/src/load-config.ts new file mode 100644 index 000000000..db36edf25 --- /dev/null +++ b/packages/integrations/markdoc/src/load-config.ts @@ -0,0 +1,102 @@ +import type { AstroConfig } from 'astro'; +import type { Config as MarkdocConfig } from '@markdoc/markdoc'; +import { build as esbuild } from 'esbuild'; +import { fileURLToPath } from 'node:url'; +import * as fs from 'node:fs'; + +const SUPPORTED_MARKDOC_CONFIG_FILES = [ + 'markdoc.config.js', + 'markdoc.config.mjs', + 'markdoc.config.mts', + 'markdoc.config.ts', +]; + +export async function loadMarkdocConfig(astroConfig: Pick<AstroConfig, 'root'>) { + let markdocConfigUrl: URL | undefined; + for (const filename of SUPPORTED_MARKDOC_CONFIG_FILES) { + const filePath = new URL(filename, astroConfig.root); + if (!fs.existsSync(filePath)) continue; + + markdocConfigUrl = filePath; + break; + } + if (!markdocConfigUrl) return; + + const { code, dependencies } = await bundleConfigFile({ + markdocConfigUrl, + astroConfig, + }); + const config: MarkdocConfig = await loadConfigFromBundledFile(astroConfig.root, code); + + return { + config, + fileUrl: markdocConfigUrl, + }; +} + +/** + * Forked from Vite's `bundleConfigFile` function + * with added handling for `.astro` imports, + * and removed unused Deno patches. + * @see https://github.com/vitejs/vite/blob/main/packages/vite/src/node/config.ts#L961 + */ +async function bundleConfigFile({ + markdocConfigUrl, + astroConfig, +}: { + markdocConfigUrl: URL; + astroConfig: Pick<AstroConfig, 'root'>; +}): Promise<{ code: string; dependencies: string[] }> { + const result = await esbuild({ + absWorkingDir: fileURLToPath(astroConfig.root), + entryPoints: [fileURLToPath(markdocConfigUrl)], + outfile: 'out.js', + write: false, + target: ['node16'], + platform: 'node', + packages: 'external', + bundle: true, + format: 'esm', + sourcemap: 'inline', + metafile: true, + plugins: [ + { + name: 'stub-astro-imports', + setup(build) { + build.onResolve({ filter: /.*\.astro$/ }, () => { + return { + // Stub with an unused default export + path: 'data:text/javascript,export default true', + external: true, + }; + }); + }, + }, + ], + }); + const { text } = result.outputFiles[0]; + return { + code: text, + dependencies: result.metafile ? Object.keys(result.metafile.inputs) : [], + }; +} + +/** + * Forked from Vite config loader, replacing CJS-based path concat + * with ESM only + * @see https://github.com/vitejs/vite/blob/main/packages/vite/src/node/config.ts#L1074 + */ +async function loadConfigFromBundledFile(root: URL, code: string): Promise<MarkdocConfig> { + // Write it to disk, load it with native Node ESM, then delete the file. + const tmpFileUrl = new URL(`markdoc.config.timestamp-${Date.now()}.mjs`, root); + fs.writeFileSync(tmpFileUrl, code); + try { + return (await import(tmpFileUrl.pathname)).default; + } finally { + try { + fs.unlinkSync(tmpFileUrl); + } catch { + // already removed if this function is called twice simultaneously + } + } +} diff --git a/packages/integrations/markdoc/src/utils.ts b/packages/integrations/markdoc/src/utils.ts index 9d6e5af26..95f84700c 100644 --- a/packages/integrations/markdoc/src/utils.ts +++ b/packages/integrations/markdoc/src/utils.ts @@ -1,5 +1,3 @@ -import type { AstroInstance } from 'astro'; -import z from 'astro/zod'; import matter from 'gray-matter'; import type fsMod from 'node:fs'; import path from 'node:path'; @@ -86,66 +84,12 @@ interface ErrorProperties { } /** - * Matches `search` function used for resolving `astro.config` files. - * Used by Markdoc for error handling. - * @see 'astro/src/core/config/config.ts' - */ -export function getAstroConfigPath(fs: typeof fsMod, root: string): string | undefined { - const paths = [ - 'astro.config.mjs', - 'astro.config.js', - 'astro.config.ts', - 'astro.config.mts', - 'astro.config.cjs', - 'astro.config.cts', - ].map((p) => path.join(root, p)); - - for (const file of paths) { - if (fs.existsSync(file)) { - return file; - } - } -} - -/** * @see 'astro/src/core/path.ts' */ export function prependForwardSlash(str: string) { return str[0] === '/' ? str : '/' + str; } -export function validateComponentsProp(components: Record<string, AstroInstance['default']>) { - try { - componentsPropValidator.parse(components); - } catch (e) { - throw new MarkdocError({ - message: - e instanceof z.ZodError - ? e.issues[0].message - : 'Invalid `components` prop. Ensure you are passing an object of components to <Content />', - }); - } -} - -const componentsPropValidator = z.record( - z - .string() - .min(1, 'Invalid `components` prop. Component names cannot be empty!') - .refine( - (value) => isCapitalized(value), - (value) => ({ - message: `Invalid \`components\` prop: ${JSON.stringify( - value - )}. Component name must be capitalized. If you want to render HTML elements as components, try using a Markdoc node (https://docs.astro.build/en/guides/integrations-guide/markdoc/#render-markdoc-nodes--html-elements-as-astro-components)`, - }) - ), - z.any() -); - -export function isCapitalized(str: string) { - return str.length > 0 && str[0] === str[0].toUpperCase(); -} - export function isValidUrl(str: string): boolean { try { new URL(str); |