feat: mermaid-animator with HTTP API, WebSocket server, and Docker deployment

Animated GIF generator for Mermaid diagrams with three animation modes
(progressive, template, sequence). Includes POST /api/render REST endpoint
with CORS for cross-origin canvas integration, plus WebSocket server for
real-time preview. Dockerized with Playwright Chromium for server-side rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-01 12:21:36 -07:00
commit 4c8ea44300
24 changed files with 3794 additions and 0 deletions

4
.dockerignore Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
*.gif
.git

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
*.gif
*.png
.DS_Store

42
Dockerfile Normal file
View File

@ -0,0 +1,42 @@
FROM node:22-bookworm-slim
# Install Playwright Chromium dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libnss3 \
libnspr4 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libdbus-1-3 \
libxkbcommon0 \
libatspi2.0-0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libpango-1.0-0 \
libcairo2 \
libasound2 \
libx11-xcb1 \
fonts-liberation \
fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci --production=false
# Install Playwright Chromium browser
RUN npx playwright install chromium
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
EXPOSE 3999
CMD ["node", "dist/cli.js", "preview", "--port", "3999"]

21
docker-compose.yml Normal file
View File

@ -0,0 +1,21 @@
services:
mermaid-animator:
build: .
container_name: mermaid-animator
restart: unless-stopped
deploy:
resources:
limits:
memory: 2G
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.mermaid-animator.rule=Host(`mermaid.jeffemmett.com`)"
- "traefik.http.routers.mermaid-animator.entrypoints=websecure"
- "traefik.http.routers.mermaid-animator.tls.certresolver=letsencrypt"
- "traefik.http.services.mermaid-animator.loadbalancer.server.port=3999"
networks:
proxy:
external: true

2469
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "mermaid-animator",
"version": "0.1.0",
"description": "Animated GIF generator for Mermaid diagrams",
"type": "module",
"bin": {
"mermaid-animator": "./dist/cli.js"
},
"scripts": {
"build": "tsc",
"dev": "tsx src/cli.ts"
},
"keywords": [
"mermaid",
"animation",
"gif",
"diagram"
],
"license": "MIT",
"dependencies": {
"commander": "^13.1.0",
"gifenc": "^1.0.3",
"mermaid-isomorphic": "^3.1.0",
"playwright": "^1.59.1",
"sharp": "^0.33.5",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/node": "^22.13.0",
"@types/ws": "^8.5.13",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}

17
src/animators/index.ts Normal file
View File

@ -0,0 +1,17 @@
import type { Animator, AnimationMode } from '../types.js';
import { ProgressiveAnimator } from './progressive.js';
import { TemplateAnimator } from './template.js';
import { SequenceAnimator } from './sequence.js';
export function getAnimator(mode: AnimationMode): Animator {
switch (mode) {
case 'progressive':
return new ProgressiveAnimator();
case 'template':
return new TemplateAnimator();
case 'sequence':
return new SequenceAnimator();
default:
throw new Error(`Unknown animation mode: ${mode}`);
}
}

View File

@ -0,0 +1,53 @@
import type { Animator, AnimationResult, ProgressiveResult } from '../types.js';
import { parseDiagram, generateProgressiveFrames } from '../parser/mermaid-ast.js';
export class ProgressiveAnimator implements Animator {
/**
* Fallback: generates separate mermaid code per frame (causes layout shifts).
* Used by template/sequence modes via the generic pipeline.
*/
parse(input: string, delay: number): AnimationResult {
const parsed = parseDiagram(input);
const frameCodes = generateProgressiveFrames(parsed);
return {
frames: frameCodes.map((code) => ({ mermaidCode: code, delay })),
};
}
/**
* Stable-layout progressive mode: returns the full diagram code
* plus per-frame visibility info (which nodes should be shown).
*/
parseStable(input: string, delay: number): ProgressiveResult {
const parsed = parseDiagram(input);
const { header, directives, elements } = parsed;
// Build the full diagram code (all elements + directives)
const directiveBlock = directives.length > 0 ? '\n' + directives.join('\n') : '';
const allLines = elements.map((e) => e.line);
const fullCode = header + '\n' + allLines.join('\n') + directiveBlock;
// Build cumulative visibility — one node at a time.
// Edges appear automatically when both endpoints become visible.
const frameVisibility = [];
const cumulativeNodes = new Set<string>();
for (const element of elements) {
for (const name of element.nodeNames) {
if (!cumulativeNodes.has(name)) {
cumulativeNodes.add(name);
frameVisibility.push({
visibleNodes: new Set(cumulativeNodes),
});
}
}
}
// If no elements, single frame with everything visible
if (frameVisibility.length === 0) {
frameVisibility.push({ visibleNodes: new Set<string>() });
}
return { fullCode, frameVisibility, delay };
}
}

