diff options
Diffstat (limited to 'packages/integrations/markdoc/src')
-rw-r--r-- | packages/integrations/markdoc/src/index.ts | 127 | ||||
-rw-r--r-- | packages/integrations/markdoc/src/utils.ts | 147 |
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(); +} |