feat: Add Project Documenter Plugin with agents and skills (#1436)

* 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>
This commit is contained in:
parveen-dotnet
2026-05-01 05:59:26 +05:30
committed by GitHub
parent 431417044f
commit acdae521d2
13 changed files with 1452 additions and 0 deletions

96
skills/drawio/SKILL.md Normal file
View File

@@ -0,0 +1,96 @@
---
name: drawio
description: Generate draw.io diagrams as .drawio files and export to PNG/SVG/PDF with embedded XML
---
# Draw.io Diagram Skill
Generate draw.io diagrams as native `.drawio` files and export them to PNG images that can be embedded in Word documents.
## How to Create a Diagram
1. **Generate draw.io XML** in `mxGraphModel` format for the requested diagram
2. **Write the XML** to a `.drawio` file using the create/edit file tool
3. **Export to PNG** using the bundled export script
## Bundled Export Script
This skill includes `drawio-to-png.mjs`, a Node.js export script with two rendering backends:
1. **draw.io CLI** (pixel-perfect, fastest) — used automatically if draw.io desktop is installed
2. **Official draw.io viewer in headless browser** (pixel-perfect, needs Chromium/Edge) — fallback when CLI is unavailable
### Usage
```bash
# Install dependencies (one-time, from the scripts folder)
cd skills/drawio/scripts && npm install
# Export a single diagram
node skills/drawio/scripts/drawio-to-png.mjs <input.drawio> [output.png]
# Export all .drawio files in a directory
node skills/drawio/scripts/drawio-to-png.mjs --dir <directory>
# Force a specific renderer
node skills/drawio/scripts/drawio-to-png.mjs --renderer=cli|viewer|auto <input.drawio>
```
### Skill Folder Contents
| File | Purpose |
|------|---------|
| `SKILL.md` | This instruction file |
| `scripts/drawio-to-png.mjs` | Node.js export script (CLI + browser fallback) |
| `scripts/package.json` | Dependencies (`puppeteer-core`) |
## Supported Export Formats
| Format | Embed XML | Notes |
|--------|-----------|-------|
| `png` | Yes | Viewable everywhere, editable in draw.io |
| `svg` | Yes | Scalable, editable in draw.io |
| `pdf` | Yes | Printable, editable in draw.io |
## Draw.io XML Style Conventions
Use these styles for consistent, professional diagrams:
```xml
<!-- Primary service (highlighted) -->
<mxCell style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;strokeWidth=2;arcSize=12;shadow=1;" />
<!-- External system -->
<mxCell style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" />
<!-- Success/processing stage -->
<mxCell style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" />
<!-- Warning/quality gate -->
<mxCell style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" />
<!-- Error/failure path -->
<mxCell style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" />
<!-- Data store (cylinder) -->
<mxCell style="shape=cylinder3;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" />
<!-- Arrow -->
<mxCell style="edgeStyle=orthogonalEdgeStyle;rounded=1;strokeColor=#6c8ebf;strokeWidth=2;" />
```
## Locating the draw.io CLI
Try `drawio` first (works if on PATH), then fall back:
- **Windows**: `"C:\Program Files\draw.io\draw.io.exe"`
- **macOS**: `/Applications/draw.io.app/Contents/MacOS/draw.io`
- **Linux**: `drawio` (via snap/apt/flatpak)
### CLI Export Command
```bash
drawio -x -f png -e -b 10 -o <output.png> <input.drawio>
```
Flags: `-x` (export), `-f` (format), `-e` (embed diagram XML), `-b` (border), `-o` (output path).

View File

@@ -0,0 +1,361 @@
/**
* 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); });

View File

@@ -0,0 +1,8 @@
{
"private": true,
"type": "module",
"description": "Dependencies for the draw.io diagram export skill",
"dependencies": {
"puppeteer-core": "^24.39.1"
}
}

View File

@@ -0,0 +1,74 @@
---
name: md-to-docx
description: Convert Markdown files to professionally formatted Word (.docx) documents with embedded PNG images — pure JavaScript, no external tools required
---
# Markdown to Word (.docx) Skill
Convert Markdown (`.md`) files into professionally formatted Word (`.docx`) documents with embedded PNG images. Uses **pure JavaScript** via the `docx` and `marked` npm packages — no Pandoc, LibreOffice, or any native binary required.
## How to Convert
```bash
# Install dependencies (one-time, from the scripts folder)
cd skills/md-to-docx/scripts && npm install
# Convert (run from workspace root)
node skills/md-to-docx/scripts/md-to-docx.mjs <input.md> [output.docx]
```
If `output.docx` is omitted, it defaults to `<input-basename>.docx` in the current directory.
## Skill Folder Contents
| File | Purpose |
|------|---------|
| `SKILL.md` | This instruction file |
| `scripts/md-to-docx.mjs` | Node.js Markdown-to-Word converter |
| `scripts/package.json` | Dependencies (`docx`, `marked`) |
## Prerequisites
| Requirement | Version | Notes |
|-------------|---------|-------|
| **Node.js** | 18+ | Required runtime |
| **`docx`** | 9+ | Pure JS Word document generator |
| **`marked`** | 15+ | Markdown parser |
No native binaries. No system-level installs. Works on Windows, macOS, and Linux.
## Features
The converter:
- **Extracts YAML front-matter** — uses `title`, `date`, `version`, `audience` for the title page
- **Generates a title page** — with project name, subtitle, date, version, and audience
- **Generates a table of contents** — built from H1-H3 headings
- **Embeds PNG images** — resolves `![alt](path)` references relative to the input `.md` file, reads the PNG, and embeds it inline in the Word document
- **Styled output** — Calibri font, colored headings (`#1F3864`), styled tables with alternating row colors, code blocks in Consolas
- **Handles all Markdown elements** — headings, paragraphs, tables, code blocks, lists, images, links, horizontal rules
## Image Embedding
The converter automatically embeds PNG images referenced in the Markdown:
```markdown
![High-Level Architecture](diagrams/high-level-architecture.drawio.png)
```
The image path is resolved **relative to the input Markdown file**. The PNG is read, dimensions are extracted from the PNG header, and the image is scaled to fit within 6 inches width while preserving aspect ratio.
If an image file is not found, a placeholder `[Image not found: <path>]` is inserted.
## Front-Matter Format
```yaml
---
title: Project Name — Project Summary
date: 2025-01-15
version: 1.0
audience: Engineering Team, Architects, Stakeholders
---
```
The title is split on `—` or `` into main title and subtitle for the title page.

View File

@@ -0,0 +1,440 @@
/**
* md-to-docx.mjs - Markdown to Word converter
* Pure JavaScript, no external tools required.
* Usage: node md-to-docx.mjs <input.md> [output.docx]
*/
import { readFileSync, writeFileSync, existsSync } from "fs";
import { dirname, join, resolve } from "path";
import { marked } from "marked";
import {
Document, Packer, Paragraph, TextRun, HeadingLevel, ImageRun,
TableRow, TableCell, Table, WidthType, BorderStyle,
AlignmentType, ShadingType, PageBreak
} from "docx";
// --- Image dimensions from PNG header ---
function pngDimensions(buffer) {
// PNG signature check + IHDR chunk at offset 16 (width) and 20 (height)
if (buffer[0] === 0x89 && buffer[1] === 0x50) {
return {
width: buffer.readUInt32BE(16),
height: buffer.readUInt32BE(20),
};
}
return { width: 600, height: 400 }; // fallback
}
// --- CLI argument parsing ---
const inputPath = process.argv[2];
if (!inputPath) {
console.error("Usage: node md-to-docx.mjs <input.md> [output.docx]");
process.exit(1);
}
const outputPath = process.argv[3] || inputPath.replace(/\.md$/i, ".docx");
const inputDir = dirname(resolve(inputPath));
const mdSource = readFileSync(inputPath, "utf-8");
// --- Extract YAML front-matter metadata ---
let title = "Document";
let subtitle = "";
let date = new Date().toISOString().slice(0, 10);
let version = "1.0";
let audience = "";
const fmMatch = mdSource.match(/^---\n([\s\S]*?)\n---/m);
if (fmMatch) {
const fm = fmMatch[1];
title = fm.match(/^title:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, "") || title;
date = fm.match(/^date:\s*(.+)$/m)?.[1]?.trim() || date;
version = fm.match(/^version:\s*(.+)$/m)?.[1]?.trim() || version;
audience = fm.match(/^audience:\s*(.+)$/m)?.[1]?.trim() || "";
}
// Strip front-matter from markdown content
const md = mdSource.replace(/^---[\s\S]*?---\n*/m, "");
// Derive title / subtitle from front-matter title or first H1
const titleParts = title.split(/\s*[—–]\s*/);
const mainTitle = titleParts[0] || title;
subtitle = titleParts[1] || "";
if (!subtitle) {
const h1Match = md.match(/^#\s+(.+)$/m);
if (h1Match) {
const h1Parts = h1Match[1].split(/\s*[—–]\s*/);
if (h1Parts.length > 1) {
subtitle = h1Parts[1];
if (!mainTitle || mainTitle === "Document") title = h1Parts[0];
}
}
}
// --- Parse Markdown tokens ---
const tokens = marked.lexer(md);
// --- Style constants ---
const FONT = "Calibri";
const HEADER_COLOR = "1F3864";
const ACCENT_COLOR = "2E75B6";
const TABLE_HEADER_BG = "D6E4F0";
const TABLE_ALT_BG = "F2F7FB";
const CODE_BG = "F5F5F5";
const CODE_FONT = "Consolas";
const BORDER_COLOR = "B4C6E7";
const tableBorder = { style: BorderStyle.SINGLE, size: 1, color: BORDER_COLOR };
const tableBorders = {
top: tableBorder, bottom: tableBorder,
left: tableBorder, right: tableBorder,
insideHorizontal: tableBorder, insideVertical: tableBorder,
};
// --- Utility: decode HTML entities ---
function decodeEntities(str) {
return str
.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
.replace(/&quot;/g, '"').replace(/&#39;/g, "'");
}
// --- Inline tokens to TextRun[] ---
function inlineToRuns(inlineTokens, parentBold = false, parentItalic = false) {
const runs = [];
if (!inlineTokens) return runs;
for (const t of inlineTokens) {
switch (t.type) {
case "text":
runs.push(new TextRun({
text: decodeEntities(t.text || t.raw || ""),
bold: parentBold, italics: parentItalic, font: FONT, size: 22,
}));
break;
case "strong":
runs.push(...inlineToRuns(t.tokens, true, parentItalic));
break;
case "em":
runs.push(...inlineToRuns(t.tokens, parentBold, true));
break;
case "codespan":
runs.push(new TextRun({
text: t.text, font: CODE_FONT, size: 20, bold: parentBold,
shading: { type: ShadingType.SOLID, color: CODE_BG, fill: CODE_BG },
}));
break;
case "link":
runs.push(new TextRun({
text: t.text || t.href, bold: parentBold, italics: parentItalic,
font: FONT, size: 22, color: ACCENT_COLOR, underline: {},
}));
break;
case "image":
// Images handled at paragraph level; skip inline
break;
case "br":
runs.push(new TextRun({ break: 1, font: FONT }));
break;
default:
if (t.raw) {
runs.push(new TextRun({
text: decodeEntities(t.raw), bold: parentBold, italics: parentItalic,
font: FONT, size: 22,
}));
}
break;
}
}
return runs;
}
// --- Paragraph inline runs ---
function paragraphRuns(token) {
if (token.tokens) return inlineToRuns(token.tokens);
return [new TextRun({ text: token.text || token.raw || "", font: FONT, size: 22 })];
}
// --- Table builder ---
function buildTable(token) {
const rows = [];
if (token.header) {
rows.push(new TableRow({
tableHeader: true,
children: token.header.map(cell => new TableCell({
shading: { type: ShadingType.SOLID, color: TABLE_HEADER_BG, fill: TABLE_HEADER_BG },
children: [new Paragraph({
children: inlineToRuns(cell.tokens, true),
spacing: { before: 40, after: 40 },
})],
})),
}));
}
if (token.rows) {
token.rows.forEach((row, idx) => {
rows.push(new TableRow({
children: row.map(cell => new TableCell({
shading: idx % 2 === 1
? { type: ShadingType.SOLID, color: TABLE_ALT_BG, fill: TABLE_ALT_BG }
: undefined,
children: [new Paragraph({
children: inlineToRuns(cell.tokens),
spacing: { before: 30, after: 30 },
})],
})),
}));
});
}
return new Table({
rows, width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders,
});
}
// --- Code block builder ---
function buildCodeBlock(token) {
const lines = (token.text || "").split("\n");
return lines.map(line => new Paragraph({
children: [new TextRun({ text: line || " ", font: CODE_FONT, size: 18 })],
spacing: { before: 20, after: 20 },
shading: { type: ShadingType.SOLID, color: CODE_BG, fill: CODE_BG },
indent: { left: 360 },
}));
}
// --- List builder ---
function buildList(token, level = 0) {
const items = [];
for (const item of token.items) {
const textTokens = item.tokens?.find(t => t.type === "text");
const bullet = token.ordered ? `${item.raw?.match(/^\d+/)?.[0] || "1"}.` : "\u2022";
const indent = 720 + level * 360;
items.push(new Paragraph({
children: [
new TextRun({ text: `${bullet} `, font: FONT, size: 22 }),
...(textTokens ? inlineToRuns(textTokens.tokens) : [new TextRun({
text: decodeEntities(item.text || ""), font: FONT, size: 22,
})]),
],
spacing: { before: 40, after: 40 },
indent: { left: indent },
}));
const nestedList = item.tokens?.find(t => t.type === "list");
if (nestedList) items.push(...buildList(nestedList, level + 1));
}
return items;
}
// --- Build document children ---
const children = [];
// Title page (from front-matter metadata)
children.push(
new Paragraph({ spacing: { before: 2400 } }),
new Paragraph({
children: [new TextRun({ text: mainTitle, font: FONT, size: 56, bold: true, color: HEADER_COLOR })],
alignment: AlignmentType.CENTER,
}),
);
if (subtitle) {
children.push(new Paragraph({
children: [new TextRun({ text: subtitle, font: FONT, size: 36, color: ACCENT_COLOR })],
alignment: AlignmentType.CENTER, spacing: { after: 400 },
}));
}
children.push(
new Paragraph({
children: [new TextRun({
text: `Date: ${date} | Version: ${version}`,
font: FONT, size: 22, color: "666666",
})],
alignment: AlignmentType.CENTER,
}),
);
if (audience) {
children.push(new Paragraph({
children: [new TextRun({ text: `Audience: ${audience}`, font: FONT, size: 22, color: "666666" })],
alignment: AlignmentType.CENTER, spacing: { after: 600 },
}));
}
children.push(new Paragraph({ children: [new PageBreak()] }));
// Table of Contents (static, built from headings found in the markdown)
children.push(
new Paragraph({
children: [new TextRun({ text: "Table of Contents", font: FONT, size: 32, bold: true, color: HEADER_COLOR })],
spacing: { before: 200, after: 400 },
}),
);
// Pre-scan tokens for headings to build the TOC
for (const tok of tokens) {
if (tok.type !== "heading" || tok.depth > 3) continue;
// Skip the first H1 title and the TOC heading itself
if (tok.depth === 1 && mainTitle !== "Document" &&
decodeEntities(tok.text || "").includes(mainTitle)) continue;
if (tok.text === "Table of Contents") continue;
const indent = (tok.depth - 1) * 360;
const tocSize = tok.depth === 1 ? 24 : tok.depth === 2 ? 22 : 20;
const tocBold = tok.depth <= 2;
const tocColor = tok.depth <= 2 ? HEADER_COLOR : ACCENT_COLOR;
children.push(new Paragraph({
children: [new TextRun({
text: decodeEntities(tok.text),
font: FONT, size: tocSize, bold: tocBold, color: tocColor,
})],
spacing: { before: tok.depth === 2 ? 80 : 40, after: 40 },
indent: { left: indent },
}));
}
children.push(new Paragraph({ children: [new PageBreak()] }));
// --- Token walker ---
let skipToc = false;
for (const token of tokens) {
switch (token.type) {
case "heading": {
// Skip first H1 if it matches the front-matter title (already on title page)
if (token.depth === 1 && mainTitle !== "Document" &&
decodeEntities(token.text || "").includes(mainTitle)) {
continue;
}
// Skip markdown TOC section
if (token.text === "Table of Contents") { skipToc = true; continue; }
if (skipToc && token.depth > 2) continue;
skipToc = false;
const headingMap = {
1: HeadingLevel.HEADING_1, 2: HeadingLevel.HEADING_2,
3: HeadingLevel.HEADING_3, 4: HeadingLevel.HEADING_4,
};
children.push(new Paragraph({
heading: headingMap[token.depth] || HeadingLevel.HEADING_4,
children: [new TextRun({
text: decodeEntities(token.text),
font: FONT, bold: true,
color: token.depth <= 2 ? HEADER_COLOR : ACCENT_COLOR,
size: token.depth === 2 ? 32 : token.depth === 3 ? 26 : 24,
})],
spacing: { before: token.depth === 2 ? 360 : 240, after: 120 },
}));
break;
}
case "paragraph": {
if (skipToc) continue;
// Check if the paragraph is a standalone image
const imgToken = token.tokens && token.tokens.length === 1 && token.tokens[0].type === "image"
? token.tokens[0] : null;
if (imgToken) {
const href = imgToken.href || "";
const imgPath = resolve(inputDir, href);
if (existsSync(imgPath)) {
const imgBuf = readFileSync(imgPath);
const dims = pngDimensions(imgBuf);
const maxW = 580; // max width in points (~6 inches)
const scale = dims.width > maxW ? maxW / dims.width : 1;
const w = Math.round(dims.width * scale);
const h = Math.round(dims.height * scale);
children.push(new Paragraph({
children: [new ImageRun({ data: imgBuf, transformation: { width: w, height: h }, type: "png" })],
alignment: AlignmentType.CENTER,
spacing: { before: 120, after: 40 },
}));
// Add caption if alt text exists
if (imgToken.text) {
children.push(new Paragraph({
children: [new TextRun({ text: imgToken.text, font: FONT, size: 18, italics: true, color: "666666" })],
alignment: AlignmentType.CENTER,
spacing: { before: 0, after: 120 },
}));
}
} else {
children.push(new Paragraph({
children: [new TextRun({ text: `[Image not found: ${href}]`, font: FONT, size: 20, italics: true, color: "888888" })],
spacing: { before: 80, after: 80 },
}));
}
} else {
children.push(new Paragraph({
children: paragraphRuns(token), spacing: { before: 80, after: 80 },
}));
}
break;
}
case "table":
if (skipToc) continue;
children.push(buildTable(token));
children.push(new Paragraph({ spacing: { after: 120 } }));
break;
case "code":
if (skipToc) continue;
if (token.lang === "mermaid") {
children.push(new Paragraph({
children: [new TextRun({
text: "[Diagram: See source .md file for interactive Mermaid diagram]",
font: FONT, size: 20, italics: true, color: "888888",
})],
spacing: { before: 80, after: 80 },
shading: { type: ShadingType.SOLID, color: CODE_BG, fill: CODE_BG },
indent: { left: 360 },
}));
} else {
children.push(...buildCodeBlock(token));
}
children.push(new Paragraph({ spacing: { after: 80 } }));
break;
case "list":
if (skipToc) continue;
children.push(...buildList(token));
break;
case "hr":
skipToc = false;
children.push(new Paragraph({
spacing: { before: 200, after: 200 },
border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: BORDER_COLOR } },
}));
break;
case "space":
break;
default:
if (token.raw && !skipToc) {
children.push(new Paragraph({
children: [new TextRun({ text: decodeEntities(token.raw.trim()), font: FONT, size: 22 })],
spacing: { before: 80, after: 80 },
}));
}
break;
}
}
// --- Create and write document ---
const doc = new Document({
styles: {
default: {
document: { run: { font: FONT, size: 22 } },
heading1: {
run: { font: FONT, size: 36, bold: true, color: HEADER_COLOR },
paragraph: { spacing: { before: 360, after: 160 } },
},
heading2: {
run: { font: FONT, size: 32, bold: true, color: HEADER_COLOR },
paragraph: { spacing: { before: 320, after: 120 } },
},
heading3: {
run: { font: FONT, size: 26, bold: true, color: ACCENT_COLOR },
paragraph: { spacing: { before: 240, after: 100 } },
},
},
},
sections: [{
properties: {
page: { margin: { top: 1440, bottom: 1440, left: 1440, right: 1440 } },
},
children,
}],
features: { updateFields: false },
});
const buffer = await Packer.toBuffer(doc);
writeFileSync(outputPath, buffer);
console.log(`Generated: ${outputPath} (${(buffer.length / 1024).toFixed(0)} KB)`);

View File

@@ -0,0 +1,9 @@
{
"private": true,
"type": "module",
"description": "Dependencies for the Markdown to Word converter skill",
"dependencies": {
"docx": "^9.6.1",
"marked": "^17.0.4"
}
}