25
src/animators/sequence.ts Normal file
View File

@ -0,0 +1,25 @@
import type { Animator, AnimationResult } from '../types.js';
/**
* Sequence mode: multiple diagrams separated by ---
* Each diagram becomes one frame.
*/
export class SequenceAnimator implements Animator {
parse(input: string, delay: number): AnimationResult {
const diagrams = input
.split(/^---$/m)
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (diagrams.length === 0) {
throw new Error('No diagrams found in sequence input');
}
return {
frames: diagrams.map((code) => ({
mermaidCode: code,
delay,
})),
};
}
}

60
src/animators/template.ts Normal file
View File

@ -0,0 +1,60 @@
import type { Animator, AnimationResult } from '../types.js';
/**
* Template substitution mode.
* Syntax: {[frameIndex value |frameIndex value |...]}
* Each frame index gets its corresponding value substituted.
*/
export class TemplateAnimator implements Animator {
parse(input: string, delay: number): AnimationResult {
// Find all template markers and determine frame count
const markerRegex = /\{\[([^\]]+)\]\}/g;
const markers: { full: string; options: Map<number, string> }[] = [];
let maxFrame = 0;
let match;
while ((match = markerRegex.exec(input)) !== null) {
const content = match[1];
const options = new Map<number, string>();
// Parse "frameIndex value" pairs separated by |
const parts = content.split('|').map((s) => s.trim());
for (const part of parts) {
const spaceIdx = part.indexOf(' ');
if (spaceIdx === -1) continue;
const idx = parseInt(part.substring(0, spaceIdx), 10);
const value = part.substring(spaceIdx + 1).trim();
if (!isNaN(idx)) {
options.set(idx, value);
maxFrame = Math.max(maxFrame, idx);
}
}
markers.push({ full: match[0], options });
}
if (markers.length === 0) {
return { frames: [{ mermaidCode: input, delay }] };
}
// Generate one frame per index
const frames = [];
for (let frameIdx = 0; frameIdx <= maxFrame; frameIdx++) {
let code = input;
for (const marker of markers) {
// Use the value for this frame index, or the closest lower one
let value = '';
for (let j = frameIdx; j >= 0; j--) {
if (marker.options.has(j)) {
value = marker.options.get(j)!;
break;
}
}
code = code.replace(marker.full, value);
}
frames.push({ mermaidCode: code, delay });
}
return { frames };
}
}

92
src/cli.ts Normal file
View File

@ -0,0 +1,92 @@
#!/usr/bin/env node
import { Command } from 'commander';
import { readFileSync, writeFileSync } from 'fs';
import { getAnimator } from './animators/index.js';
import { ProgressiveAnimator } from './animators/progressive.js';
import { renderFrames, renderStableProgressiveFrames } from './renderer/index.js';
import { encodeGif } from './encoder/gif.js';
import type { AnimationMode, RenderOptions } from './types.js';
const program = new Command();
program
.name('mermaid-animator')
.description('Animated GIF generator for Mermaid diagrams')
.version('0.1.0');
program
.command('render')
.description('Render a Mermaid diagram to an animated GIF')
.argument('<input>', 'Path to .mmd file')
.option('-o, --output <path>', 'Output GIF path', 'output.gif')
.option('-m, --mode <mode>', 'Animation mode: progressive, template, sequence', 'progressive')
.option('-d, --delay <ms>', 'Delay between frames in ms', '800')
.option('-w, --width <px>', 'Diagram width', '800')
.option('-t, --theme <theme>', 'Mermaid theme: default, dark, forest, neutral', 'default')
.option('--bg <color>', 'Background color', 'white')
.action(async (input: string, opts: Record<string, string>) => {
try {
const mmdContent = readFileSync(input, 'utf-8');
const mode = opts.mode as AnimationMode;
const delay = parseInt(opts.delay, 10);
console.log(`Mode: ${mode}, Delay: ${delay}ms`);
const renderOpts: RenderOptions = {
width: parseInt(opts.width, 10),
theme: opts.theme as RenderOptions['theme'],
backgroundColor: opts.bg,
};
let pngBuffers: Buffer[];
if (mode === 'progressive') {
// Stable-layout progressive: render full diagram once, hide elements per frame
const animator = new ProgressiveAnimator();
const progressive = animator.parseStable(mmdContent, delay);
console.log(`Generated ${progressive.frameVisibility.length} frames (stable layout)`);
console.log('Rendering frames...');
pngBuffers = await renderStableProgressiveFrames(
progressive.fullCode,
progressive.frameVisibility,
renderOpts
);
} else {
// Template/sequence: render each frame independently
const animator = getAnimator(mode);
const result = animator.parse(mmdContent, delay);
console.log(`Generated ${result.frames.length} frames`);
console.log('Rendering frames...');
pngBuffers = await renderFrames(
result.frames.map((f) => f.mermaidCode),
renderOpts
);
}
// Encode to GIF
console.log('Encoding GIF...');
const gif = await encodeGif(pngBuffers, { delay });
writeFileSync(opts.output, gif);
console.log(`Written: ${opts.output} (${gif.length} bytes)`);
process.exit(0);
} catch (err) {
console.error('Error:', err instanceof Error ? err.message : err);
process.exit(1);
}
});
program
.command('preview')
.description('Launch web preview with live editor')
.argument('[input]', 'Optional .mmd file to load')
.option('-p, --port <port>', 'Server port', '3999')
.action(async (input: string | undefined, opts: Record<string, string>) => {
const { startServer } = await import('./server/index.js');
const initialCode = input ? readFileSync(input, 'utf-8') : '';
await startServer(parseInt(opts.port, 10), initialCode);
});
program.parse();

