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/index.ts127
-rw-r--r--packages/integrations/markdoc/src/utils.ts147
2 files changed, 274 insertions, 0 deletions
diff --git a/packages/integrations/markdoc/src/index.ts b/packages/integrations/markdoc/src/index.ts
new file mode 100644
index 000000000..b71fe6968
--- /dev/null
+++ b/packages/integrations/markdoc/src/index.ts
@@ -0,0 +1,127 @@
+import type { AstroIntegration, AstroConfig, ContentEntryType, HookParameters } from 'astro';
+import { InlineConfig } from 'vite';
+import type { Config } from '@markdoc/markdoc';
+import Markdoc from '@markdoc/markdoc';
+import {
+ prependForwardSlash,
+ getAstroConfigPath,
+ MarkdocError,
+ parseFrontmatter,
+} from './utils.js';
+import { fileURLToPath } from 'node:url';
+import fs from 'node:fs';
+
+type IntegrationWithPrivateHooks = {
+ name: string;
+ hooks: Omit<AstroIntegration['hooks'], 'astro:config:setup'> & {
+ 'astro:config:setup': (
+ params: HookParameters<'astro:config:setup'> & {
+ // `contentEntryType` is not a public API
+ // Add type defs here
+ addContentEntryType: (contentEntryType: ContentEntryType) => void;
+ }
+ ) => void | Promise<void>;
+ };
+};
+
+export default function markdoc(markdocConfig: Config = {}): IntegrationWithPrivateHooks {
+ return {
+ name: '@astrojs/markdoc',
+ hooks: {
+ 'astro:config:setup': async ({ updateConfig, config, addContentEntryType }) => {
+ 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,
+ contentModuleTypes: await fs.promises.readFile(
+ new URL('../template/content-module-types.d.ts', import.meta.url),
+ 'utf-8'
+ ),
+ });
+
+ const viteConfig: InlineConfig = {
+ plugins: [
+ {
+ name: '@astrojs/markdoc',
+ async transform(code, id) {
+ if (!id.endsWith('.mdoc')) return;
+
+ validateRenderProperties(markdocConfig, config);
+ const body = getEntryInfo({
+ // Can't use `pathToFileUrl` - Vite IDs are not plain file paths
+ fileUrl: new URL(prependForwardSlash(id), 'file://'),
+ contents: code,
+ }).body;
+ const ast = Markdoc.parse(body);
+ const content = Markdoc.transform(ast, markdocConfig);
+
+ return `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;`;
+ },
+ },
+ ],
+ };
+ updateConfig({ vite: viteConfig });
+ },
+ },
+ };
+}
+
+function validateRenderProperties(markdocConfig: Config, 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();
+}
diff --git a/packages/integrations/markdoc/src/utils.ts b/packages/integrations/markdoc/src/utils.ts
new file mode 100644
index 000000000..fc41f651d
--- /dev/null
+++ b/packages/integrations/markdoc/src/utils.ts
@@ -0,0 +1,147 @@
+import matter from 'gray-matter';
+import path from 'node:path';
+import type fsMod from 'node:fs';
+import type { ErrorPayload as ViteErrorPayload } from 'vite';
+import type { AstroInstance } from 'astro';
+import z from 'astro/zod';
+
+/**
+ * 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;
+ }
+ }
+}
+
+/**
+ * Matches AstroError object with types like error codes stubbed out
+ * @see 'astro/src/core/errors/errors.ts'
+ */
+export class MarkdocError extends Error {
+ public errorCode: number;
+ public loc: ErrorLocation | undefined;
+ public title: string | undefined;
+ public hint: string | undefined;
+ public frame: string | undefined;
+
+ type = 'MarkdocError';
+
+ constructor(props: ErrorProperties, ...params: any) {
+ super(...params);
+
+ const {
+ // Use default code for unknown errors in Astro core
+ // We don't have a best practice for integration error codes yet
+ code = 99999,
+ name,
+ title = 'MarkdocError',
+ message,
+ stack,
+ location,
+ hint,
+ frame,
+ } = props;
+
+ this.errorCode = code;
+ this.title = title;
+ if (message) this.message = message;
+ // Only set this if we actually have a stack passed, otherwise uses Error's
+ this.stack = stack ? stack : this.stack;
+ this.loc = location;
+ this.hint = hint;
+ this.frame = frame;
+ }
+}
+
+interface ErrorLocation {
+ file?: string;
+ line?: number;
+ column?: number;
+}
+
+interface ErrorProperties {
+ code?: number;
+ title?: string;
+ name?: string;
+ message?: string;
+ location?: ErrorLocation;
+ hint?: string;
+ stack?: string;
+ frame?: string;
+}
+
+/**
+ * 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();
+}