@astrojs/markdoc
Version:
Add support for Markdoc in your Astro site
318 lines (311 loc) • 11.5 kB
JavaScript
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { parseFrontmatter } from "@astrojs/markdown-remark";
import Markdoc from "@markdoc/markdoc";
import { emitESMImage } from "astro/assets/utils";
import { htmlTokenTransform } from "./html/transform/html-token-transform.js";
import { setupConfig } from "./runtime.js";
import { getMarkdocTokenizer } from "./tokenizer.js";
import { isComponentConfig, isValidUrl, MarkdocError, prependForwardSlash } from "./utils.js";
async function getContentEntryType({
markdocConfigResult,
astroConfig,
options
}) {
return {
extensions: [".mdoc"],
getEntryInfo({ fileUrl, contents }) {
const parsed = safeParseFrontmatter(contents, fileURLToPath(fileUrl));
return {
data: parsed.frontmatter,
body: parsed.content.trim(),
slug: parsed.frontmatter.slug,
rawData: parsed.rawFrontmatter
};
},
handlePropagation: true,
async getRenderModule({ contents, fileUrl, viteId }) {
const parsed = safeParseFrontmatter(contents, fileURLToPath(fileUrl));
const tokenizer = getMarkdocTokenizer(options);
let tokens = tokenizer.tokenize(parsed.content);
if (options?.allowHTML) {
tokens = htmlTokenTransform(tokenizer, tokens);
}
const ast = Markdoc.parse(tokens);
const userMarkdocConfig = markdocConfigResult?.config ?? {};
const markdocConfigUrl = markdocConfigResult?.fileUrl;
const pluginContext = this;
const markdocConfig = await setupConfig(
userMarkdocConfig,
options,
astroConfig.experimental.headingIdCompat
);
const filePath = fileURLToPath(fileUrl);
raiseValidationErrors({
ast,
/* Raised generics issue with Markdoc core https://github.com/markdoc/markdoc/discussions/400 */
markdocConfig,
viteId,
astroConfig,
filePath
});
await resolvePartials({
ast,
markdocConfig,
fileUrl,
allowHTML: options?.allowHTML,
tokenizer,
pluginContext,
root: astroConfig.root,
raisePartialValidationErrors: (partialAst, partialPath) => {
raiseValidationErrors({
ast: partialAst,
markdocConfig,
viteId,
astroConfig,
filePath: partialPath
});
}
});
const usedTags = getUsedTags(ast);
let componentConfigByTagMap = {};
for (const tag of usedTags) {
const render = markdocConfig.tags?.[tag]?.render;
if (isComponentConfig(render)) {
componentConfigByTagMap[tag] = render;
}
}
let componentConfigByNodeMap = {};
for (const [nodeType, schema] of Object.entries(markdocConfig.nodes ?? {})) {
const render = schema?.render;
if (isComponentConfig(render)) {
componentConfigByNodeMap[nodeType] = render;
}
}
await emitOptimizedImages(ast.children, {
hasDefaultImage: Boolean(markdocConfig.nodes.image),
astroConfig,
pluginContext,
filePath
});
const res = `import { Renderer } from '@astrojs/markdoc/components';
import { createGetHeadings, createContentComponent } from '@astrojs/markdoc/runtime';
${markdocConfigUrl ? `import markdocConfig from ${JSON.stringify(fileURLToPath(markdocConfigUrl))};` : "const markdocConfig = {};"}
import { assetsConfig } from '@astrojs/markdoc/runtime-assets-config';
markdocConfig.nodes = { ...assetsConfig.nodes, ...markdocConfig.nodes };
${getStringifiedImports(componentConfigByTagMap, "Tag", astroConfig.root)}
${getStringifiedImports(componentConfigByNodeMap, "Node", astroConfig.root)}
const experimentalHeadingIdCompat = ${JSON.stringify(astroConfig.experimental.headingIdCompat || false)}
const tagComponentMap = ${getStringifiedMap(componentConfigByTagMap, "Tag")};
const nodeComponentMap = ${getStringifiedMap(componentConfigByNodeMap, "Node")};
const options = ${JSON.stringify(options)};
const stringifiedAst = ${JSON.stringify(
/* Double stringify to encode *as* stringified JSON */
JSON.stringify(ast)
)};
export const getHeadings = createGetHeadings(stringifiedAst, markdocConfig, options, experimentalHeadingIdCompat);
export const Content = createContentComponent(
Renderer,
stringifiedAst,
markdocConfig,
options,
tagComponentMap,
nodeComponentMap,
experimentalHeadingIdCompat,
)`;
return { code: res };
},
contentModuleTypes: await fs.promises.readFile(
new URL("../template/content-module-types.d.ts", import.meta.url),
"utf-8"
)
};
}
async function resolvePartials({
ast,
fileUrl,
root,
tokenizer,
allowHTML,
markdocConfig,
pluginContext,
raisePartialValidationErrors
}) {
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;
let partialContents;
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,
viteId,
astroConfig,
filePath
}) {
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" && /^Partial .+ not found/.test(e.error.message));
});
if (validationErrors.length) {
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: validationErrors[0].lines[0],
file: viteId
}
});
}
}
function getUsedTags(markdocAst) {
const tags = /* @__PURE__ */ new Set();
const validationErrors = Markdoc.validate(markdocAst);
for (const { error } of validationErrors) {
if (error.id === "tag-undefined") {
const [, tagName] = /Undefined tag: '(.*)'/.exec(error.message) ?? [];
tags.add(tagName);
}
}
return tags;
}
async function emitOptimizedImages(nodeChildren, ctx) {
for (const node of nodeChildren) {
let isComponent = node.type === "tag" && node.tag === "image" || node.type === "image" && ctx.hasDefaultImage;
if ((node.type === "image" || isComponent) && typeof node.attributes.src === "string") {
let attributeName = isComponent ? "src" : "__optimizedSrc";
if (shouldOptimizeImage(node.attributes.src)) {
const resolved = await ctx.pluginContext.resolve(node.attributes.src, ctx.filePath);
if (resolved?.id && fs.existsSync(new URL(prependForwardSlash(resolved.id), "file://"))) {
const src = await emitESMImage(
resolved.id,
ctx.pluginContext.meta.watchMode,
// FUTURE: Remove in this in v6
resolved.id.endsWith(".svg"),
ctx.pluginContext.emitFile
);
const fsPath = resolved.id;
if (src) {
if (ctx.astroConfig.output === "static") {
if (globalThis.astroAsset.referencedImages)
globalThis.astroAsset.referencedImages.add(fsPath);
}
node.attributes[attributeName] = { ...src, fsPath };
}
} else {
throw new MarkdocError({
message: `Could not resolve image ${JSON.stringify(
node.attributes.src
)} from ${JSON.stringify(ctx.filePath)}. Does the file exist?`
});
}
} else if (isComponent) {
node.attributes[attributeName] = node.attributes.src;
}
}
await emitOptimizedImages(node.children, ctx);
}
}
function shouldOptimizeImage(src) {
return !isValidUrl(src) && !src.startsWith("/");
}
function getStringifiedImports(componentConfigMap, componentNamePrefix, root) {
let stringifiedComponentImports = "";
for (const [key, config] of Object.entries(componentConfigMap)) {
const importName = config.namedExport ? `{ ${config.namedExport} as ${componentNamePrefix + toImportName(key)} }` : componentNamePrefix + toImportName(key);
const resolvedPath = config.type === "local" ? fileURLToPath(new URL(config.path, root)) : config.path;
stringifiedComponentImports += `import ${importName} from ${JSON.stringify(resolvedPath)};
`;
}
return stringifiedComponentImports;
}
function toImportName(unsafeName) {
return unsafeName.replace("-", "_");
}
function getStringifiedMap(componentConfigMap, componentNamePrefix) {
let stringifiedComponentMap = "{";
for (const key in componentConfigMap) {
stringifiedComponentMap += `${JSON.stringify(key)}: ${componentNamePrefix + toImportName(key)},
`;
}
stringifiedComponentMap += "}";
return stringifiedComponentMap;
}
function safeParseFrontmatter(fileContents, filePath) {
try {
return parseFrontmatter(fileContents, { frontmatter: "empty-with-lines" });
} catch (e) {
if (e.name === "YAMLException") {
const err = e;
err.id = filePath;
err.loc = { file: e.id, line: e.mark.line + 1, column: e.mark.column };
err.message = e.reason;
throw err;
} else {
throw e;
}
}
}
export {
getContentEntryType
};