From 3a9f72c7f30ed173438fd0a222a094e5997b917d Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Tue, 9 May 2023 17:20:55 -0400 Subject: [PATCH] [Markdoc] Validation and debugging improvements (#7045) * feat: better validation logs * chore: add warning to restart server on config chnage * feat: expose Markdoc global from markdoc/config * docs: update `nodes` reference * chore: changeset * docs: simplify headings explainer * chore: ignore eslint log errors * fix: make legacyConfig prop optional --- .changeset/nine-wolves-watch.md | 5 ++ packages/integrations/markdoc/README.md | 14 ++--- packages/integrations/markdoc/src/config.ts | 1 + packages/integrations/markdoc/src/index.ts | 60 +++++++++++++------ .../integrations/markdoc/src/load-config.ts | 9 ++- 5 files changed, 62 insertions(+), 27 deletions(-) create mode 100644 .changeset/nine-wolves-watch.md diff --git a/.changeset/nine-wolves-watch.md b/.changeset/nine-wolves-watch.md new file mode 100644 index 000000000000..f6f8a586b358 --- /dev/null +++ b/.changeset/nine-wolves-watch.md @@ -0,0 +1,5 @@ +--- +'@astrojs/markdoc': patch +--- + +Improve Markdoc validation errors with full message and file preview. diff --git a/packages/integrations/markdoc/README.md b/packages/integrations/markdoc/README.md index 85a657ea4828..9a8bda3bbc9a 100644 --- a/packages/integrations/markdoc/README.md +++ b/packages/integrations/markdoc/README.md @@ -143,28 +143,26 @@ Use tags like this fancy "aside" to add some *flair* to your docs. #### Render Markdoc nodes / HTML elements as Astro components -You may also want to map standard HTML elements like headings and paragraphs to components. For this, you can configure a custom [Markdoc node][markdoc-nodes]. This example overrides Markdoc's `heading` node to render a `Heading` component, passing the built-in `level` attribute as a prop: +You may also want to map standard HTML elements like headings and paragraphs to components. For this, you can configure a custom [Markdoc node][markdoc-nodes]. This example overrides Markdoc's `heading` node to render a `Heading` component, and passes through [Markdoc's default attributes for headings](https://markdoc.dev/docs/nodes#built-in-nodes). ```js // markdoc.config.mjs -import { defineMarkdocConfig } from '@astrojs/markdoc/config'; +import { defineMarkdocConfig, Markdoc } from '@astrojs/markdoc/config'; import Heading from './src/components/Heading.astro'; export default defineMarkdocConfig({ nodes: { heading: { render: Heading, - attributes: { - // Pass the attributes from Markdoc's default heading node - // as component props. - level: { type: String }, - } + attributes: Markdoc.nodes.heading.attributes, }, }, }) ``` -Now, all Markdown headings will render with the `Heading.astro` component. This example uses a level 3 heading, automatically passing `level: 3` as the component prop: +Now, all Markdown headings will render with the `Heading.astro` component, and pass these `attributes` as component props. For headings, Markdoc provides a `level` attribute containing the numeric heading level. + +This example uses a level 3 heading, automatically passing `level: 3` as the component prop: ```md ### I'm a level 3 heading! diff --git a/packages/integrations/markdoc/src/config.ts b/packages/integrations/markdoc/src/config.ts index 4c20e311f47a..09bbead120e8 100644 --- a/packages/integrations/markdoc/src/config.ts +++ b/packages/integrations/markdoc/src/config.ts @@ -1,4 +1,5 @@ import type { ConfigType as MarkdocConfig } from '@markdoc/markdoc'; +export { default as Markdoc } from '@markdoc/markdoc'; export function defineMarkdocConfig(config: MarkdocConfig): MarkdocConfig { return config; diff --git a/packages/integrations/markdoc/src/index.ts b/packages/integrations/markdoc/src/index.ts index 55d13169b7d0..5b3568992e9b 100644 --- a/packages/integrations/markdoc/src/index.ts +++ b/packages/integrations/markdoc/src/index.ts @@ -1,15 +1,16 @@ +/* eslint-disable no-console */ 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 { fileURLToPath, pathToFileURL } from 'node:url'; 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 { bold, red } from 'kleur/colors'; +import { bold, red, yellow } from 'kleur/colors'; import type * as rollup from 'rollup'; import { applyDefaultConfig } from './default-config.js'; -import { loadMarkdocConfig } from './load-config.js'; +import { loadMarkdocConfig, type MarkdocConfigResult } from './load-config.js'; type SetupHookParams = HookParameters<'astro:config:setup'> & { // `contentEntryType` is not a public API @@ -17,9 +18,8 @@ type SetupHookParams = HookParameters<'astro:config:setup'> & { addContentEntryType: (contentEntryType: ContentEntryType) => void; }; -export default function markdocIntegration(legacyConfig: any): AstroIntegration { +export default function markdocIntegration(legacyConfig?: any): AstroIntegration { if (legacyConfig) { - // eslint-disable-next-line no-console console.log( `${red( bold('[Markdoc]') @@ -27,14 +27,15 @@ export default function markdocIntegration(legacyConfig: any): AstroIntegration ); process.exit(0); } + let markdocConfigResult: MarkdocConfigResult | undefined; return { name: '@astrojs/markdoc', hooks: { 'astro:config:setup': async (params) => { const { config: astroConfig, addContentEntryType } = params as SetupHookParams; - const configLoadResult = await loadMarkdocConfig(astroConfig); - const userMarkdocConfig = configLoadResult?.config ?? {}; + markdocConfigResult = await loadMarkdocConfig(astroConfig); + const userMarkdocConfig = markdocConfigResult?.config ?? {}; function getEntryInfo({ fileUrl, contents }: { fileUrl: URL; contents: string }) { const parsed = parseFrontmatter(contents, fileURLToPath(fileUrl)); @@ -54,17 +55,28 @@ export default function markdocIntegration(legacyConfig: any): AstroIntegration 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'; + return ( + // Ignore `variable-undefined` errors. + // Variables can be configured at runtime, + // so we cannot validate them at build time. + e.error.id !== 'variable-undefined' && + (e.error.level === 'error' || e.error.level === 'critical') + ); }); if (validationErrors.length) { + // Heuristic: take number of newlines for `rawData` and add 2 for the `---` fences + const frontmatterBlockOffset = entry._internal.rawData.split('\n').length + 2; throw new MarkdocError({ message: [ - `**${String(entry.collection)} → ${String(entry.id)}** failed to validate:`, - ...validationErrors.map((e) => e.error.id), + `**${String(entry.collection)} → ${String(entry.id)}** contains invalid content:`, + ...validationErrors.map((e) => `- ${e.error.message}`), ].join('\n'), + location: { + // Error overlay does not support multi-line or ranges. + // Just point to the first line. + line: frontmatterBlockOffset + validationErrors[0].lines[0], + file: viteId, + }, }); } @@ -76,13 +88,15 @@ export default function markdocIntegration(legacyConfig: any): AstroIntegration }); } - const code = { + return { 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)};` + markdocConfigResult + ? `\nimport userConfig from ${JSON.stringify( + markdocConfigResult.fileUrl.pathname + )};` : '' }${ astroConfig.experimental.assets @@ -94,7 +108,7 @@ const stringifiedAst = ${JSON.stringify( )}; export async function Content (props) { const config = applyDefaultConfig(${ - configLoadResult + markdocConfigResult ? '{ ...userConfig, variables: { ...userConfig.variables, ...props } }' : '{ variables: props }' }, { entry });${ @@ -104,7 +118,6 @@ export async function Content (props) { } return h(Renderer, { stringifiedAst, config }); };`, }; - return code; }, contentModuleTypes: await fs.promises.readFile( new URL('../template/content-module-types.d.ts', import.meta.url), @@ -112,6 +125,17 @@ export async function Content (props) { ), }); }, + 'astro:server:setup': async ({ server }) => { + server.watcher.on('all', (event, entry) => { + if (pathToFileURL(entry).pathname === markdocConfigResult?.fileUrl.pathname) { + console.log( + yellow( + `${bold('[Markdoc]')} Restart the dev server for config changes to take effect.` + ) + ); + } + }); + }, }, }; } diff --git a/packages/integrations/markdoc/src/load-config.ts b/packages/integrations/markdoc/src/load-config.ts index af4e0e4aadbe..4a8b2f9cdfa3 100644 --- a/packages/integrations/markdoc/src/load-config.ts +++ b/packages/integrations/markdoc/src/load-config.ts @@ -11,7 +11,14 @@ const SUPPORTED_MARKDOC_CONFIG_FILES = [ 'markdoc.config.ts', ]; -export async function loadMarkdocConfig(astroConfig: Pick) { +export type MarkdocConfigResult = { + config: MarkdocConfig; + fileUrl: URL; +}; + +export async function loadMarkdocConfig( + astroConfig: Pick +): Promise { let markdocConfigUrl: URL | undefined; for (const filename of SUPPORTED_MARKDOC_CONFIG_FILES) { const filePath = new URL(filename, astroConfig.root);