Files
toom1996 4b409b5a29 update
2026-09-20 00:44:14 +08:00

177 lines
5.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* Render graphviz diagrams from a skill's SKILL.md to SVG files.
*
* Usage:
* ./render-graphs.js <skill-directory> # Render each diagram separately
* ./render-graphs.js <skill-directory> --combine # Combine all into one diagram
*
* Extracts all ```dot blocks from SKILL.md and renders to SVG.
* Useful for helping your human partner visualize the process flows.
*
* Requires: graphviz (dot) installed on system
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
// 注:上游 v6.3.0 把本文件整体改成了 ESM(import ...)。我们**刻意不跟** ——
// 这个脚本会被拷进用户项目,Node 对 .js 的模块判定取决于用户项目最近的
// package.json;Node 22.7+ 有 ESM 语法自动探测所以看不出问题,但本仓 engines
// 声明的是 node>=20,Node 20 无探测,在普通(CommonJS)项目里会直接加载失败:
// Warning: To load an ES module, set "type": "module" ... + SyntaxError
// 实测方式:node --no-experimental-detect-module ./render-graphs.js <dir>
// 上游的另外两处改动(execFileSync 安全加固、用 dot -V 代替 which)已采纳。
function extractDotBlocks(markdown) {
const blocks = [];
const regex = /```dot\n([\s\S]*?)```/g;
let match;
while ((match = regex.exec(markdown)) !== null) {
const content = match[1].trim();
// Extract digraph name
const nameMatch = content.match(/digraph\s+(\w+)/);
const name = nameMatch ? nameMatch[1] : `graph_${blocks.length + 1}`;
blocks.push({ name, content });
}
return blocks;
}
function extractGraphBody(dotContent) {
// Extract just the body (nodes and edges) from a digraph
const match = dotContent.match(/digraph\s+\w+\s*\{([\s\S]*)\}/);
if (!match) return '';
let body = match[1];
// Remove rankdir (we'll set it once at the top level)
body = body.replace(/^\s*rankdir\s*=\s*\w+\s*;?\s*$/gm, '');
return body.trim();
}
function combineGraphs(blocks, skillName) {
const bodies = blocks.map((block, i) => {
const body = extractGraphBody(block.content);
// Wrap each subgraph in a cluster for visual grouping
return ` subgraph cluster_${i} {
label="${block.name}";
${body.split('\n').map(line => ' ' + line).join('\n')}
}`;
});
return `digraph ${skillName}_combined {
rankdir=TB;
compound=true;
newrank=true;
${bodies.join('\n\n')}
}`;
}
function renderToSvg(dotContent) {
try {
return execFileSync('dot', ['-Tsvg'], {
input: dotContent,
encoding: 'utf-8',
maxBuffer: 10 * 1024 * 1024
});
} catch (err) {
console.error('Error running dot:', err.message);
if (err.stderr) console.error(err.stderr.toString());
return null;
}
}
function main() {
const args = process.argv.slice(2);
const combine = args.includes('--combine');
const skillDirArg = args.find(a => !a.startsWith('--'));
if (!skillDirArg) {
console.error('Usage: render-graphs.js <skill-directory> [--combine]');
console.error('');
console.error('Options:');
console.error(' --combine Combine all diagrams into one SVG');
console.error('');
console.error('Example:');
console.error(' ./render-graphs.js ../subagent-driven-development');
console.error(' ./render-graphs.js ../subagent-driven-development --combine');
process.exit(1);
}
const skillDir = path.resolve(skillDirArg);
const skillFile = path.join(skillDir, 'SKILL.md');
const skillName = path.basename(skillDir).replace(/-/g, '_');
if (!fs.existsSync(skillFile)) {
console.error(`Error: ${skillFile} not found`);
process.exit(1);
}
// Check if dot is available. Run the binary directly rather than probing
// with `which`, which is not a command on Windows.
try {
execFileSync('dot', ['-V'], { stdio: 'ignore' });
} catch {
console.error('Error: graphviz (dot) not found. Install with:');
console.error(' brew install graphviz # macOS');
console.error(' apt install graphviz # Linux');
process.exit(1);
}
const markdown = fs.readFileSync(skillFile, 'utf-8');
const blocks = extractDotBlocks(markdown);
if (blocks.length === 0) {
console.log('No ```dot blocks found in', skillFile);
process.exit(0);
}
console.log(`Found ${blocks.length} diagram(s) in ${path.basename(skillDir)}/SKILL.md`);
const outputDir = path.join(skillDir, 'diagrams');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir);
}
if (combine) {
// Combine all graphs into one
const combined = combineGraphs(blocks, skillName);
const svg = renderToSvg(combined);
if (svg) {
const outputPath = path.join(outputDir, `${skillName}_combined.svg`);
fs.writeFileSync(outputPath, svg);
console.log(` Rendered: ${skillName}_combined.svg`);
// Also write the dot source for debugging
const dotPath = path.join(outputDir, `${skillName}_combined.dot`);
fs.writeFileSync(dotPath, combined);
console.log(` Source: ${skillName}_combined.dot`);
} else {
console.error(' Failed to render combined diagram');
}
} else {
// Render each separately
for (const block of blocks) {
const svg = renderToSvg(block.content);
if (svg) {
const outputPath = path.join(outputDir, `${block.name}.svg`);
fs.writeFileSync(outputPath, svg);
console.log(` Rendered: ${block.name}.svg`);
} else {
console.error(` Failed: ${block.name}`);
}
}
}
console.log(`\nOutput: ${outputDir}/`);
}
main();