summaryrefslogtreecommitdiff
path: root/packages/integrations/markdoc/src
diff options
context:
space:
mode:
authorGravatar Ben Holmes <hey@bholmes.dev> 2024-04-03 16:48:53 -0400
committerGravatar GitHub <noreply@github.com> 2024-04-03 16:48:53 -0400
commit90cfade88c2b9a34d8a5fe711ce329732d690409 (patch)
treeb76d0de544c8aebf0a8051aed6cb615af964295f /packages/integrations/markdoc/src
parent8ca8943ce2c10f06c90398f10c583002cd9a6bee (diff)
downloadastro-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.ts202
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);