diff options
author | 2024-04-03 16:48:53 -0400 | |
---|---|---|
committer | 2024-04-03 16:48:53 -0400 | |
commit | 90cfade88c2b9a34d8a5fe711ce329732d690409 (patch) | |
tree | b76d0de544c8aebf0a8051aed6cb615af964295f /packages/integrations/markdoc/src | |
parent | 8ca8943ce2c10f06c90398f10c583002cd9a6bee (diff) | |
download | astro-90cfade88c2b9a34d8a5fe711ce329732d690409.tar.gz astro-90cfade88c2b9a34d8a5fe711ce329732d690409.tar.zst astro-90cfade88c2b9a34d8a5fe711ce329732d690409.zip |
feat: automatic Markdoc partial resolution (#10649)
* wip: react counter example
* feat: resolve markdoc partials by file path
* test: components within partials
* test: html within partial
* chore: changeset
* fix: respect user configured partials
* test: basic partials
* chore: lock
* chore: fix lock
* chore: minor -> patch
* fix: use --parallel for dev server timeout error
* refactor: move component tests to separate file
* fix: build indent fixture
* fix: check before addWatchFile
* refactor: rootRelative -> relativePartial
* deps: use workspace react integration
* refactor: split test files by fixture
* refactor: switch to preact to avoid react prod build error
* feat: use vite pluginContext
* fix: handle missing ./
* chore: bump timeout
Diffstat (limited to 'packages/integrations/markdoc/src')
-rw-r--r-- | packages/integrations/markdoc/src/content-entry-type.ts | 202 |
1 files changed, 164 insertions, 38 deletions
diff --git a/packages/integrations/markdoc/src/content-entry-type.ts b/packages/integrations/markdoc/src/content-entry-type.ts index 89f9f9e86..5168c49c1 100644 --- a/packages/integrations/markdoc/src/content-entry-type.ts +++ b/packages/integrations/markdoc/src/content-entry-type.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import type { Config as MarkdocConfig, Node } from '@markdoc/markdoc'; import Markdoc from '@markdoc/markdoc'; import type { AstroConfig, ContentEntryType } from 'astro'; @@ -38,9 +38,41 @@ export async function getContentEntryType({ } const ast = Markdoc.parse(tokens); - const usedTags = getUsedTags(ast); const userMarkdocConfig = markdocConfigResult?.config ?? {}; const markdocConfigUrl = markdocConfigResult?.fileUrl; + const pluginContext = this; + const markdocConfig = await setupConfig(userMarkdocConfig, options); + const filePath = fileURLToPath(fileUrl); + raiseValidationErrors({ + ast, + /* Raised generics issue with Markdoc core https://github.com/markdoc/markdoc/discussions/400 */ + markdocConfig: markdocConfig as MarkdocConfig, + entry, + viteId, + astroConfig, + filePath, + }); + await resolvePartials({ + ast, + markdocConfig: markdocConfig as MarkdocConfig, + fileUrl, + allowHTML: options?.allowHTML, + tokenizer, + pluginContext, + root: astroConfig.root, + raisePartialValidationErrors: (partialAst, partialPath) => { + raiseValidationErrors({ + ast: partialAst, + markdocConfig: markdocConfig as MarkdocConfig, + entry, + viteId, + astroConfig, + filePath: partialPath, + }); + }, + }); + + const usedTags = getUsedTags(ast); let componentConfigByTagMap: Record<string, ComponentConfig> = {}; // Only include component imports for tags used in the document. @@ -59,42 +91,6 @@ export async function getContentEntryType({ } } - const pluginContext = this; - const markdocConfig = await setupConfig(userMarkdocConfig, options); - - const filePath = fileURLToPath(fileUrl); - - const validationErrors = Markdoc.validate( - ast, - /* Raised generics issue with Markdoc core https://github.com/markdoc/markdoc/discussions/400 */ - markdocConfig as MarkdocConfig - ).filter((e) => { - 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.rawData.split('\n').length + 2; - const rootRelativePath = path.relative(fileURLToPath(astroConfig.root), filePath); - throw new MarkdocError({ - message: [ - `**${String(rootRelativePath)}** 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, - }, - }); - } - await emitOptimizedImages(ast.children, { astroConfig, pluginContext, @@ -142,6 +138,136 @@ export const Content = createContentComponent( }; } +/** + * Recursively resolve partial tags to their content. + * Note: Mutates the `ast` object directly. + */ +async function resolvePartials({ + ast, + fileUrl, + root, + tokenizer, + allowHTML, + markdocConfig, + pluginContext, + raisePartialValidationErrors, +}: { + ast: Node; + fileUrl: URL; + root: URL; + tokenizer: any; + allowHTML?: boolean; + markdocConfig: MarkdocConfig; + pluginContext: Rollup.PluginContext; + raisePartialValidationErrors: (ast: Node, filePath: string) => void; +}) { + const relativePartialPath = path.relative(fileURLToPath(root), fileURLToPath(fileUrl)); + for (const node of ast.walk()) { + if (node.type === 'tag' && node.tag === 'partial') { + const { file } = node.attributes; + if (!file) { + throw new MarkdocError({ + // Should be caught by Markdoc validation step. + message: `(Uncaught error) Partial tag requires a 'file' attribute`, + }); + } + + if (markdocConfig.partials?.[file]) continue; + + let partialPath: string; + let partialContents: string; + try { + const resolved = await pluginContext.resolve(file, fileURLToPath(fileUrl)); + let partialId = resolved?.id; + if (!partialId) { + const attemptResolveAsRelative = await pluginContext.resolve( + './' + file, + fileURLToPath(fileUrl) + ); + if (!attemptResolveAsRelative?.id) throw new Error(); + partialId = attemptResolveAsRelative.id; + } + + partialPath = fileURLToPath(new URL(prependForwardSlash(partialId), 'file://')); + partialContents = await fs.promises.readFile(partialPath, 'utf-8'); + } catch { + throw new MarkdocError({ + message: [ + `**${String(relativePartialPath)}** contains invalid content:`, + `Could not read partial file \`${file}\`. Does the file exist?`, + ].join('\n'), + }); + } + if (pluginContext.meta.watchMode) pluginContext.addWatchFile(partialPath); + let partialTokens = tokenizer.tokenize(partialContents); + if (allowHTML) { + partialTokens = htmlTokenTransform(tokenizer, partialTokens); + } + const partialAst = Markdoc.parse(partialTokens); + raisePartialValidationErrors(partialAst, partialPath); + await resolvePartials({ + ast: partialAst, + root, + fileUrl: pathToFileURL(partialPath), + tokenizer, + allowHTML, + markdocConfig, + pluginContext, + raisePartialValidationErrors, + }); + + Object.assign(node, partialAst); + } + } +} + +function raiseValidationErrors({ + ast, + markdocConfig, + entry, + viteId, + astroConfig, + filePath, +}: { + ast: Node; + markdocConfig: MarkdocConfig; + entry: ReturnType<typeof getEntryInfo>; + viteId: string; + astroConfig: AstroConfig; + filePath: string; +}) { + const validationErrors = Markdoc.validate(ast, markdocConfig).filter((e) => { + return ( + (e.error.level === 'error' || e.error.level === 'critical') && + // Ignore `variable-undefined` errors. + // Variables can be configured at runtime, + // so we cannot validate them at build time. + e.error.id !== 'variable-undefined' && + // Ignore missing partial errors. + // We will resolve these in `resolvePartials`. + !(e.error.id === 'attribute-value-invalid' && e.error.message.match(/^Partial .+ not found/)) + ); + }); + + if (validationErrors.length) { + // Heuristic: take number of newlines for `rawData` and add 2 for the `---` fences + const frontmatterBlockOffset = entry.rawData.split('\n').length + 2; + const rootRelativePath = path.relative(fileURLToPath(astroConfig.root), filePath); + throw new MarkdocError({ + message: [ + `**${String(rootRelativePath)}** 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, + }, + }); + } +} + function getUsedTags(markdocAst: Node) { const tags = new Set<string>(); const validationErrors = Markdoc.validate(markdocAst); |