66
src/encoder/gif.ts Normal file
View File

@ -0,0 +1,66 @@
import sharp from 'sharp';
import gifenc from 'gifenc';
const { GIFEncoder, quantize, applyPalette } = gifenc;
interface EncodeOptions {
delay: number; // ms per frame
repeat?: number; // 0 = loop forever, -1 = no loop
}
export async function encodeGif(
pngBuffers: Buffer[],
options: EncodeOptions
): Promise<Uint8Array> {
if (pngBuffers.length === 0) {
throw new Error('No frames to encode');
}
// Decode all PNGs and get their dimensions
const frames = await Promise.all(
pngBuffers.map(async (buf) => {
const img = sharp(buf);
const meta = await img.metadata();
return { img, width: meta.width!, height: meta.height! };
})
);
// Normalize all frames to the same dimensions (max width/height, white padded)
const maxWidth = Math.max(...frames.map((f) => f.width));
const maxHeight = Math.max(...frames.map((f) => f.height));
const rgbaFrames = await Promise.all(
frames.map(async ({ img, width, height }) => {
let pipeline = img;
if (width !== maxWidth || height !== maxHeight) {
// Extend with white background to match max dimensions
pipeline = pipeline.extend({
top: 0,
bottom: maxHeight - height,
left: 0,
right: maxWidth - width,
background: { r: 255, g: 255, b: 255, alpha: 1 },
});
}
return pipeline.ensureAlpha().raw().toBuffer();
})
);
// Encode to GIF
const gif = GIFEncoder();
for (const rgba of rgbaFrames) {
// Convert RGBA buffer to Uint8Array for quantization
const data = new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength);
const palette = quantize(data, 256);
const index = applyPalette(data, palette);
gif.writeFrame(index, maxWidth, maxHeight, {
palette,
delay: options.delay,
repeat: options.repeat ?? 0,
});
}
gif.finish();
return gif.bytesView();
}

57
src/gifenc.d.ts vendored Normal file
View File

@ -0,0 +1,57 @@
declare module 'gifenc' {
interface GIFEncoderInstance {
writeFrame(
index: Uint8Array,
width: number,
height: number,
options?: {
palette?: number[][];
delay?: number;
repeat?: number;
transparent?: boolean;
transparentIndex?: number;
dispose?: number;
first?: boolean;
colorDepth?: number;
}
): void;
finish(): void;
bytes(): Uint8Array;
bytesView(): Uint8Array;
reset(): void;
readonly buffer: ArrayBuffer;
}
interface Gifenc {
GIFEncoder(): GIFEncoderInstance;
quantize(
data: Uint8Array,
maxColors: number,
options?: {
format?: 'rgb565' | 'rgb444' | 'rgba4444';
oneBitAlpha?: boolean | number;
clearAlpha?: boolean;
clearAlphaColor?: number;
clearAlphaThreshold?: number;
}
): number[][];
applyPalette(
data: Uint8Array,
palette: number[][],
format?: 'rgb565' | 'rgb444' | 'rgba4444'
): Uint8Array;
nearestColorIndex(palette: number[][], color: number[]): number;
prequantize(
data: Uint8Array,
options?: { roundRGB?: number; roundAlpha?: number; oneBitAlpha?: boolean | number }
): void;
snapColorsToPalette(
palette: number[][],
knownColors: number[][],
threshold?: number
): void;
}
const gifenc: Gifenc;
export default gifenc;
}

163
src/parser/mermaid-ast.ts Normal file
View File

