From 9e2808c58cff53761e28c73c5bbc044a581fae50 Mon Sep 17 00:00:00 2001 From: bholmesdev Date: Thu, 2 Mar 2023 12:50:20 -0500 Subject: [PATCH] chore: validate tags and nodes --- packages/integrations/markdoc/src/index.ts | 56 ++++++++++++++- packages/integrations/markdoc/src/utils.ts | 79 ++++++++++++++++++++++ 2 files changed, 132 insertions(+), 3 deletions(-) diff --git a/packages/integrations/markdoc/src/index.ts b/packages/integrations/markdoc/src/index.ts index ccfc00e3c..e1b113f65 100644 --- a/packages/integrations/markdoc/src/index.ts +++ b/packages/integrations/markdoc/src/index.ts @@ -1,12 +1,12 @@ -import type { AstroIntegration } from 'astro'; +import type { AstroIntegration, AstroConfig } from 'astro'; import type { InlineConfig } from 'vite'; import type { Config } from '@markdoc/markdoc'; import Markdoc from '@markdoc/markdoc'; -import { parseFrontmatter } from './utils.js'; +import { getAstroConfigPath, MarkdocError, parseFrontmatter } from './utils.js'; import { fileURLToPath } from 'node:url'; import fs from 'node:fs'; -export default function markdoc(markdocConfig: Config): AstroIntegration { +export default function markdoc(markdocConfig: Config = {}): AstroIntegration { const entryBodyByFileIdCache = new Map(); return { name: '@astrojs/markdoc', @@ -38,6 +38,7 @@ export default function markdoc(markdocConfig: Config): AstroIntegration { async transform(code, id) { if (!id.endsWith('.mdoc')) return; + validateRenderProperties(markdocConfig, config); const body = entryBodyByFileIdCache.get(id); if (!body) { // Cache entry should exist if `getCollection()` was called @@ -64,3 +65,52 @@ export default function markdoc(markdocConfig: Config): AstroIntegration { }, }; } + +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; +}) { + 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. If you want to render to an HTML element, see our docs on rendering Markdoc manually [TODO docs link].`, + 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 index abcebbf8b..9ef80e1bf 100644 --- a/packages/integrations/markdoc/src/utils.ts +++ b/packages/integrations/markdoc/src/utils.ts @@ -1,4 +1,6 @@ import matter from 'gray-matter'; +import path from 'node:path'; +import type fsMod from 'node:fs'; import type { ErrorPayload as ViteErrorPayload } from 'vite'; /** @@ -23,3 +25,80 @@ export function parseFrontmatter(fileContents: string, filePath: string) { } } } + +/** + * 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 { + 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; + } + } +}