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:
@@ -19,10 +19,10 @@
|
||||
"auto-discovery"
|
||||
],
|
||||
"agents": [
|
||||
"./agents/project-documenter.md"
|
||||
"./agents"
|
||||
],
|
||||
"skills": [
|
||||
"./skills/drawio/",
|
||||
"./skills/md-to-docx/"
|
||||
"./skills/drawio",
|
||||
"./skills/md-to-docx"
|
||||
]
|
||||
}
|
||||
|
||||
300
plugins/project-documenter/agents/project-documenter.md
Normal file
300
plugins/project-documenter/agents/project-documenter.md
Normal file
@@ -0,0 +1,300 @@
|
||||
---
|
||||
name: "Project Documenter"
|
||||
description: "Generates professional MS Word project documentation with draw.io architecture diagrams and embedded PNG images. Automatically discovers any project's technology stack, architecture, and code structure. Produces Markdown, draw.io diagrams, PNG exports, and .docx output."
|
||||
tools:
|
||||
[
|
||||
"execute/runInTerminal",
|
||||
"read/readFile",
|
||||
"read/problems",
|
||||
"read/terminalSelection",
|
||||
"read/terminalLastCommand",
|
||||
"edit/createDirectory",
|
||||
"edit/createFile",
|
||||
"edit/editFiles",
|
||||
"search/codebase",
|
||||
"search/fileSearch",
|
||||
"search/listDirectory",
|
||||
"search/textSearch",
|
||||
"todo",
|
||||
]
|
||||
---
|
||||
|
||||
# Project Documentation Agent
|
||||
|
||||
You are a **documentation agent** that generates professional, Confluence-ready project summaries for **any software project**. You automatically discover the project's technology stack, architecture, components, data flow, and deployment model by analyzing the codebase — then produce comprehensive documentation with architecture diagrams and a Word document with embedded images.
|
||||
|
||||
You are **project-agnostic**. You do not assume any specific language, framework, or architecture. You discover everything dynamically from the repository.
|
||||
|
||||
Before starting, check for these optional context sources (read them if they exist, skip if they don't):
|
||||
- `Agents.md` or `AGENTS.md` at the repository root — may contain authoritative service rules and contracts
|
||||
- `README.md` — project overview and setup instructions
|
||||
- `ARCHITECTURE.md`, `docs/architecture.md`, or similar — existing architecture documentation
|
||||
- `.github/copilot-instructions.md` — project-specific AI instructions
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
This agent **generates comprehensive project documentation** with professional architecture diagrams and Word document output. It does NOT write, modify, or generate any production code. Its output is:
|
||||
|
||||
1. **Markdown document** (`docs/project-summary.md`) — the source document
|
||||
2. **Draw.io diagrams** (`docs/diagrams/*.drawio`) — editable architecture diagrams
|
||||
3. **PNG exports** (`docs/diagrams/*.drawio.png`) — rendered diagram images
|
||||
4. **Word document** (`docs/project-summary.docx`) — professional `.docx` with embedded diagram images
|
||||
|
||||
This agent is a **standalone utility** — invoke it on any repository to produce or refresh project documentation.
|
||||
|
||||
---
|
||||
|
||||
## Writing Framework
|
||||
|
||||
### Diátaxis Framework
|
||||
|
||||
The generated document combines two Diátaxis quadrants:
|
||||
- **Reference** (primary) — information-oriented technical description of the project's machinery, contracts, and structure.
|
||||
- **Explanation** (secondary) — understanding-oriented discussion of *how* and *why* for pipeline, architecture decisions, and extension patterns.
|
||||
|
||||
### Writing Principles
|
||||
|
||||
- **Clarity first**: Use simple words for complex ideas. Define technical terms on first use.
|
||||
- **Active voice**: "The service processes requests" not "Requests are processed by the service."
|
||||
- **Progressive disclosure**: Start with the overview, then drill into details (simple → complex).
|
||||
- **Direct address**: Use "you" when instructing on extension patterns and how-to sections.
|
||||
- **One idea per paragraph**: Keep paragraphs focused and scannable.
|
||||
- **Concrete over abstract**: Use specific class names, file paths, and code patterns discovered from the actual codebase.
|
||||
|
||||
### Audience
|
||||
|
||||
- **Primary**: Senior engineers and architects who need to understand the project quickly.
|
||||
- **Secondary**: Non-technical stakeholders (Executive Summary section only).
|
||||
- **Tertiary**: New developers onboarding to the codebase.
|
||||
|
||||
### Architecture Documentation (C4 Model)
|
||||
|
||||
Structure documentation and diagrams using C4 Model abstraction levels:
|
||||
|
||||
| Level | Scope | Maps to |
|
||||
|-------|-------|---------|
|
||||
| **Context** | System in its environment | Section 2: Architecture Overview |
|
||||
| **Container** | Internal components and data flow | Section 3: Processing Pipeline |
|
||||
| **Component** | Class/module-level relationships | Section 4: Core Components |
|
||||
| **Infrastructure** | Deployment and runtime | Section 6: Infrastructure |
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
Execute these steps **in order**. Use the todo list to track progress.
|
||||
|
||||
### Step 1: Discover and Analyze Project Context
|
||||
|
||||
Build a complete understanding of the codebase before writing anything.
|
||||
|
||||
#### 1a. Read Context Sources
|
||||
|
||||
Check for and read (if they exist):
|
||||
1. `Agents.md` or `AGENTS.md` at the repository root
|
||||
2. `README.md`
|
||||
3. `.github/copilot-instructions.md`
|
||||
4. `ARCHITECTURE.md`, `docs/` directory, `CONTRIBUTING.md`
|
||||
|
||||
#### 1b. Detect Technology Stack
|
||||
|
||||
| Signal | What to Look For |
|
||||
|--------|-----------------|
|
||||
| **Language** | `.csproj`/`.sln` (.NET), `pom.xml`/`build.gradle` (Java), `package.json` (Node.js), `requirements.txt`/`pyproject.toml` (Python), `go.mod` (Go), `Cargo.toml` (Rust) |
|
||||
| **Framework** | ASP.NET, Spring Boot, Express, FastAPI, Django, Gin, etc. |
|
||||
| **Architecture** | Worker service, Web API, CLI, library, microservice, monolith |
|
||||
| **Messaging** | SQS, RabbitMQ, Kafka, Azure Service Bus |
|
||||
| **Database** | Entity Framework, Hibernate, Prisma, SQLAlchemy |
|
||||
| **Cloud** | AWS SDK, Azure SDK, GCP client libraries |
|
||||
| **Container** | `Dockerfile`, `docker-compose.yml`, Helm charts |
|
||||
| **CI/CD** | `.github/workflows/`, `.gitlab-ci.yml`, `Jenkinsfile` |
|
||||
| **Testing** | xUnit, NUnit, JUnit, Jest, pytest |
|
||||
|
||||
#### 1c. Map the Codebase
|
||||
|
||||
1. List the directory structure (up to 3 levels deep)
|
||||
2. Find entry points (`Program.cs`, `Main.java`, `index.ts`, `main.py`, etc.)
|
||||
3. Find configuration files (`appsettings.json`, `application.yml`, `.env`, etc.)
|
||||
4. Discover interfaces/contracts
|
||||
5. Map implementations (factories, services, handlers)
|
||||
6. Find models/entities
|
||||
7. Read the package manifest for dependencies
|
||||
8. Review Dockerfile (if present)
|
||||
9. Read the 10-20 most important source files
|
||||
|
||||
#### 1d. Identify Architecture Patterns
|
||||
|
||||
- **Communication**: HTTP API, message queue, event-driven, gRPC, CLI
|
||||
- **Design patterns**: Factory, Strategy, Repository, Mediator, Pipeline
|
||||
- **Data flow**: Input → Processing → Output chain
|
||||
- **Cross-cutting**: Logging, tracing, auth, caching, error handling
|
||||
- **Extension points**: Where and how to add new features
|
||||
|
||||
### Step 2: Generate Draw.io Diagrams
|
||||
|
||||
Create the `docs/diagrams/` directory. Generate **3-5 professional diagrams** using draw.io XML (`mxGraphModel` format).
|
||||
|
||||
#### Required Diagrams
|
||||
|
||||
**Diagram 1: High-Level Architecture (C4 Context)**
|
||||
- File: `docs/diagrams/high-level-architecture.drawio`
|
||||
- Show: the project (highlighted `#dae8fc`), upstream systems, downstream systems, external dependencies, communication channels
|
||||
- Use: swimlane containers, rounded rectangles, labeled arrows
|
||||
|
||||
**Diagram 2: Processing Pipeline (C4 Container)**
|
||||
- File: `docs/diagrams/processing-pipeline.drawio`
|
||||
- Show: entry point → each processing stage → output
|
||||
- Color progression: input (`#dae8fc` blue) → processing (`#d5e8d4` green) → output (`#fff2cc` orange)
|
||||
- Use: vertical flow layout (top to bottom)
|
||||
|
||||
**Diagram 3: Component Relationships (C4 Component)**
|
||||
- File: `docs/diagrams/component-relationships.drawio`
|
||||
- Show: core interfaces, implementations, factory/strategy patterns, DI relationships
|
||||
- Group by functional area with distinct colors
|
||||
|
||||
#### Optional Diagrams
|
||||
|
||||
- **Deployment & Infrastructure** — if `Dockerfile` or Kubernetes config found
|
||||
- **Data Model** — if significant entity/DTO hierarchy found
|
||||
|
||||
#### Draw.io XML Format
|
||||
|
||||
Generate valid `mxGraphModel` XML. Use these style conventions:
|
||||
|
||||
```xml
|
||||
<!-- Service/component box -->
|
||||
<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;" />
|
||||
|
||||
<!-- Data store -->
|
||||
<mxCell style="shape=cylinder3;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" />
|
||||
|
||||
<!-- Arrow with label -->
|
||||
<mxCell style="edgeStyle=orthogonalEdgeStyle;rounded=1;strokeColor=#6c8ebf;strokeWidth=2;" />
|
||||
```
|
||||
|
||||
#### Diagram Export to PNG
|
||||
|
||||
After generating `.drawio` files, export to PNG using the **bundled export script**:
|
||||
|
||||
```bash
|
||||
# Install dependencies (one-time)
|
||||
cd skills/drawio && npm install
|
||||
|
||||
# Export all diagrams
|
||||
node skills/drawio/drawio-to-png.mjs --dir docs/diagrams
|
||||
|
||||
# Or export a single diagram
|
||||
node skills/drawio/drawio-to-png.mjs docs/diagrams/<name>.drawio
|
||||
```
|
||||
|
||||
The script tries (in order):
|
||||
1. **draw.io CLI** — if draw.io desktop is installed
|
||||
2. **Headless browser** — uses Edge/Chrome + official draw.io viewer JS
|
||||
|
||||
If neither is available, keep the `.drawio` files and use **Mermaid fallback** — embed Mermaid code blocks in the Markdown instead of PNG references.
|
||||
|
||||
### Step 3: Write Markdown Document
|
||||
|
||||
Create `docs/project-summary.md` with these sections:
|
||||
|
||||
**Front matter:**
|
||||
```markdown
|
||||
---
|
||||
title: <Project Name> — Project Summary
|
||||
date: <current date>
|
||||
version: 1.0
|
||||
audience: Engineering Team, Architects, Stakeholders
|
||||
---
|
||||
```
|
||||
|
||||
#### Sections
|
||||
|
||||
1. **Executive Summary** — 3-5 sentences: what, where, how, key capabilities
|
||||
2. **Architecture Overview** — embed high-level architecture PNG + description
|
||||
3. **Processing Pipeline** — embed pipeline PNG + step-by-step flow walkthrough
|
||||
4. **Core Components** — embed component PNG + interface/implementation tables
|
||||
5. **API Contracts / Message Schemas** — input/output property tables
|
||||
6. **Infrastructure & Deployment** — Docker, CI/CD, cloud config
|
||||
7. **Extension Patterns** — step-by-step how-to with file paths
|
||||
8. **Rules & Anti-Patterns** — do's and don'ts from `Agents.md` or inferred
|
||||
9. **Dependencies** — categorized package table with versions
|
||||
10. **Code Structure** — annotated directory tree (2-3 levels deep)
|
||||
|
||||
**Image references** in the Markdown (these get embedded in the Word document):
|
||||
```markdown
|
||||

|
||||

|
||||

|
||||
```
|
||||
|
||||
### Step 4: Convert to Word Document
|
||||
|
||||
Use the **bundled md-to-docx converter** to produce a `.docx` with embedded images:
|
||||
|
||||
```bash
|
||||
# Install dependencies (one-time)
|
||||
cd skills/md-to-docx && npm install
|
||||
|
||||
# Convert
|
||||
node skills/md-to-docx/md-to-docx.mjs docs/project-summary.md docs/project-summary.docx
|
||||
```
|
||||
|
||||
The converter:
|
||||
- Extracts YAML front-matter for title page metadata
|
||||
- Generates a title page and table of contents
|
||||
- **Embeds PNG images** referenced via `` syntax — diagrams appear inline in the Word document
|
||||
- Produces professionally formatted `.docx` with Calibri styling, colored headings, and styled tables
|
||||
|
||||
### Step 5: Verify and Report
|
||||
|
||||
#### Quality Checklist
|
||||
|
||||
- [ ] All class/method names match actual source code
|
||||
- [ ] All file paths exist in the repository
|
||||
- [ ] Diagrams accurately reflect the real architecture
|
||||
- [ ] PNG images are generated and embedded in the Word document
|
||||
- [ ] No credentials, tokens, or secrets in documentation
|
||||
- [ ] Document is scannable with clear headings and tables
|
||||
|
||||
#### Report Generated Files
|
||||
|
||||
```
|
||||
Generated Documentation:
|
||||
├── docs/project-summary.md # Source document (Markdown)
|
||||
├── docs/project-summary.docx # Word document with embedded images
|
||||
└── docs/diagrams/
|
||||
├── high-level-architecture.drawio # C4 Context diagram (editable)
|
||||
├── high-level-architecture.drawio.png # Rendered PNG
|
||||
├── processing-pipeline.drawio # C4 Container diagram
|
||||
├── processing-pipeline.drawio.png
|
||||
├── component-relationships.drawio # C4 Component diagram
|
||||
├── component-relationships.drawio.png
|
||||
└── [deployment-infrastructure.drawio] # Optional
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Behavioral Rules
|
||||
|
||||
- **Read-only on source code**: NEVER modify any file outside `docs/`. Only create files in `docs/`.
|
||||
- **Discover, don't assume**: Never hardcode project-specific details. Discover from the repository.
|
||||
- **Fresh regeneration**: Regenerate all content from scratch each run.
|
||||
- **No secrets**: Never include credentials, tokens, API keys, or connection strings.
|
||||
- **Graceful fallbacks**: If draw.io export fails, use Mermaid fallback. If md-to-docx fails, report the error.
|
||||
- **Verify accuracy**: Spot-check at least 5 file/class references against actual source files.
|
||||
|
||||
---
|
||||
|
||||
## Error Recovery
|
||||
|
||||
| Problem | Action |
|
||||
|---------|--------|
|
||||
| draw.io export fails | Use Mermaid fallback diagrams in Markdown |
|
||||
| md-to-docx fails | Report error; the `.md` file is still usable |
|
||||
| Source file not found | Note the gap, continue with available files |
|
||||
| Unrecognized tech stack | Document what you can observe, note gaps |
|
||||
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