mirror of
https://github.com/github/awesome-copilot.git
synced 2026-05-01 12:45:56 +00:00
* feat: Add Project Documenter Plugin with agents and skills - Auto-discovers technology stack and project structure - Generates architecture diagrams with draw.io - Creates professional Word (.docx) output with embedded PNG images - Includes agents for orchestration and skills for functionality * chore: moved agents, skills and scripts to respective folders * chore: ran npm run build for readme updates * Update plugins/project-documenter/.github/plugin/plugin.json Co-authored-by: Aaron Powell <me@aaron-powell.com> * fix: readme.agent.md file * fix: added the missing agent file and updated readme for the same --------- Co-authored-by: Aaron Powell <me@aaron-powell.com>
362 lines
12 KiB
JavaScript
362 lines
12 KiB
JavaScript
/**
|
|
* drawio-to-png.mjs - Convert .drawio files to PNG with accurate rendering.
|
|
*
|
|
* Rendering priority:
|
|
* 1. draw.io CLI (if installed) — pixel-perfect, fastest
|
|
* 2. Official draw.io viewer JS in headless browser — pixel-perfect, needs network
|
|
*
|
|
* Usage: node drawio-to-png.mjs <input.drawio> [output.png]
|
|
* node drawio-to-png.mjs --dir <directory> (converts all .drawio files in directory)
|
|
* node drawio-to-png.mjs --renderer=cli|viewer|auto <input.drawio> [output.png]
|
|
*/
|
|
|
|
import { readFileSync, writeFileSync, readdirSync, statSync } from "fs";
|
|
import { join, basename, dirname, resolve } from "path";
|
|
import { spawnSync } from "child_process";
|
|
import { inflateRawSync } from "zlib";
|
|
import puppeteer from "puppeteer-core";
|
|
|
|
// --- Build HTML that uses the official draw.io viewer for rendering ---
|
|
function buildViewerHtml(rawFileContent) {
|
|
// Escape for embedding in a JS template literal
|
|
const escaped = rawFileContent
|
|
.replace(/\\/g, "\\\\")
|
|
.replace(/`/g, "\\`")
|
|
.replace(/\$/g, "\\$");
|
|
|
|
// The official draw.io viewer (viewer-static.min.js) contains the full mxGraph
|
|
// rendering engine — it handles orthogonal edge routing, all shape types,
|
|
// container layouts, and compressed/uncompressed diagram formats.
|
|
return `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<style>
|
|
* { margin: 0; padding: 0; }
|
|
body { background: white; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="diagram-host"></div>
|
|
<script>
|
|
// Prepare diagram XML and set up the viewer target div
|
|
(function() {
|
|
var raw = \`${escaped}\`;
|
|
|
|
// Wrap raw mxGraphModel in mxfile if needed (viewer expects mxfile format)
|
|
var xmlStr = raw.trim();
|
|
if (xmlStr.startsWith('<mxGraphModel')) {
|
|
xmlStr = '<mxfile><diagram name="Page-1">' + xmlStr + '</diagram></mxfile>';
|
|
} else if (!xmlStr.startsWith('<mxfile')) {
|
|
// Assume it's already an mxfile or a diagram element
|
|
if (xmlStr.startsWith('<diagram')) {
|
|
xmlStr = '<mxfile>' + xmlStr + '</mxfile>';
|
|
}
|
|
}
|
|
|
|
var config = {
|
|
xml: xmlStr,
|
|
highlight: "none",
|
|
nav: false,
|
|
resize: true,
|
|
toolbar: null,
|
|
"toolbar-nohide": true,
|
|
edit: null,
|
|
lightbox: false,
|
|
"auto-fit": true,
|
|
"check-visible-state": false
|
|
};
|
|
|
|
var div = document.createElement('div');
|
|
div.className = 'mxgraph';
|
|
div.setAttribute('data-mxgraph', JSON.stringify(config));
|
|
document.getElementById('diagram-host').appendChild(div);
|
|
})();
|
|
|
|
// Poll until the viewer renders the diagram (viewer script loaded separately)
|
|
window.__pollStarted = false;
|
|
window.__startPoll = function() {
|
|
if (window.__pollStarted) return;
|
|
window.__pollStarted = true;
|
|
// Explicitly trigger viewer processing
|
|
if (typeof GraphViewer !== 'undefined' && GraphViewer.processElements) {
|
|
GraphViewer.processElements();
|
|
}
|
|
(function poll() {
|
|
// Viewer places SVG directly inside .mxgraph div
|
|
var mxDiv = document.querySelector('.mxgraph');
|
|
if (mxDiv) {
|
|
var svg = mxDiv.querySelector('svg');
|
|
if (svg) {
|
|
var rect = mxDiv.getBoundingClientRect();
|
|
if (rect.width > 10 && rect.height > 10) {
|
|
window.__renderComplete = true;
|
|
window.__renderWidth = rect.width;
|
|
window.__renderHeight = rect.height;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
setTimeout(poll, 150);
|
|
})();
|
|
};
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
// --- Extract mxGraph XML from .drawio input (supports mxGraphModel and mxfile) ---
|
|
function extractMxGraphModelXml(inputXml) {
|
|
const trimmed = inputXml.trim();
|
|
|
|
if (trimmed.startsWith("<mxGraphModel")) {
|
|
return trimmed;
|
|
}
|
|
|
|
const diagramMatch = trimmed.match(/<diagram\b[^>]*>([\s\S]*?)<\/diagram>/i);
|
|
if (!diagramMatch) {
|
|
throw new Error("Unsupported .drawio format: missing <mxGraphModel> or <diagram> content");
|
|
}
|
|
|
|
const diagramContent = diagramMatch[1].trim();
|
|
|
|
if (diagramContent.startsWith("<mxGraphModel")) {
|
|
return diagramContent;
|
|
}
|
|
|
|
// draw.io compressed diagrams are base64(deflateRaw(encodeURIComponent(xml)))
|
|
try {
|
|
const inflated = inflateRawSync(Buffer.from(diagramContent, "base64")).toString("utf-8");
|
|
const decoded = decodeURIComponent(inflated);
|
|
if (!decoded.trim().startsWith("<mxGraphModel")) {
|
|
throw new Error("decoded content is not mxGraphModel XML");
|
|
}
|
|
return decoded;
|
|
} catch (err) {
|
|
throw new Error(`Failed to decode compressed <diagram> content: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
function resolveRenderer(rawArgs) {
|
|
let renderer = "auto";
|
|
const args = [];
|
|
|
|
for (const arg of rawArgs) {
|
|
if (arg.startsWith("--renderer=")) {
|
|
renderer = arg.substring("--renderer=".length).trim().toLowerCase();
|
|
continue;
|
|
}
|
|
args.push(arg);
|
|
}
|
|
|
|
if (!["auto", "cli", "viewer"].includes(renderer)) {
|
|
throw new Error(`Invalid renderer '${renderer}'. Use auto, cli, or viewer.`);
|
|
}
|
|
|
|
return { renderer, args };
|
|
}
|
|
|
|
function findDrawioCliPath() {
|
|
const envPath = process.env.DRAWIO_PATH;
|
|
if (envPath) {
|
|
try {
|
|
if (statSync(envPath).isFile()) return envPath;
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
const candidates = [
|
|
"C:\\Program Files\\draw.io\\draw.io.exe",
|
|
"C:\\Program Files (x86)\\draw.io\\draw.io.exe",
|
|
"/Applications/draw.io.app/Contents/MacOS/draw.io",
|
|
"/usr/bin/drawio",
|
|
"/usr/local/bin/drawio",
|
|
];
|
|
|
|
for (const p of candidates) {
|
|
try {
|
|
if (statSync(p).isFile()) return p;
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
const locator = process.platform === "win32" ? "where" : "which";
|
|
const names = process.platform === "win32" ? ["drawio", "draw.io"] : ["drawio"];
|
|
|
|
for (const name of names) {
|
|
const probe = spawnSync(locator, [name], { encoding: "utf-8" });
|
|
if (probe.status === 0 && probe.stdout) {
|
|
const first = probe.stdout.split(/\r?\n/).map(line => line.trim()).find(Boolean);
|
|
if (first) return first;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function exportWithDrawioCli(drawioPath, input, output) {
|
|
const args = ["-x", "-f", "png", "-e", "-b", "10", "-o", output, input];
|
|
const result = spawnSync(drawioPath, args, { encoding: "utf-8" });
|
|
if (result.status !== 0) {
|
|
const stderr = (result.stderr || "").trim();
|
|
const stdout = (result.stdout || "").trim();
|
|
throw new Error(stderr || stdout || `draw.io CLI failed with exit code ${result.status}`);
|
|
}
|
|
}
|
|
|
|
// --- Main ---
|
|
async function main() {
|
|
const parsed = resolveRenderer(process.argv.slice(2));
|
|
const renderer = parsed.renderer;
|
|
const args = parsed.args;
|
|
|
|
let files = [];
|
|
if (args[0] === "--dir") {
|
|
const dir = resolve(args[1] || ".");
|
|
files = readdirSync(dir)
|
|
.filter(f => f.endsWith(".drawio"))
|
|
.map(f => ({
|
|
input: join(dir, f),
|
|
output: join(dir, f.replace(/\.drawio$/, ".drawio.png"))
|
|
}));
|
|
} else if (args[0]) {
|
|
const input = resolve(args[0]);
|
|
const output = args[1] || input.replace(/\.drawio$/, ".drawio.png");
|
|
files = [{ input, output }];
|
|
} else {
|
|
console.error("Usage: node drawio-to-png.mjs <input.drawio> [output.png]");
|
|
console.error(" node drawio-to-png.mjs --dir <directory>");
|
|
console.error(" node drawio-to-png.mjs --renderer=cli|auto|custom <input.drawio> [output.png]");
|
|
process.exit(1);
|
|
}
|
|
|
|
if (files.length === 0) {
|
|
console.log("No .drawio files found.");
|
|
return;
|
|
}
|
|
|
|
const drawioCliPath = findDrawioCliPath();
|
|
|
|
// --- Path 1: draw.io CLI (best fidelity, no network needed) ---
|
|
if (renderer === "cli" || (renderer === "auto" && drawioCliPath)) {
|
|
if (!drawioCliPath) {
|
|
console.error("draw.io CLI not found. Install draw.io desktop or set DRAWIO_PATH.");
|
|
process.exit(1);
|
|
}
|
|
console.log(`Using renderer: draw.io CLI (${basename(drawioCliPath)})`);
|
|
for (const { input, output } of files) {
|
|
console.log(`Rendering: ${basename(input)}`);
|
|
try {
|
|
exportWithDrawioCli(drawioCliPath, input, output);
|
|
let kb = "?";
|
|
try {
|
|
kb = (statSync(output).size / 1024).toFixed(0);
|
|
} catch { /* ignore size read errors */ }
|
|
console.log(` -> ${basename(output)} (${kb} KB)`);
|
|
} catch (err) {
|
|
console.error(` Error rendering ${basename(input)}: ${err.message}`);
|
|
}
|
|
}
|
|
console.log("Done.");
|
|
return;
|
|
}
|
|
|
|
// --- Path 2: Official draw.io viewer in headless browser ---
|
|
// Find browser
|
|
const browserPaths = [
|
|
process.env.CHROME_PATH,
|
|
process.env.EDGE_PATH,
|
|
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
"/usr/bin/google-chrome",
|
|
"/usr/bin/chromium-browser",
|
|
"/usr/bin/microsoft-edge",
|
|
].filter(Boolean);
|
|
|
|
let execPath;
|
|
for (const p of browserPaths) {
|
|
try {
|
|
if (statSync(p).isFile()) { execPath = p; break; }
|
|
} catch { /* not found */ }
|
|
}
|
|
|
|
if (!execPath) {
|
|
console.error("No browser found. Set CHROME_PATH or EDGE_PATH environment variable.");
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`Using renderer: draw.io viewer (${basename(execPath)})`);
|
|
|
|
const browser = await puppeteer.launch({
|
|
executablePath: execPath,
|
|
headless: true,
|
|
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"],
|
|
});
|
|
|
|
for (const { input, output } of files) {
|
|
console.log(`Rendering: ${basename(input)}`);
|
|
try {
|
|
const rawContent = readFileSync(input, "utf-8");
|
|
const html = buildViewerHtml(rawContent);
|
|
|
|
const page = await browser.newPage();
|
|
await page.setViewport({ width: 2400, height: 1600, deviceScaleFactor: 2 });
|
|
|
|
// Set HTML content first (sets up the .mxgraph div with diagram XML)
|
|
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
|
|
// Load the official draw.io viewer JS via addScriptTag (more reliable than inline src)
|
|
const VIEWER_URL = "https://viewer.diagrams.net/js/viewer-static.min.js";
|
|
try {
|
|
await page.addScriptTag({ url: VIEWER_URL });
|
|
} catch (scriptErr) {
|
|
throw new Error(`Failed to load draw.io viewer JS: ${scriptErr.message}`);
|
|
}
|
|
|
|
// Start polling for the rendered diagram
|
|
await page.evaluate(() => window.__startPoll());
|
|
|
|
// Wait for the viewer to finish rendering
|
|
await page.waitForFunction(() => window.__renderComplete === true, { timeout: 30000 });
|
|
|
|
// Check rendering succeeded
|
|
const viewerOk = await page.evaluate(() => window.__renderWidth > 0);
|
|
if (!viewerOk) {
|
|
throw new Error("draw.io viewer failed to load or render (check network access)");
|
|
}
|
|
|
|
// Take element screenshot of just the diagram div for exact bounds
|
|
const containerHandle = await page.$('.mxgraph');
|
|
let pngBuffer;
|
|
|
|
if (containerHandle) {
|
|
pngBuffer = await containerHandle.screenshot({ type: "png" });
|
|
} else {
|
|
// Fallback: full-page screenshot
|
|
const dims = await page.evaluate(() => ({
|
|
w: Math.ceil(window.__renderWidth),
|
|
h: Math.ceil(window.__renderHeight)
|
|
}));
|
|
pngBuffer = await page.screenshot({
|
|
type: "png",
|
|
clip: { x: 0, y: 0, width: dims.w + 20, height: dims.h + 20 },
|
|
});
|
|
}
|
|
|
|
writeFileSync(output, pngBuffer);
|
|
console.log(` -> ${basename(output)} (${(pngBuffer.length / 1024).toFixed(0)} KB)`);
|
|
|
|
await page.close();
|
|
} catch (err) {
|
|
console.error(` Error rendering ${basename(input)}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
await browser.close();
|
|
console.log("Done.");
|
|
}
|
|
|
|
main().catch(err => { console.error(err); process.exit(1); });
|