@ -0,0 +1,163 @@
/**
* Line-level tokenizer for Mermaid flowchart/graph diagrams.
* Parses the diagram into a header, directives (classDef/style/linkStyle),
* and elements (nodes, edges, subgraphs) for progressive reveal.
*/
export interface ParsedDiagram {
header: string; // e.g. "graph TD" or "flowchart LR"
directives: string[]; // classDef, style, linkStyle — always included in every frame
elements: ElementInfo[]; // nodes/edges/subgraphs to reveal progressively
}
export interface ElementInfo {
line: string; // original source line(s)
nodeNames: string[]; // node IDs introduced by this element
}
const DIRECTIVE_PATTERNS = [
/^\s*classDef\s/,
/^\s*class\s/,
/^\s*style\s/,
/^\s*linkStyle\s/,
/^\s*%%/,
/^\s*click\s/,
];
const HEADER_PATTERN = /^\s*(graph|flowchart|sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt|pie|gitGraph|journey|mindmap|timeline|sankey|xychart|block)/;
const KEYWORDS = new Set([
'graph', 'flowchart', 'TD', 'TB', 'BT', 'RL', 'LR',
'subgraph', 'end', 'classDef', 'class', 'style', 'linkStyle',
'click', 'direction', 'true', 'false',
]);
function isDirective(line: string): boolean {
return DIRECTIVE_PATTERNS.some((p) => p.test(line));
}
function isBlank(line: string): boolean {
return line.trim() === '';
}
/**
* Extract node names from a mermaid source line.
* Strips shape content ([text], {text}, etc.) and edge labels (|text|),
* then finds word tokens that match known SVG node names.
*/
export function extractNodeNames(line: string): string[] {
let cleaned = line;
// Remove quoted strings
cleaned = cleaned.replace(/"[^"]*"/g, ' ');
// Remove arrow operators FIRST (before shape removal, to avoid > in --> matching asymmetric shapes)
cleaned = cleaned.replace(/[-.=~]{2,}>?/g, ' ');
cleaned = cleaned.replace(/<[-.=~]{2,}/g, ' ');
// Remove edge labels |text|
cleaned = cleaned.replace(/\|[^|]*\|/g, ' ');
// Remove shape content (order matters: compound shapes first)
cleaned = cleaned.replace(/\(\[.*?\]\)/g, ' ');
cleaned = cleaned.replace(/\[\[.*?\]\]/g, ' ');
cleaned = cleaned.replace(/\(\(.*?\)\)/g, ' ');
cleaned = cleaned.replace(/\[.*?\]/g, ' ');
cleaned = cleaned.replace(/\(.*?\)/g, ' ');
cleaned = cleaned.replace(/\{.*?\}/g, ' ');
// Asymmetric shape: ID>text] — only match when preceded by a word char
cleaned = cleaned.replace(/(?<=\w)>.*?\]/g, ' ');
// Extract word tokens
const tokens = cleaned.match(/\b([A-Za-z_][\w]*)\b/g) || [];
return [...new Set(tokens.filter((t) => !KEYWORDS.has(t)))];
}
export function parseDiagram(code: string): ParsedDiagram {
const lines = code.split('\n');
let header = '';
const directives: string[] = [];
const elements: ElementInfo[] = [];
let i = 0;
// Find header line
while (i < lines.length) {
const line = lines[i];
if (isBlank(line)) {
i++;
continue;
}
if (HEADER_PATTERN.test(line)) {
header = line;
i++;
break;
}
if (line.trim().startsWith('%%{')) {
directives.push(line);
i++;
continue;
}
header = line;
i++;
break;
}
// Parse remaining lines into directives and elements
while (i < lines.length) {
const line = lines[i];
if (isBlank(line)) {
i++;
continue;
}
if (isDirective(line)) {
directives.push(line);
i++;
continue;
}
// Handle subgraph blocks as single elements
if (/^\s*subgraph\s/.test(line)) {
const subgraphLines: string[] = [line];
i++;
let depth = 1;
while (i < lines.length && depth > 0) {
const subLine = lines[i];
subgraphLines.push(subLine);
if (/^\s*subgraph\s/.test(subLine)) depth++;
if (/^\s*end\s*$/.test(subLine.trim())) depth--;
i++;
}
const fullLine = subgraphLines.join('\n');
const nodeNames = subgraphLines.flatMap(extractNodeNames);
elements.push({ line: fullLine, nodeNames: [...new Set(nodeNames)] });
continue;
}
// Regular line: node, edge, or other content
elements.push({ line, nodeNames: extractNodeNames(line) });
i++;
}
return { header, directives, elements };
}
/**
* Generate progressive frame codes from a parsed diagram.
* Each frame adds one more element while always including directives.
*/
export function generateProgressiveFrames(parsed: ParsedDiagram): string[] {
const { header, directives, elements } = parsed;
const frames: string[] = [];
const directiveBlock = directives.length > 0 ? '\n' + directives.join('\n') : '';
for (let i = 0; i < elements.length; i++) {
const visibleElements = elements.slice(0, i + 1).map((e) => e.line);
const frame = header + '\n' + visibleElements.join('\n') + directiveBlock;
frames.push(frame);
}
if (frames.length === 0) {
frames.push(header + directiveBlock);
}
return frames;
}

