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>
441 lines
14 KiB
JavaScript
441 lines
14 KiB
JavaScript
/**
|
|
* 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)`);
|