mirror of
https://github.com/github/awesome-copilot.git
synced 2026-05-01 20:55:55 +00:00
chore: publish from staged
This commit is contained in:
96
plugins/project-documenter/skills/drawio/SKILL.md
Normal file
96
plugins/project-documenter/skills/drawio/SKILL.md
Normal 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).
|
||||
@@ -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); });
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Dependencies for the draw.io diagram export skill",
|
||||
"dependencies": {
|
||||
"puppeteer-core": "^24.39.1"
|
||||
}
|
||||
}
|
||||
74
plugins/project-documenter/skills/md-to-docx/SKILL.md
Normal file
74
plugins/project-documenter/skills/md-to-docx/SKILL.md
Normal 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 `` 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
|
||||

|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, '"').replace(/'/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)`);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user