232
src/renderer/index.ts Normal file
View File

@ -0,0 +1,232 @@
import { createMermaidRenderer, type MermaidRenderer } from 'mermaid-isomorphic';
import { chromium, type Browser, type Page } from 'playwright';
import type { RenderOptions, FrameVisibility } from '../types.js';
let renderer: MermaidRenderer | null = null;
let screenshotBrowser: Browser | null = null;
function getRenderer(): MermaidRenderer {
if (!renderer) {
renderer = createMermaidRenderer({
launchOptions: {
args: ['--no-sandbox', '--disable-setuid-sandbox'],
},
});
}
return renderer;
}
async function getScreenshotBrowser(): Promise<Browser> {
if (!screenshotBrowser) {
screenshotBrowser = await chromium.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
}
return screenshotBrowser;
}
export async function renderDiagram(
code: string,
opts: RenderOptions
): Promise<Buffer> {
const render = getRenderer();
const results = await render([code], {
screenshot: true,
mermaidConfig: {
theme: opts.theme,
},
});
const result = results[0];
if (result.status === 'rejected') {
throw new Error(`Mermaid render failed: ${result.reason}`);
}
if (!result.value.screenshot) {
throw new Error('No screenshot produced');
}
return Buffer.from(result.value.screenshot);
}
export async function renderFrames(
codes: string[],
opts: RenderOptions
): Promise<Buffer[]> {
const render = getRenderer();
const results = await render(codes, {
screenshot: true,
mermaidConfig: {
theme: opts.theme,
},
});
return results.map((result, i) => {
if (result.status === 'rejected') {
throw new Error(`Frame ${i} render failed: ${result.reason}`);
}
if (!result.value.screenshot) {
throw new Error(`Frame ${i}: no screenshot produced`);
}
return Buffer.from(result.value.screenshot);
});
}
// --- Stable-layout progressive rendering ---
interface SvgElementIds {
nodeIds: Map<string, string>; // nodeName → full SVG element ID
edgeIds: Map<string, string[]>; // "source_target" → full SVG element IDs
edgeLabelDataIds: Map<string, string[]>; // "source_target" → data-id values
}
/**
* Extract node and edge IDs from mermaid SVG output.
*/
function extractSvgIds(svg: string): SvgElementIds {
const nodeIds = new Map<string, string>();
const edgeIds = new Map<string, string[]>();
const edgeLabelDataIds = new Map<string, string[]>();
// Node IDs: id="mermaid-0-flowchart-A-0"
const nodeRegex = /id="(mermaid-\d+-flowchart-(\w+)-\d+)"/g;
let match;
while ((match = nodeRegex.exec(svg)) !== null) {
nodeIds.set(match[2], match[1]);
}
// Edge path IDs: id="mermaid-0-L_A_B_0"
const edgeRegex = /id="(mermaid-\d+-L_(\w+)_(\w+)_\d+)"/g;
while ((match = edgeRegex.exec(svg)) !== null) {
const key = `${match[2]}_${match[3]}`;
const ids = edgeIds.get(key) || [];
ids.push(match[1]);
edgeIds.set(key, ids);
}
// Edge label data-ids: data-id="L_A_B_0"
const labelRegex = /data-id="(L_(\w+)_(\w+)_\d+)"/g;
while ((match = labelRegex.exec(svg)) !== null) {
const key = `${match[2]}_${match[3]}`;
const ids = edgeLabelDataIds.get(key) || [];
ids.push(match[1]);
edgeLabelDataIds.set(key, ids);
}
return { nodeIds, edgeIds, edgeLabelDataIds };
}
/**
* Build CSS that hides SVG elements not visible in the current frame.
*/
function buildHidingCss(
svgIds: SvgElementIds,
visibleNodes: Set<string>
): string {
const selectors: string[] = [];
// Hide nodes not yet visible
for (const [nodeName, svgId] of svgIds.nodeIds) {
if (!visibleNodes.has(nodeName)) {
selectors.push(`#${cssEscapeId(svgId)}`);
}
}
// Hide edges where either endpoint is not visible
for (const [key, ids] of svgIds.edgeIds) {
const [source, target] = key.split('_');
if (!visibleNodes.has(source) || !visibleNodes.has(target)) {
for (const id of ids) {
selectors.push(`#${cssEscapeId(id)}`);
}
}
}
// Hide edge labels where either endpoint is not visible
for (const [key, dataIds] of svgIds.edgeLabelDataIds) {
const [source, target] = key.split('_');
if (!visibleNodes.has(source) || !visibleNodes.has(target)) {
for (const dataId of dataIds) {
selectors.push(`[data-id="${dataId}"]`);
}
}
}
if (selectors.length === 0) return '';
return selectors.join(', ') + ' { visibility: hidden !important; }';
}
function cssEscapeId(id: string): string {
// CSS.escape equivalent for SVG IDs (alphanumeric + hyphens, safe without escaping)
return id.replace(/([^\w-])/g, '\\$1');
}
/**
* Render progressive frames with stable layout.
* 1. Renders the full diagram to SVG (final layout)
* 2. For each frame, injects CSS to hide future elements
* 3. Screenshots each modified SVG via Playwright
*/
export async function renderStableProgressiveFrames(
fullCode: string,
frameVisibility: FrameVisibility[],
opts: RenderOptions
): Promise<Buffer[]> {
// 1. Render full diagram to get SVG with final layout
const render = getRenderer();
const results = await render([fullCode], {
screenshot: false,
mermaidConfig: { theme: opts.theme },
});
const result = results[0];
if (result.status === 'rejected') {
throw new Error(`Mermaid render failed: ${result.reason}`);
}
const svg = result.value.svg;
const svgWidth = result.value.width;
const svgHeight = result.value.height;
// 2. Extract element IDs from SVG
const svgIds = extractSvgIds(svg);
// 3. Prepare a fixed-size SVG (replace width="100%" with explicit dimensions)
const fixedSvg = svg.replace(
/width="100%"/,
`width="${svgWidth}" height="${svgHeight}"`
);
// 4. For each frame, inject hiding CSS and screenshot
const browser = await getScreenshotBrowser();
const page = await browser.newPage();
await page.setViewportSize({
width: Math.ceil(svgWidth) + 20,
height: Math.ceil(svgHeight) + 20,
});
const pngBuffers: Buffer[] = [];
for (const frame of frameVisibility) {
const hidingCss = buildHidingCss(svgIds, frame.visibleNodes);
const modifiedSvg = hidingCss
? fixedSvg.replace('</style>', hidingCss + '</style>')
: fixedSvg;
const html = `<!DOCTYPE html><html><head><style>body{margin:0;padding:0;background:white;}</style></head><body>${modifiedSvg}</body></html>`;
await page.setContent(html, { waitUntil: 'load' });
const screenshot = await page.screenshot({
clip: {
x: 0,
y: 0,
width: Math.ceil(svgWidth),
height: Math.ceil(svgHeight),
},
type: 'png',
});
pngBuffers.push(Buffer.from(screenshot));
}
await page.close();
return pngBuffers;
}

