arkit
Version:
Visualises JavaScript, TypeScript and Flow codebases as meaningful and committable architecture diagrams
290 lines (247 loc) • 9.5 kB
text/typescript
import { ConfigBase, OutputFormat, SavedString } from "./types";
import * as path from "path";
import * as fs from "fs";
import { debug, warn, info } from "./logger";
export type ConverterBackend = "wasm" | "java";
export class Converter {
private readonly config: ConfigBase;
private backend: ConverterBackend | null = null;
constructor(config: ConfigBase) {
this.config = config;
}
private getDotWrapperPath(): string | undefined {
// Check dist/ (runtime) then fall back to src/../dist/ (ts-jest)
const candidates = [
path.join(__dirname, "dot-wasm.js"),
path.join(__dirname, "..", "dist", "dot-wasm.js"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return undefined;
}
// Initialize the appropriate backend
private async initializeBackend(): Promise<ConverterBackend> {
if (this.backend) {
return this.backend;
}
// Try WASM wrapper first (eliminates GraphViz system dependency)
// The dot-wasm.js script uses @hpcc-js/wasm-graphviz as a drop-in dot replacement
const dotWrapperPath = this.getDotWrapperPath();
if (dotWrapperPath) {
try {
fs.chmodSync(dotWrapperPath, 0o755);
} catch {
// Ignore permission errors
}
this.backend = "wasm";
info(
"Using Java PlantUML + @hpcc-js/wasm-graphviz (no GraphViz system dependency)",
);
return "wasm";
}
// Fall back to system GraphViz
try {
await import("node-plantuml");
this.backend = "java";
info(
"Using Java PlantUML + system GraphViz (requires Java + GraphViz installed)",
);
return "java";
} catch (javaError: any) {
throw new Error(
`No diagram renderer available.\n` +
`Java/PlantUML error: ${javaError.message}\n\n` +
`Please install Java Runtime Environment (JRE) 8+:\n` +
` - Windows: https://adoptium.net/\n` +
` - macOS: brew install openjdk\n` +
` - Linux: sudo apt-get install default-jre\n\n` +
`Then either:\n` +
` 1. npm install @hpcc-js/wasm-graphviz (recommended, no GraphViz needed)\n` +
` 2. Install GraphViz: https://graphviz.org/download/`,
);
}
}
async convert(pathOrType: string, puml: string): Promise<SavedString> {
const fullExportPath = path.resolve(this.config.directory, pathOrType);
const ext = path.extname(fullExportPath);
const shouldConvertAndSave = Object.values<any>(OutputFormat).includes(
ext.replace(".", ""),
);
const shouldConvertAndOutput =
Object.values<any>(OutputFormat).includes(pathOrType);
if (fs.existsSync(fullExportPath)) {
debug("Removing", fullExportPath);
fs.unlinkSync(fullExportPath);
}
if (shouldConvertAndSave || shouldConvertAndOutput) {
debug("Converting", ext ? fullExportPath : pathOrType);
// Initialize backend on first conversion
await this.initializeBackend();
return this.convertToImage(puml, ext || pathOrType)
.then((image) => {
if (shouldConvertAndSave) {
debug("Saving", fullExportPath, image.length);
return this.save(fullExportPath, image);
}
return image.toString();
})
.catch((err) => {
throw err;
});
} else {
if (ext === ".puml") {
debug("Saving", fullExportPath);
return this.save(fullExportPath, puml);
}
return Promise.resolve(puml);
}
}
private save(path: string, data: Buffer | string): Promise<SavedString> {
const str = new SavedString(data.toString());
str.path = path;
fs.writeFileSync(path, data);
return Promise.resolve(str);
}
requestChain: Promise<any> = Promise.resolve();
async convertToImage(puml: string, format: string): Promise<Buffer> {
const formatMatch = format.match(/\w{3}/);
if (!formatMatch) {
throw new Error(`Cannot identify image format from ${format}`);
}
const outputFormat = formatMatch[0];
// Validate format is supported
if (outputFormat !== "svg" && outputFormat !== "png") {
throw new Error(
`Unsupported format: ${outputFormat}. Only svg and png are supported.`,
);
}
if (!this.backend) {
throw new Error("Backend not initialized");
}
if (this.backend === "wasm") {
return this.convertWithWasm(puml, outputFormat);
} else {
return this.convertWithSystemGraphviz(puml, outputFormat);
}
}
private async convertWithWasm(puml: string, format: string): Promise<Buffer> {
debug(
`Converting PlantUML to ${format} using Java PlantUML + @hpcc-js/wasm GraphViz`,
);
try {
const plantuml = await import("node-plantuml");
// Use PlantUML to generate the requested format (svg or png)
const output = await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
const gen = plantuml.generate(puml, {
format: format as "svg" | "png",
dot: this.getDotWrapperPath(),
});
gen.out.on("data", (chunk: Buffer) => chunks.push(chunk));
gen.out.on("end", () => {
let result = Buffer.concat(chunks);
// If SVG, check for multiple XML declarations (PlantUML error output)
// and keep only the first complete SVG document
if (format === "svg") {
const svgString = result.toString("utf8");
const closingSvgTag = "</svg>";
const firstSvgEnd = svgString.indexOf(closingSvgTag);
if (firstSvgEnd !== -1) {
const afterFirstSvg = svgString.substring(
firstSvgEnd + closingSvgTag.length,
);
if (afterFirstSvg.includes('<?xml version="1.0"')) {
// Multiple SVG documents found, keep only the first one
result = Buffer.from(
svgString.substring(0, firstSvgEnd + closingSvgTag.length),
"utf8",
);
debug(
`Filtered duplicate PlantUML output (error SVG), kept first ${result.length} bytes (was ${svgString.length})`,
);
}
}
}
resolve(result);
});
gen.out.on("error", reject);
});
debug(
`Successfully generated ${format} using WASM backend, size: ${output.length} bytes`,
);
return output;
} catch (error: any) {
warn(`WASM conversion error: ${error.message}`);
throw new Error(`WASM conversion failed: ${error.message}`);
}
}
private convertWithSystemGraphviz(
puml: string,
format: string,
): Promise<Buffer> {
debug(
`Converting PlantUML to ${format} using Java PlantUML + system GraphViz`,
);
return new Promise((resolve, reject) => {
// Chain requests to avoid concurrent PlantUML execution issues
this.requestChain = this.requestChain.then(async () => {
return new Promise<Buffer>((resolveChain, rejectChain) => {
const plantuml = require("node-plantuml");
const chunks: Buffer[] = [];
const gen = plantuml.generate(puml, { format });
gen.out.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
gen.out.on("end", () => {
const result = Buffer.concat(chunks);
debug(
`Successfully generated ${format} using system GraphViz, size: ${result.length} bytes`,
);
resolveChain(result);
resolve(result);
});
gen.out.on("error", (err: Error) => {
warn(`System GraphViz conversion error: ${err.message}`);
const errorMsg = this.getSystemDependencyErrorMessage(err);
rejectChain(new Error(errorMsg));
reject(new Error(errorMsg));
});
});
});
});
}
private getSystemDependencyErrorMessage(err: Error): string {
const errMsg = err.message || "";
if (errMsg.includes("ENOENT") || errMsg.includes("java")) {
return (
`PlantUML conversion failed: Java is not installed or not in PATH.\n` +
`Please install Java Runtime Environment (JRE) 8 or higher:\n` +
` - Windows: Download from https://adoptium.net/\n` +
` - macOS: brew install openjdk\n` +
` - Linux: sudo apt-get install default-jre\n\n` +
`Install @hpcc-js/wasm-graphviz to eliminate GraphViz system dependency:\n` +
` npm install @hpcc-js/wasm-graphviz\n\n` +
`Or install GraphViz:\n` +
` - Windows: Download from https://graphviz.org/download/\n` +
` - macOS: brew install graphviz\n` +
` - Linux: sudo apt-get install graphviz\n\n` +
`Original error: ${errMsg}`
);
}
if (errMsg.includes("dot") || errMsg.includes("graphviz")) {
return (
`GraphViz not found. Please either:\n` +
` 1. npm install @hpcc-js/wasm-graphviz (recommended, no system dependency)\n` +
` 2. Install GraphViz system-wide:\n` +
` - Windows: https://graphviz.org/download/\n` +
` - macOS: brew install graphviz\n` +
` - Linux: sudo apt-get install graphviz\n\n` +
`Original error: ${errMsg}`
);
}
return `PlantUML conversion failed: ${errMsg}`;
}
}