[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
This commit is contained in:
parent
18d0632911
commit
3a9f72c7f3
5 changed files with 62 additions and 27 deletions
5
.changeset/nine-wolves-watch.md
Normal file
5
.changeset/nine-wolves-watch.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@astrojs/markdoc': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve Markdoc validation errors with full message and file preview.
|
|
@ -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
|
#### 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
|
```js
|
||||||
// markdoc.config.mjs
|
// markdoc.config.mjs
|
||||||
import { defineMarkdocConfig } from '@astrojs/markdoc/config';
|
import { defineMarkdocConfig, Markdoc } from '@astrojs/markdoc/config';
|
||||||
import Heading from './src/components/Heading.astro';
|
import Heading from './src/components/Heading.astro';
|
||||||
|
|
||||||
export default defineMarkdocConfig({
|
export default defineMarkdocConfig({
|
||||||
nodes: {
|
nodes: {
|
||||||
heading: {
|
heading: {
|
||||||
render: Heading,
|
render: Heading,
|
||||||
attributes: {
|
attributes: Markdoc.nodes.heading.attributes,
|
||||||
// Pass the attributes from Markdoc's default heading node
|
|
||||||
// as component props.
|
|
||||||
level: { type: String },
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
```md
|
||||||
### I'm a level 3 heading!
|
### I'm a level 3 heading!
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { ConfigType as MarkdocConfig } from '@markdoc/markdoc';
|
import type { ConfigType as MarkdocConfig } from '@markdoc/markdoc';
|
||||||
|
export { default as Markdoc } from '@markdoc/markdoc';
|
||||||
|
|
||||||
export function defineMarkdocConfig(config: MarkdocConfig): MarkdocConfig {
|
export function defineMarkdocConfig(config: MarkdocConfig): MarkdocConfig {
|
||||||
return config;
|
return config;
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
|
/* eslint-disable no-console */
|
||||||
import type { Node } from '@markdoc/markdoc';
|
import type { Node } from '@markdoc/markdoc';
|
||||||
import Markdoc from '@markdoc/markdoc';
|
import Markdoc from '@markdoc/markdoc';
|
||||||
import type { AstroConfig, AstroIntegration, ContentEntryType, HookParameters } from 'astro';
|
import type { AstroConfig, AstroIntegration, ContentEntryType, HookParameters } from 'astro';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import { 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.
|
// @ts-expect-error Cannot find module 'astro/assets' or its corresponding type declarations.
|
||||||
import { emitESMImage } from 'astro/assets';
|
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 type * as rollup from 'rollup';
|
||||||
import { applyDefaultConfig } from './default-config.js';
|
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'> & {
|
type SetupHookParams = HookParameters<'astro:config:setup'> & {
|
||||||
// `contentEntryType` is not a public API
|
// `contentEntryType` is not a public API
|
||||||
|
@ -17,9 +18,8 @@ type SetupHookParams = HookParameters<'astro:config:setup'> & {
|
||||||
addContentEntryType: (contentEntryType: ContentEntryType) => void;
|
addContentEntryType: (contentEntryType: ContentEntryType) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function markdocIntegration(legacyConfig: any): AstroIntegration {
|
export default function markdocIntegration(legacyConfig?: any): AstroIntegration {
|
||||||
if (legacyConfig) {
|
if (legacyConfig) {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(
|
console.log(
|
||||||
`${red(
|
`${red(
|
||||||
bold('[Markdoc]')
|
bold('[Markdoc]')
|
||||||
|
@ -27,14 +27,15 @@ export default function markdocIntegration(legacyConfig: any): AstroIntegration
|
||||||
);
|
);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
let markdocConfigResult: MarkdocConfigResult | undefined;
|
||||||
return {
|
return {
|
||||||
name: '@astrojs/markdoc',
|
name: '@astrojs/markdoc',
|
||||||
hooks: {
|
hooks: {
|
||||||
'astro:config:setup': async (params) => {
|
'astro:config:setup': async (params) => {
|
||||||
const { config: astroConfig, addContentEntryType } = params as SetupHookParams;
|
const { config: astroConfig, addContentEntryType } = params as SetupHookParams;
|
||||||
|
|
||||||
const configLoadResult = await loadMarkdocConfig(astroConfig);
|
markdocConfigResult = await loadMarkdocConfig(astroConfig);
|
||||||
const userMarkdocConfig = configLoadResult?.config ?? {};
|
const userMarkdocConfig = markdocConfigResult?.config ?? {};
|
||||||
|
|
||||||
function getEntryInfo({ fileUrl, contents }: { fileUrl: URL; contents: string }) {
|
function getEntryInfo({ fileUrl, contents }: { fileUrl: URL; contents: string }) {
|
||||||
const parsed = parseFrontmatter(contents, fileURLToPath(fileUrl));
|
const parsed = parseFrontmatter(contents, fileURLToPath(fileUrl));
|
||||||
|
@ -54,17 +55,28 @@ export default function markdocIntegration(legacyConfig: any): AstroIntegration
|
||||||
const markdocConfig = applyDefaultConfig(userMarkdocConfig, { entry });
|
const markdocConfig = applyDefaultConfig(userMarkdocConfig, { entry });
|
||||||
|
|
||||||
const validationErrors = Markdoc.validate(ast, markdocConfig).filter((e) => {
|
const validationErrors = Markdoc.validate(ast, markdocConfig).filter((e) => {
|
||||||
// Ignore `variable-undefined` errors.
|
return (
|
||||||
// Variables can be configured at runtime,
|
// Ignore `variable-undefined` errors.
|
||||||
// so we cannot validate them at build time.
|
// Variables can be configured at runtime,
|
||||||
return e.error.id !== 'variable-undefined';
|
// so we cannot validate them at build time.
|
||||||
|
e.error.id !== 'variable-undefined' &&
|
||||||
|
(e.error.level === 'error' || e.error.level === 'critical')
|
||||||
|
);
|
||||||
});
|
});
|
||||||
if (validationErrors.length) {
|
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({
|
throw new MarkdocError({
|
||||||
message: [
|
message: [
|
||||||
`**${String(entry.collection)} → ${String(entry.id)}** failed to validate:`,
|
`**${String(entry.collection)} → ${String(entry.id)}** contains invalid content:`,
|
||||||
...validationErrors.map((e) => e.error.id),
|
...validationErrors.map((e) => `- ${e.error.message}`),
|
||||||
].join('\n'),
|
].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';
|
code: `import { jsx as h } from 'astro/jsx-runtime';
|
||||||
import { applyDefaultConfig } from '@astrojs/markdoc/default-config';
|
import { applyDefaultConfig } from '@astrojs/markdoc/default-config';
|
||||||
import { Renderer } from '@astrojs/markdoc/components';
|
import { Renderer } from '@astrojs/markdoc/components';
|
||||||
import * as entry from ${JSON.stringify(viteId + '?astroContent')};${
|
import * as entry from ${JSON.stringify(viteId + '?astroContent')};${
|
||||||
configLoadResult
|
markdocConfigResult
|
||||||
? `\nimport userConfig from ${JSON.stringify(configLoadResult.fileUrl.pathname)};`
|
? `\nimport userConfig from ${JSON.stringify(
|
||||||
|
markdocConfigResult.fileUrl.pathname
|
||||||
|
)};`
|
||||||
: ''
|
: ''
|
||||||
}${
|
}${
|
||||||
astroConfig.experimental.assets
|
astroConfig.experimental.assets
|
||||||
|
@ -94,7 +108,7 @@ const stringifiedAst = ${JSON.stringify(
|
||||||
)};
|
)};
|
||||||
export async function Content (props) {
|
export async function Content (props) {
|
||||||
const config = applyDefaultConfig(${
|
const config = applyDefaultConfig(${
|
||||||
configLoadResult
|
markdocConfigResult
|
||||||
? '{ ...userConfig, variables: { ...userConfig.variables, ...props } }'
|
? '{ ...userConfig, variables: { ...userConfig.variables, ...props } }'
|
||||||
: '{ variables: props }'
|
: '{ variables: props }'
|
||||||
}, { entry });${
|
}, { entry });${
|
||||||
|
@ -104,7 +118,6 @@ export async function Content (props) {
|
||||||
}
|
}
|
||||||
return h(Renderer, { stringifiedAst, config }); };`,
|
return h(Renderer, { stringifiedAst, config }); };`,
|
||||||
};
|
};
|
||||||
return code;
|
|
||||||
},
|
},
|
||||||
contentModuleTypes: await fs.promises.readFile(
|
contentModuleTypes: await fs.promises.readFile(
|
||||||
new URL('../template/content-module-types.d.ts', import.meta.url),
|
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.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,14 @@ const SUPPORTED_MARKDOC_CONFIG_FILES = [
|
||||||
'markdoc.config.ts',
|
'markdoc.config.ts',
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function loadMarkdocConfig(astroConfig: Pick<AstroConfig, 'root'>) {
|
export type MarkdocConfigResult = {
|
||||||
|
config: MarkdocConfig;
|
||||||
|
fileUrl: URL;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadMarkdocConfig(
|
||||||
|
astroConfig: Pick<AstroConfig, 'root'>
|
||||||
|
): Promise<MarkdocConfigResult | undefined> {
|
||||||
let markdocConfigUrl: URL | undefined;
|
let markdocConfigUrl: URL | undefined;
|
||||||
for (const filename of SUPPORTED_MARKDOC_CONFIG_FILES) {
|
for (const filename of SUPPORTED_MARKDOC_CONFIG_FILES) {
|
||||||
const filePath = new URL(filename, astroConfig.root);
|
const filePath = new URL(filename, astroConfig.root);
|
||||||
|
|
Loading…
Reference in a new issue