210
src/server/index.ts Normal file
View File

@ -0,0 +1,210 @@
import { createServer, IncomingMessage, ServerResponse } from 'http';
import { readFileSync } from 'fs';
import { join, extname } from 'path';
import { fileURLToPath } from 'url';
import { WebSocketServer } from 'ws';
import { getAnimator } from '../animators/index.js';
import { ProgressiveAnimator } from '../animators/progressive.js';
import { renderFrames, renderStableProgressiveFrames } from '../renderer/index.js';
import { encodeGif } from '../encoder/gif.js';
import type { AnimationMode, RenderOptions } from '../types.js';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const MIME_TYPES: Record<string, string> = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.png': 'image/png',
'.gif': 'image/gif',
};
const CORS_HEADERS: Record<string, string> = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
interface RenderRequest {
code: string;
mode?: AnimationMode;
delay?: number;
theme?: RenderOptions['theme'];
}
/**
* Shared render function: mermaid code GIF base64
* Used by both the WebSocket handler and the HTTP API.
*/
async function renderMermaidToGif(
code: string,
mode: AnimationMode = 'progressive',
delay: number = 500,
theme: RenderOptions['theme'] = 'default',
onStatus?: (message: string) => void
): Promise<{ gif: string; frames: number; width: number; height: number }> {
const renderOpts: RenderOptions = {
width: 800,
theme,
backgroundColor: 'white',
};
let pngBuffers: Buffer[];
if (mode === 'progressive') {
const animator = new ProgressiveAnimator();
const progressive = animator.parseStable(code, delay);
onStatus?.(`Rendering ${progressive.frameVisibility.length} frames (stable layout)...`);
pngBuffers = await renderStableProgressiveFrames(
progressive.fullCode,
progressive.frameVisibility,
renderOpts
);
} else {
const animator = getAnimator(mode);
const result = animator.parse(code, delay);
onStatus?.(`Rendering ${result.frames.length} frames...`);
pngBuffers = await renderFrames(
result.frames.map((f) => f.mermaidCode),
renderOpts
);
}
onStatus?.('Encoding GIF...');
const gif = await encodeGif(pngBuffers, { delay });
const base64 = Buffer.from(gif).toString('base64');
return {
gif: base64,
frames: pngBuffers.length,
width: 800,
height: 600,
};
}
/**
* Read the full request body as a string.
*/
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
req.on('error', reject);
});
}
/**
* Handle POST /api/render HTTP API for GIF rendering.
*/
async function handleApiRender(req: IncomingMessage, res: ServerResponse): Promise<void> {
// CORS preflight
if (req.method === 'OPTIONS') {
res.writeHead(204, CORS_HEADERS);
res.end();
return;
}
if (req.method !== 'POST') {
res.writeHead(405, { ...CORS_HEADERS, 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
try {
const body = await readBody(req);
const { code, mode, delay, theme } = JSON.parse(body) as RenderRequest;
if (!code || typeof code !== 'string') {
res.writeHead(400, { ...CORS_HEADERS, 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing required field: code' }));
return;
}
const result = await renderMermaidToGif(code, mode, delay, theme);
res.writeHead(200, { ...CORS_HEADERS, 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error('API render error:', message);
res.writeHead(500, { ...CORS_HEADERS, 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: message }));
}
}
export async function startServer(port: number, initialCode: string): Promise<void> {
const staticDir = join(__dirname, 'static');
const server = createServer((req, res) => {
const url = req.url === '/' ? '/index.html' : req.url!;
// API routes
if (url === '/api/initial-code') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ code: initialCode }));
return;
}
if (url === '/api/render') {
handleApiRender(req, res);
return;
}
// CORS preflight for any /api/* path
if (url.startsWith('/api/') && req.method === 'OPTIONS') {
res.writeHead(204, CORS_HEADERS);
res.end();
return;
}
// Static file serving
try {
const filePath = join(staticDir, url);
const content = readFileSync(filePath);
const ext = extname(filePath);
res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
res.end(content);
} catch {
res.writeHead(404);
res.end('Not found');
}
});
const wss = new WebSocketServer({ server });
wss.on('connection', (ws) => {
console.log('Client connected');
ws.on('message', async (data) => {
try {
const msg = JSON.parse(data.toString());
if (msg.type === 'render') {
const { code, mode = 'progressive', delay = 500, theme = 'default' } = msg;
ws.send(JSON.stringify({ type: 'status', message: 'Parsing...' }));
const result = await renderMermaidToGif(
code,
mode as AnimationMode,
delay,
theme as RenderOptions['theme'],
(message) => ws.send(JSON.stringify({ type: 'status', message }))
);
ws.send(JSON.stringify({ type: 'gif', data: result.gif }));
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
ws.send(JSON.stringify({ type: 'error', message }));
}
});
});
server.listen(port, () => {
console.log(`Preview server running at http://localhost:${port}`);
});
// Keep process alive
await new Promise(() => {});
}

View File

@ -0,0 +1,38 @@
// Monaco Editor setup
const DEFAULT_CODE = `graph TD
A[Start] --> B[Process]
B --> C{Decision}
C -->|Yes| D[Result]
C -->|No| E[Other]`;
let editor;
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' } });
require(['vs/editor/editor.main'], function () {
editor = monaco.editor.create(document.getElementById('editor-container'), {
value: DEFAULT_CODE,
language: 'markdown',
theme: 'vs-dark',
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
wordWrap: 'on',
automaticLayout: true,
scrollBeyondLastLine: false,
});
// Load initial code from server if provided
fetch('/api/initial-code')
.then((r) => r.json())
.then((data) => {
if (data.code) {
editor.setValue(data.code);
}
})
.catch(() => {});
});
function getEditorValue() {
return editor ? editor.getValue() : DEFAULT_CODE;
}

View File

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mermaid Animator — Preview</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; height: 100vh; display: flex; flex-direction: column; background: #1e1e1e; color: #ccc; }
header { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: #252526; border-bottom: 1px solid #333; }
header h1 { font-size: 14px; font-weight: 600; color: #fff; }
.controls { display: flex; gap: 8px; align-items: center; margin-left: auto; }
.controls label { font-size: 12px; }
.controls select, .controls input { background: #3c3c3c; color: #ccc; border: 1px solid #555; border-radius: 3px; padding: 3px 6px; font-size: 12px; }
.controls input[type="number"] { width: 60px; }
button { background: #0078d4; color: #fff; border: none; border-radius: 3px; padding: 6px 16px; cursor: pointer; font-size: 13px; }
button:hover { background: #106ebe; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.main { display: flex; flex: 1; overflow: hidden; }
.editor-pane { flex: 1; display: flex; flex-direction: column; border-right: 1px solid #333; }
.preview-pane { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 16px; background: #f5f5f5; overflow: auto; }
#editor-container { flex: 1; }
#status { font-size: 12px; padding: 4px 8px; background: #252526; color: #888; }
#preview-img { max-width: 100%; max-height: 100%; image-rendering: auto; }
.placeholder { color: #999; font-size: 14px; }
#download-link { display: none; margin-top: 8px; color: #0078d4; font-size: 13px; }
</style>
</head>
<body>
<header>
<h1>Mermaid Animator</h1>
<div class="controls">
<label>Mode:
<select id="mode">
<option value="progressive">Progressive</option>
<option value="template">Template</option>
<option value="sequence">Sequence</option>
</select>
</label>
<label>Delay:
<input type="number" id="delay" value="500" min="100" step="100"> ms
</label>
<label>Theme:
<select id="theme">
<option value="default">Default</option>
<option value="dark">Dark</option>
<option value="forest">Forest</option>
<option value="neutral">Neutral</option>
</select>
</label>
<button id="render-btn">Render GIF</button>
</div>
</header>
<div class="main">
<div class="editor-pane">
<div id="editor-container"></div>
<div id="status">Ready</div>
</div>
<div class="preview-pane">
<img id="preview-img" style="display:none" />
<p class="placeholder" id="placeholder">Click "Render GIF" to generate animation</p>
<a id="download-link" download="mermaid-animation.gif">Download GIF</a>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js"></script>
<script src="editor.js"></script>
<script src="preview.js"></script>
</body>
</html>

View File

@ -0,0 +1,53 @@
// WebSocket preview client
const ws = new WebSocket(`ws://${location.host}`);
const statusEl = document.getElementById('status');
const renderBtn = document.getElementById('render-btn');
const previewImg = document.getElementById('preview-img');
const placeholder = document.getElementById('placeholder');
const downloadLink = document.getElementById('download-link');
ws.onopen = () => {
statusEl.textContent = 'Connected';
};
ws.onclose = () => {
statusEl.textContent = 'Disconnected — refresh to reconnect';
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'status':
statusEl.textContent = msg.message;
break;
case 'gif':
const dataUrl = `data:image/gif;base64,${msg.data}`;
previewImg.src = dataUrl;
previewImg.style.display = 'block';
placeholder.style.display = 'none';
downloadLink.href = dataUrl;
downloadLink.style.display = 'inline';
statusEl.textContent = 'Done';
renderBtn.disabled = false;
break;
case 'error':
statusEl.textContent = `Error: ${msg.message}`;
renderBtn.disabled = false;
break;
}
};
renderBtn.addEventListener('click', () => {
const code = getEditorValue();
const mode = document.getElementById('mode').value;
const delay = parseInt(document.getElementById('delay').value, 10);
const theme = document.getElementById('theme').value;
renderBtn.disabled = true;
statusEl.textContent = 'Sending...';
ws.send(JSON.stringify({ type: 'render', code, mode, delay, theme }));
});

42
src/types.ts Normal file
View File

@ -0,0 +1,42 @@
export interface RenderOptions {
width: number;
height?: number;
theme: 'default' | 'dark' | 'forest' | 'neutral';
backgroundColor: string;
}
export interface AnimationFrame {
mermaidCode: string;
delay: number; // ms
}
export interface AnimationResult {
frames: AnimationFrame[];
}
export type AnimationMode = 'progressive' | 'template' | 'sequence';
export interface Animator {
parse(input: string, delay: number): AnimationResult;
}
/** Progressive mode: stable-layout frames with visibility info */
export interface ProgressiveResult {
fullCode: string;
frameVisibility: FrameVisibility[];
delay: number;
}
export interface FrameVisibility {
visibleNodes: Set<string>;
}
export interface CliOptions {
output: string;
mode: AnimationMode;
delay: number;
width: number;
height?: number;
theme: 'default' | 'dark' | 'forest' | 'neutral';
backgroundColor: string;
}

15
test/fixtures/sequence.mmd vendored Normal file
View File

@ -0,0 +1,15 @@
graph TD
A[Start]
---
graph TD
A[Start] --> B[Process]
---
graph TD
A[Start] --> B[Process]
B --> C{Decision}
---
graph TD
A[Start] --> B[Process]
B --> C{Decision}
C -->|Yes| D[Result]
C -->|No| E[Other]

5
test/fixtures/simple.mmd vendored Normal file
View File

@ -0,0 +1,5 @@
graph TD
A[Start] --> B[Process]
B --> C{Decision}
C -->|Yes| D[Result]
C -->|No| E[Other]

4
test/fixtures/template.mmd vendored Normal file
View File

@ -0,0 +1,4 @@
graph TD
A[{[0 Start |1 Begin |2 Initialize]}] --> B[{[0 Step 1 |1 Step 2 |2 Step 3]}]
B --> C[{[0 Processing |1 Computing |2 Finishing]}]
C --> D[Done]

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}