UNPKG

@vahor/rehype-d2

Version:

A Rehype plugin to convert D2 diagrams to SVG or PNG.

262 lines (260 loc) 8.16 kB
// src/index.ts import { readFileSync, readdirSync } from "node:fs"; import { D2 } from "@terrastruct/d2"; import { fromHtml } from "hast-util-from-html"; import svgToDataURI from "mini-svg-data-uri"; import { optimize } from "svgo"; import { visitParents } from "unist-util-visit-parents"; var strategies = ["inline-svg", "inline-png"]; var svggoConfig = {}; function isValidStrategy(strategy) { return strategies.includes(strategy); } function validateImports(options, fs) { const { globalImports } = options; if (!globalImports) return; for (const [theme, imports] of Object.entries(globalImports)) { if (imports.length === 0) return; const invalidImports = imports.filter((importName) => { if (typeof importName === "string") return fs[importName] === undefined; return fs[importName.filename] === undefined; }); if (invalidImports.length > 0) { const fsKeys = Object.keys(fs); throw new RehypeD2RendererError(`Invalid imports: ${invalidImports.join(", ")} for theme ${theme}, found files: [${fsKeys.join(", ")}]`); } } } function optimizeSvg(svg, config) { const { data } = optimize(svg, config); return data; } function isD2Tag(node, target) { if (node.tagName !== target.tagName) return false; if (Array.isArray(node.properties.className)) { return node.properties.className.includes(target.className); } } function valueContainsImports(value) { const pattern = /^\s*...@\w+(?:\.d2)?\s*$/gm; return pattern.test(value); } function buildImportDirectory(cwd) { if (!cwd) return {}; const imports = readdirSync(cwd); return imports.reduce((acc, importName) => { if (!importName.endsWith(".d2")) return acc; const importPath = `${cwd}/${importName}`; const importContent = readFileSync(importPath, "utf-8"); acc[importName] = importContent; return acc; }, {}); } function buildHeaders(options, theme, fs) { if (!options.globalImports) return ""; if (!options.globalImports[theme]) return ""; const r = options.globalImports[theme].map((importName) => { if (typeof importName === "string") { const withoutSuffix = importName.replace(/\.d2$/, ""); return `...@${withoutSuffix}`; } if (importName.mode === "import") { const withoutSuffix = importName.filename.replace(/\.d2$/, ""); return `...@${withoutSuffix}`; } return fs[importName.filename]; }).filter(Boolean).join(` `); return `${r} `; } function autoCastValue(value) { const valueAsNumber = Number(value); if (!Number.isNaN(valueAsNumber)) { return valueAsNumber; } if (value === "true" || value === "false") { return value === "true"; } return value; } function parseMetadata(node, value) { const metadata = { title: value.trim(), alt: value.trim(), noXMLTag: true, center: true, pad: 0, optimize: true }; const data = node.data; if (data?.meta) { const pattern = /([^=\s]+)=(?:"([^"]*)"|([^\s]*))/g; let match; while (true) { match = pattern.exec(data.meta); if (!match) break; const key = match[1]; const value2 = match[2] !== undefined ? match[2] : match[3]; if (!key || !value2) continue; metadata[key] = autoCastValue(value2); } } if (node.properties) { for (const [key, value2] of Object.entries(node.properties)) { if (Array.isArray(value2)) continue; metadata[key] = autoCastValue(value2); } } if (!Array.isArray(metadata.themes) && typeof metadata.themes === "string") { metadata.themes = metadata.themes.split(","); } return metadata; } function addDefaultMetadata(to, value, theme, defaultMetadata) { if (!defaultMetadata?.[theme]) return; for (const [key, defaultValue] of Object.entries(defaultMetadata[theme])) { if (to[key]) continue; if (typeof defaultValue === "function") { to[key] = defaultValue(value); } else { to[key] = defaultValue; } } } class RehypeD2RendererError extends Error { constructor(message) { super(message); this.name = "RehypeD2RendererError"; } } var rehypeD2 = (options) => { const { strategy = "inline-svg", target = { tagName: "code", className: "language-d2" }, cwd, defaultMetadata, globalImports, defaultThemes = ["default"] } = options; if (!isValidStrategy(strategy)) { throw new RehypeD2RendererError(`Invalid strategy "${strategy}". Valid strategies are: ${strategies.join(", ")}`); } if (globalImports && Object.values(globalImports).some((imports) => imports.length > 0) && !cwd) { throw new RehypeD2RendererError(`To use globalImports, you must provide a "cwd" option (directory to resolve imports from)`); } const fs = buildImportDirectory(cwd); validateImports(options, fs); return async (tree) => { const foundNodes = []; visitParents(tree, "element", (node, ancestors) => { if (!isD2Tag(node, target) || node.children.length === 0) { return; } if (node.children.length !== 1) { throw new RehypeD2RendererError(`Expected exactly one child element for ${node.tagName} elements, but found ${node.children.length}`); } const nodeContent = node.children[0]; if (valueContainsImports(nodeContent.value) && !cwd) { throw new RehypeD2RendererError(`To use imports, you must provide a "cwd" option (directory to resolve imports from)`); } const parent = ancestors.at(-1); foundNodes.push({ node, value: nodeContent.value, ancestor: parent }); }); await Promise.all(foundNodes.map(async ({ node, value, ancestor }) => { const d2 = new D2; const baseMetadata = parseMetadata(node, value); if (!baseMetadata.themes) { baseMetadata.themes = defaultThemes; if (defaultThemes.length === 0) { throw new RehypeD2RendererError("Missing themes in metadata and no defaultThemes found"); } } const metadataThemes = new Set(baseMetadata.themes); const elements = []; for (const theme of metadataThemes) { const headers = buildHeaders(options, theme, fs); const metadata = JSON.parse(JSON.stringify(baseMetadata)); addDefaultMetadata(metadata, value, theme, defaultMetadata); metadata.salt = theme; const codeToProcess = `${headers}${value}`; const render = await d2.compile({ fs: { ...fs, index: codeToProcess }, options: metadata }); const svg = await d2.render(render.diagram, render.renderOptions); if (typeof svg !== "string") { throw new RehypeD2RendererError(`Failed to render svg diagram for ${value}`); } let optimizedSvg = svg; if (metadata.optimize) { optimizedSvg = optimizeSvg(svg, svggoConfig); } const sharedProperties = { height: metadata.height, width: metadata.width, "data-d2-theme": theme, title: metadata.title }; let result; if (strategy === "inline-svg") { const root = fromHtml(optimizedSvg, { fragment: true }); const svgElement = root.children[0]; svgElement.properties = { ...svgElement.properties, ...sharedProperties, role: "img", "aria-label": metadata.alt }; result = svgElement; } else { const img = { type: "element", tagName: "img", properties: { ...sharedProperties, alt: metadata.alt, src: svgToDataURI(optimizedSvg) }, children: [] }; result = img; } elements.push(result); } const children = ancestor.children; const index = children.indexOf(node); children.splice(index, 1, ...elements); })); }; }; var src_default = rehypeD2; export { src_default as default, RehypeD2RendererError };