summaryrefslogtreecommitdiff
path: root/packages/integrations/markdoc/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/integrations/markdoc/src')
-rw-r--r--packages/integrations/markdoc/src/config.ts5
-rw-r--r--packages/integrations/markdoc/src/default-config.ts18
-rw-r--r--packages/integrations/markdoc/src/experimental-assets-config.ts29
-rw-r--r--packages/integrations/markdoc/src/index.ts208
-rw-r--r--packages/integrations/markdoc/src/load-config.ts102
-rw-r--r--packages/integrations/markdoc/src/utils.ts56
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);