@vahor/rehype-d2
Version:
A Rehype plugin to convert D2 diagrams to SVG or PNG.
305 lines (302 loc) • 9.91 kB
JavaScript
var __create = Object.create;
var __getProtoOf = Object.getPrototypeOf;
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __toESM = (mod, isNodeMode, target) => {
target = mod != null ? __create(__getProtoOf(mod)) : {};
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
for (let key of __getOwnPropNames(mod))
if (!__hasOwnProp.call(to, key))
__defProp(to, key, {
get: () => mod[key],
enumerable: true
});
return to;
};
var __moduleCache = /* @__PURE__ */ new WeakMap;
var __toCommonJS = (from) => {
var entry = __moduleCache.get(from), desc;
if (entry)
return entry;
entry = __defProp({}, "__esModule", { value: true });
if (from && typeof from === "object" || typeof from === "function")
__getOwnPropNames(from).map((key) => !__hasOwnProp.call(entry, key) && __defProp(entry, key, {
get: () => from[key],
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
}));
__moduleCache.set(from, entry);
return entry;
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, {
get: all[name],
enumerable: true,
configurable: true,
set: (newValue) => all[name] = () => newValue
});
};
// src/index.ts
var exports_src = {};
__export(exports_src, {
default: () => src_default,
RehypeD2RendererError: () => RehypeD2RendererError
});
module.exports = __toCommonJS(exports_src);
var import_node_fs = require("node:fs");
var import_d2 = require("@terrastruct/d2");
var import_hast_util_from_html = require("hast-util-from-html");
var import_mini_svg_data_uri = __toESM(require("mini-svg-data-uri"));
var import_svgo = require("svgo");
var import_unist_util_visit_parents = require("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 } = import_svgo.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 = import_node_fs.readdirSync(cwd);
return imports.reduce((acc, importName) => {
if (!importName.endsWith(".d2"))
return acc;
const importPath = `${cwd}/${importName}`;
const importContent = import_node_fs.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 = [];
import_unist_util_visit_parents.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 import_d2.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 = import_hast_util_from_html.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: import_mini_svg_data_uri.default(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;