diff --git a/.claude/settings.json b/.claude/settings.json index 6d7fbf4..48a5211 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -51,6 +51,7 @@ "Skill(sentry-skills:doc-coauthoring)", "Skill(sentry-skills:document-api-endpoint)", "Skill(sentry-skills:find-bugs)", + "Skill(sentry-skills:flowchart-maker)", "Skill(sentry-skills:gh-review-requests)", "Skill(sentry-skills:gha-security-review)", "Skill(sentry-skills:iterate-pr)", diff --git a/README.md b/README.md index 0b17434..59364da 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Works with Claude Code, Cursor, Cline, GitHub Copilot, and other compatible agen | [doc-coauthoring](skills/doc-coauthoring/SKILL.md) | Guide users through a structured workflow for co-authoring documentation. | | [document-api-endpoint](skills/document-api-endpoint/SKILL.md) | Document and type a Sentry API endpoint with drf-spectacular OpenAPI schema — write/fix `@extend_schema`, specify response TypedDicts, type parameters, fix type drift, and promote to PUBLIC. | | [find-bugs](skills/find-bugs/SKILL.md) | Find bugs, security vulnerabilities, and code quality issues in local branch changes. | +| [flowchart-maker](skills/flowchart-maker/SKILL.md) | Draw flat-editorial flowchart illustrations for engineering blog posts from a Mermaid spec, with a bundled renderer that outputs HTML and PNG. | | [gh-review-requests](skills/gh-review-requests/SKILL.md) | Fetch unread GitHub notifications for open PRs where review is requested from a specified team or opened by a team member. | | [gha-security-review](skills/gha-security-review/SKILL.md) | GitHub Actions security review for workflow exploitation vulnerabilities. | | [iterate-pr](skills/iterate-pr/SKILL.md) | Iterate on a PR until CI passes and actionable review feedback is addressed. | diff --git a/skills/claude-settings-audit/SKILL.md b/skills/claude-settings-audit/SKILL.md index 7c5644f..141eed2 100644 --- a/skills/claude-settings-audit/SKILL.md +++ b/skills/claude-settings-audit/SKILL.md @@ -155,6 +155,7 @@ If this is a Sentry project (or sentry-skills plugin is installed), include: "Skill(sentry-skills:doc-coauthoring)", "Skill(sentry-skills:document-api-endpoint)", "Skill(sentry-skills:find-bugs)", + "Skill(sentry-skills:flowchart-maker)", "Skill(sentry-skills:gh-review-requests)", "Skill(sentry-skills:gha-security-review)", "Skill(sentry-skills:iterate-pr)", diff --git a/skills/flowchart-maker/SKILL.md b/skills/flowchart-maker/SKILL.md new file mode 100644 index 0000000..a884902 --- /dev/null +++ b/skills/flowchart-maker/SKILL.md @@ -0,0 +1,178 @@ +--- +name: flowchart-maker +description: Use this skill to draw flow-chart illustrations for engineering blog posts — system diagrams, request paths, retry loops, swimlanes. Contains the full token set, node/edge markup primitives, and composition rules for a flat-editorial, line-drawn figure style. Output is plain HTML/CSS (no libraries), exportable to PNG or SVG. Includes `flowfig.py`, which renders a Mermaid flowchart spec into this style. +user-invocable: true +--- + +# Flow figures + +A line-drawn, flat-editorial illustration system for explaining how a system works to other engineers. Figures are built from a fixed vocabulary — five node types, four edge types, three marks — laid out on CSS grid. Nothing is hand-drawn; nothing is bespoke. + +## Generator: `flowfig.py` + +Prefer the generator over hand-written markup. Write a Mermaid flowchart to a `.mmd` file, then run the bundled script from the repository root: + +```bash +uv run ${CLAUDE_SKILL_ROOT}/scripts/flowfig.py figure.mmd # writes figure.html +uv run ${CLAUDE_SKILL_ROOT}/scripts/flowfig.py figure.mmd --png figure.png --scale 2 +``` + +- Requires the `uv` CLI (https://docs.astral.sh/uv/getting-started/installation/). The script has no dependencies beyond the Python standard library, so `python3` works too. +- `--png` needs Google Chrome or Chromium on the machine and takes a few seconds per figure. Without a browser, deliver the HTML and tell the user to screenshot the plate at 2x. +- The script prints the output path on success and rule violations on stderr. A parse error names the offending statement; fix the spec and rerun. +- Rubik is embedded from `assets/fonts/rubik-latin.woff2` (SIL Open Font License, see `assets/fonts/OFL.txt`), so the output needs no network. +- Example specs with rendered PNGs live in `references/examples/`. Open one when unsure how a pattern should look. + +Supported Mermaid subset: + +``` +%% note: An annotation rendered inside the plate. +%% steps: queue, send, ok numbered badges in reading order +%% width: 1200 max plate width in px +graph LR or: flowchart TD + queue[(Queue)] --> send[Send webhook
POST · 10s timeout]:::focus + send --> ok{2xx?} + ok -- yes --> done((Delivered)) + ok -- no --> retry[Backoff
attempt < 5]:::warn + retry -.->|requeue| queue + retry --x dead([Dead letter]):::fail + subgraph ingest [Ingest tier] dashed group boundary, may nest + a --> b + end +``` + +- Shapes: `[process]`, `([terminal])`, `((filled end state))`, `[(store)]`, `{decision}`. +- Text after `
` becomes the mono sub-label. +- Edges: `-->` solid, `-.->` dashed, `--x` failure (pink). An edge into a `fail` node is pink too. +- Labels: `-->|label|` or `-- label -->`. A bracketed label `-->|[40 ms]|` renders as a yellow measurement chip. +- Tones: `:::focus`, `:::warn`, `:::fail`, or `class a,b focus`. +- Layout hints: `:::below` forces a node under its parent, `:::beside` forces it to the next column. By default a non-primary successor with no outgoing edges drops below its parent; everything else goes right. +- Figures carry no title, number, legend, or caption. The host page supplies those. Put what the reader must know into node labels and the optional `%% note:`. +- The tool warns on stderr when a figure breaks a rule: more than nine nodes, more than three accented nodes, an upward failure edge, or overlapping edges. + +Edit the generated HTML only for one-off tweaks. Change the spec for anything structural. + +## Tokens + +```css +:root { + /* ink & paper */ + --ink: #2B2233; /* strokes, node titles */ + --muted: #80708F; /* mono labels, lane names */ + --body: #4D4158; /* captions, annot */ + --rule: #E0DCE5; /* hairlines, grid overlay */ + --wash: #F5F3F7; /* swimlane bands */ + --paper: #FAF9FB; /* page */ + --plate: #FFFFFF; /* figure background, node fill */ + + /* signal — use sparingly */ + --accent: #6C5FC7; /* the node the paragraph is about */ + --accent-ink: #4D3FA3; /* mono label on accent fill */ + --accent-fill:#EFEBFA; + --accent-line:#C6BEEB; /* dashed boundaries */ + --warn: #FFC227; /* attention — the slow or costly step */ + --warn-ink: #7A5200; + --warn-fill: #FFF4D4; + --warn-line: #F5DFA3; + --fail: #FF45A8; /* failure paths only */ + --fail-ink: #B01B70; + --fail-fill: #FFEBF5; + + /* geometry */ + --stroke: 2.5px; /* node + edge strokes */ + --stroke-plate: 3px; + --r-node: 10px; + --r-plate: 18px; + --shadow-node: 4px 4px 0; /* hard offset, no blur — colour = ink, or the node's own accent */ + --shadow-plate: 8px 8px 0; + --unit: 8px; /* all spacing is a multiple */ +} +``` + +Fonts: **Rubik** (500/600/700) for titles, captions, node labels. **Monaco** (`Monaco, Menlo, 'Ubuntu Mono', monospace` — no webfont) for every small label — always uppercase, `letter-spacing: .08em–.12em`, 11px. + +Type scale: node title 17/700 · secondary node title 16/700 · caption 16/500 · annotation 15/500 · mono label 12/500 caps (700 for eyebrows). + +## Primitives + +Copy these verbatim. All values are literal — do not round them. + +**Process node** (the default) +```html +
+
Relay
+
auth · rate limit
+
+``` + +**Focus node** — same, but `border:2.5px solid #2B2233; background:#EFEBFA; box-shadow:4px 4px 0 #6C5FC7;` and the sub-label in `#4D3FA3`. Max two per figure. + +**Attention node** — same, but `border:2.5px solid #2B2233; background:#FFF4D4; box-shadow:4px 4px 0 #FFC227;` and the sub-label in `#7A5200`. Marks the slow, costly, or noisy step — never an error. + +**Failure node** — same, but `border:2.5px solid #2B2233; background:#FFEBF5; box-shadow:4px 4px 0 #FF45A8;` and the sub-label in `#B01B70`. + +**Terminal** — `padding:13px 24px; border:2.5px solid #2B2233; border-radius:999px; box-shadow:4px 4px 0 #2B2233;`. Filled (`background:#2B2233; color:#FFFFFF;`) for the end state. + +**Decision** — a rotated square with counter-rotated text: +```html +
+
2xx?
+
+``` +Wrap it in a fixed-height flex cell (~154px) so the rotation doesn't disturb the grid. + +**Store** — `padding:19px 21px; border:2.5px solid #2B2233; border-radius:50%/18px;` (the cylinder). + +**Edge, horizontal** — a flex row: line + CSS-triangle head. +```html +
+
+
+
+``` + +**Edge, vertical** — flex column: `
` + head `border-top:12px solid #2B2233; border-left:8px solid transparent; border-right:8px solid transparent;`. Flip the head's border side to point up. + +**Edge variants** — dashed for async/deferred/conditional (`border-top:3px dashed` horizontally, `border-left:3px dashed` vertically); `#FF45A8` for failure; labelled = stack an 11px mono caps label above the line in the same cell. + +**Marks** — step badge: `width:30px;height:30px;border-radius:999px;background:#6C5FC7;color:#FFFFFF;border:2.5px solid #2B2233;font:700 13px/25px Monaco,Menlo,'Ubuntu Mono',monospace;text-align:center;`. Annotation: a 3px accent vertical rule + 15px body sentence. Measurement chip: `padding:3px 7px;border-radius:3px;background:#FFC227;font:500 10px/1.3 Monaco,monospace;letter-spacing:.05em;color:#2B2233;` — stack it above an edge to label latency or volume. Group boundary: `border:2.5px dashed #C6BEEB;border-radius:14px;`. + +**Figure plate** — `background:#FFFFFF;border:3px solid #2B2233;border-radius:18px;box-shadow:8px 8px 0 #2B2233;padding:46px 38px;`. No caption, figure number, or legend below the plate. The page that embeds the figure supplies those. + +## Layout method + +Never absolutely position edges. Use one CSS grid per figure with **alternating node and edge columns**, named `grid-template-areas`, and `align-items:center`: + +```css +grid-template-columns: minmax(146px,1fr) 76px minmax(146px,1fr) 76px minmax(146px,1fr); +grid-template-rows: auto 60px auto; +grid-template-areas: + 'sdk e1 relay e2 queue' + '. . v1 . .' + '. . reject . .'; +``` + +Edge cells are 68–76px wide / 58–62px tall (the 12px arrowheads need the room). L-shaped return edges are two orthogonal segments in adjacent cells (a half-width horizontal stub in the node's own column, then a vertical segment in the row above). Swimlane bands are full-row divs placed with explicit `grid-column:1/-1; grid-row:N;` **before** the node cells in source order, with `margin:-14px 0` to bleed past the nodes. + +## Three patterns + +1. **Linear path with branches** — one left-to-right spine, exceptions dropped below. The default; use for "what happens to a request". +2. **Decision with a loop back** — one diamond, one dashed orthogonal return edge. Use for retries, polling, reconciliation. +3. **Swimlanes with numbered steps** — lanes for tiers, badges for reading order. Use when the point is which tier owns which step. (Not yet supported by the generator; hand-write the lane bands.) + +## Rules + +- One idea per figure. Two sentences to describe it means two figures. +- No titles, numbers, legends, or captions inside the figure. Labels on nodes and edges must stand on their own. +- Nine nodes, hard cap. Past nine, split it or collapse a group into one node with a dashed boundary. +- Accent is scarce — blurple marks only the node(s) the surrounding paragraph is about; yellow marks the one that costs time; pink is exceptions only. Never more than three accented nodes in one figure. +- Orthogonal edges only. No diagonals, no curves, no crossings. +- Edge labels are mono, caps, two or three words. Sentences go in the caption or annotation. +- Flow reads left, then down. Never route a failure upward. +- Every node and plate carries a hard offset shadow, never a blurred one. Node shadows are 4px ink — or the node's own accent when it is a focus/attention/exception node. Plates are 8px ink. +- Light mode only — this system has no dark variant. Don't invent one; ask. +- Never hand-draw SVG illustration. Every shape here is a bordered div, a CSS triangle, or a rotated square. + +## Export + +Figures are plain HTML/CSS, so screenshot the plate element at 2–3× for PNG, leaving room for the 8px offset shadow (transparent-safe: the plate is opaque white). `flowfig.py --png` does this through headless Chrome. For SVG, redraw the same geometry as ``/``/`` at the same literal values — the 2.5px stroke, 10px radius and hard offset shadow carry the style. Target 1200–1600px wide for full-width in-post figures. diff --git a/skills/flowchart-maker/SPEC.md b/skills/flowchart-maker/SPEC.md new file mode 100644 index 0000000..a94e721 --- /dev/null +++ b/skills/flowchart-maker/SPEC.md @@ -0,0 +1,80 @@ +# Flowchart Maker Specification + +## Intent + +Produce flow-chart illustrations for engineering blog posts in one fixed visual style: flat-editorial, line-drawn, built from five node types, four edge types and three marks on a CSS grid. The style is encoded twice so it cannot drift: as literal markup primitives in `SKILL.md` for hand-written figures, and as `scripts/flowfig.py`, which renders a Mermaid flowchart subset into the same markup and exports PNG through headless Chrome. + +## Scope + +In scope: +- System diagrams, request paths, retry loops, grouped tiers, and top-down decision flows with nine nodes or fewer. +- The design tokens, node and edge primitives, layout method and composition rules of the style. +- A deterministic renderer for a Mermaid flowchart subset, with HTML and PNG output. + +Out of scope: +- Sequence, class, state, ER or Gantt diagrams. Use `mermaid-layout` for those. +- Charts and data visualizations. +- A dark variant. The style is light mode only. +- Swimlane bands. The generator does not draw them; hand-write the lane markup from `SKILL.md`. + +## Users And Trigger Context + +- Primary users: engineers and agents illustrating how a system works in a blog post, design doc or internal explainer. +- Common user requests: "draw a figure of the request path", "make an illustration of the retry loop", "render this flowchart in the blog style", "turn this Mermaid graph into a figure", "export the diagram as PNG". +- Should not trigger for: plain Mermaid source meant for a markdown renderer, sequence or class diagrams, charts, slides, UI mockups. + +## Runtime Contract + +- Required first actions: write the figure as a Mermaid flowchart spec, then run `scripts/flowfig.py` on it. Fall back to hand-written primitives only for what the generator cannot draw. +- Required outputs: an HTML file with the plate, and a PNG when the user wants an image. Report the output paths. +- Non-negotiable constraints: + - Nine nodes at most, three accented nodes at most, orthogonal edges, hard offset shadows, light mode only. + - No figure title, number, legend or caption inside the figure. The host page supplies those. + - Plain-language labels when the audience is outside the team. Internal names go in the prose, not the figure. + - Never hand-draw SVG shapes. +- Expected bundled files loaded at runtime: `SKILL.md`. The script runs from `scripts/`. `references/examples/` is opened only when a pattern needs a visual reference. + +## Source And Evidence Model + +Authoritative sources: +- `SKILL.md` token set and primitives, originally produced with Claude Design as a reference implementation. +- The Mermaid flowchart syntax (https://mermaid.js.org/syntax/flowchart.html) for the accepted spec subset. + +Useful improvement sources: +- positive examples: figures accepted into published posts without layout edits. +- negative examples: overlapping edges, clipped labels, plates wider than the host column, figures that needed hand edits after rendering. +- commit logs/changelogs: layout and router changes in `scripts/flowfig.py`. +- issue or PR feedback: requests for unsupported Mermaid syntax or new patterns. +- eval results: the three example specs re-rendered after every script change. + +Data that must not be stored: +- Customer or organization identifiers in example specs. Use generic service names. +- Secrets or internal hostnames in labels. + +## Reference Architecture + +- `SKILL.md` contains: generator usage, the supported Mermaid subset, tokens, primitives, layout method, the three patterns, rules and export notes. +- `references/examples/` contains: three example specs (`.mmd`) with their rendered PNGs, one per pattern. +- `scripts/flowfig.py` contains: the parser, layered layout, orthogonal edge router, HTML renderer and Chrome PNG export. Standard library only. +- `assets/fonts/` contains: the Rubik latin subset embedded into every output, with its SIL Open Font License text. + +## Evaluation + +- Lightweight validation: render the three example specs and inspect the PNGs. Edges must meet node borders, labels must not overlap nodes, and the plate must not clip. +- Deeper evaluation: render a spec with a back edge, a merge from a lower row and nested groups. The script must print no overlap warning. +- Holdout examples: none stored yet. +- Acceptance gates: the script exits zero on all example specs, `uv run scripts/quick_validate.py` from `skill-writer` passes, and the rendered examples match the style rules in `SKILL.md`. + +## Known Limitations + +- Swimlanes and step badges inside lanes are not generated. +- Edge labels wider than an edge cell (about nine mono characters) crowd the next node. Prefer two short words. +- The router picks the first conflict-free orthogonal path and warns when none exists. It does not optimize crossings globally. +- PNG export depends on a local Chrome or Chromium binary and kills the browser once the file is complete, because headless Chrome does not exit reliably. + +## Maintenance Notes + +- When to update `SKILL.md`: a token, primitive or rule of the style changes, or the script accepts new syntax. +- When to update `SOURCES.md`: not kept for this skill. Record source changes in the PR description. +- When to update `EVAL.md`: not kept for this skill. The example specs are the evaluation set. +- When to update `references/evidence/`: when a figure needed hand edits after rendering, store the spec and the fix as a negative example. diff --git a/skills/flowchart-maker/assets/fonts/OFL.txt b/skills/flowchart-maker/assets/fonts/OFL.txt new file mode 100644 index 0000000..03af63b --- /dev/null +++ b/skills/flowchart-maker/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/flowchart-maker/assets/fonts/rubik-latin.woff2 b/skills/flowchart-maker/assets/fonts/rubik-latin.woff2 new file mode 100644 index 0000000..4ba94d1 Binary files /dev/null and b/skills/flowchart-maker/assets/fonts/rubik-latin.woff2 differ diff --git a/skills/flowchart-maker/references/examples/01-request-path.mmd b/skills/flowchart-maker/references/examples/01-request-path.mmd new file mode 100644 index 0000000..0145edb --- /dev/null +++ b/skills/flowchart-maker/references/examples/01-request-path.mmd @@ -0,0 +1,7 @@ +%% steps: sdk, relay, queue +graph LR + sdk[SDK
browser · mobile] --> relay[Relay
auth · rate limit]:::focus + relay -->|accepted| queue[(Kafka)] + relay -.->|429| reject([Rejected]):::fail + queue --> proc[Processing
symbolicate · group]:::warn + proc --> store[(Snuba)] diff --git a/skills/flowchart-maker/references/examples/01-request-path.png b/skills/flowchart-maker/references/examples/01-request-path.png new file mode 100644 index 0000000..c856a02 Binary files /dev/null and b/skills/flowchart-maker/references/examples/01-request-path.png differ diff --git a/skills/flowchart-maker/references/examples/02-retry-loop.mmd b/skills/flowchart-maker/references/examples/02-retry-loop.mmd new file mode 100644 index 0000000..9168f3e --- /dev/null +++ b/skills/flowchart-maker/references/examples/02-retry-loop.mmd @@ -0,0 +1,7 @@ +graph LR + queue[(Queue)] --> send[Send webhook
POST · 10s timeout]:::focus + send --> ok{2xx?} + ok -- yes --> done((Delivered)) + ok -- no --> retry[Backoff
attempt < 5]:::warn + retry -.->|requeue| queue + retry --x dead([Dead letter]):::fail diff --git a/skills/flowchart-maker/references/examples/02-retry-loop.png b/skills/flowchart-maker/references/examples/02-retry-loop.png new file mode 100644 index 0000000..0560666 Binary files /dev/null and b/skills/flowchart-maker/references/examples/02-retry-loop.png differ diff --git a/skills/flowchart-maker/references/examples/03-grouped.mmd b/skills/flowchart-maker/references/examples/03-grouped.mmd new file mode 100644 index 0000000..e62c6bf --- /dev/null +++ b/skills/flowchart-maker/references/examples/03-grouped.mmd @@ -0,0 +1,8 @@ +%% note: Only Relay is shown in detail. The other ingest services are collapsed into the group. +graph LR + subgraph ingest [Ingest tier] + sdk[SDK] --> relay[Relay
auth · rate limit] --> queue[(Kafka)] + end + queue --> consumer[Consumer
batch · 500ms]:::focus + consumer --> db[(Postgres)] + consumer -.->|[~40 ms]| cache[(Redis)] diff --git a/skills/flowchart-maker/references/examples/03-grouped.png b/skills/flowchart-maker/references/examples/03-grouped.png new file mode 100644 index 0000000..0e3b5d9 Binary files /dev/null and b/skills/flowchart-maker/references/examples/03-grouped.png differ diff --git a/skills/flowchart-maker/scripts/flowfig.py b/skills/flowchart-maker/scripts/flowfig.py new file mode 100644 index 0000000..5613084 --- /dev/null +++ b/skills/flowchart-maker/scripts/flowfig.py @@ -0,0 +1,953 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# /// +"""Render a Mermaid flowchart subset as a flat-editorial flowchart HTML plate. + + uv run scripts/flowfig.py SPEC.mmd [-o OUT.html] [--png OUT.png] [--scale 2] + +Supported Mermaid syntax + graph LR / flowchart TD + id[Title
sub label] process node + id([Title]) terminal (outlined) + id((Title)) terminal (filled, end state) + id[(Title)] store + id{Question?} decision + a --> b a -.-> b a --x b arrows: solid, dashed, failure + a -->|label| b a -- label --> b a -.->|label| b + a -->|[40 ms]| b a bracketed label renders as a measurement chip + id:::focus id:::warn id:::fail tone classes (also: class a,b focus) + id:::below id:::beside layout hints: force a node under, or right of, its parent + subgraph name [Title] ... end dashed group boundary; groups may nest + +Directives (comment lines) + %% note: An annotation rendered inside the plate. + %% steps: sdk, relay, queue numbered badges in this order + %% width: 1200 +""" +from __future__ import annotations + +import argparse +import base64 +import html +import heapq +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +from collections.abc import Callable +from dataclasses import dataclass, field + +INK = "#2B2233" +MUTED = "#80708F" +BODY = "#4D4158" +PAPER = "#FAF9FB" +PLATE = "#FFFFFF" +ACCENT = "#6C5FC7" +ACCENT_INK = "#4D3FA3" +ACCENT_FILL = "#EFEBFA" +ACCENT_LINE = "#C6BEEB" +WARN = "#FFC227" +WARN_INK = "#7A5200" +WARN_FILL = "#FFF4D4" +FAIL = "#FF45A8" +FAIL_INK = "#B01B70" +FAIL_FILL = "#FFEBF5" + +SANS = "'Rubik',-apple-system,system-ui,sans-serif" +MONO = "Monaco,Menlo,'Ubuntu Mono',monospace" + +TONES = { + "plain": (PLATE, INK, MUTED), + "focus": (ACCENT_FILL, ACCENT, ACCENT_INK), + "warn": (WARN_FILL, WARN, WARN_INK), + "fail": (FAIL_FILL, FAIL, FAIL_INK), +} +TONE_ALIASES = {"attention": "warn", "failure": "fail", "error": "fail", "accent": "focus"} +LAYOUT_CLASSES = {"below", "beside"} + +NODE_COL = "minmax(146px,1fr)" +EDGE_COL = "76px" +NODE_ROW = "auto" +EDGE_ROW = "60px" + + +def warn(msg: str) -> None: + print(f"flowfig: {msg}", file=sys.stderr) + + +# ---------------------------------------------------------------- model + + +@dataclass +class Node: + id: str + title: str + sub: str = "" + shape: str = "process" + tone: str = "plain" + layout: str | None = None + group: str | None = None + + +@dataclass +class Edge: + src: str + dst: str + label: str = "" + dashed: bool = False + fail: bool = False + + +@dataclass +class Group: + id: str + title: str + parent: str | None = None + + +@dataclass +class Spec: + direction: str = "LR" + nodes: dict[str, Node] = field(default_factory=dict) + edges: list[Edge] = field(default_factory=list) + groups: dict[str, Group] = field(default_factory=dict) + meta: dict[str, str] = field(default_factory=dict) + + +# ---------------------------------------------------------------- parser + +SHAPES = [ + ("([", "])", "terminal"), + ("[(", ")]", "store"), + ("((", "))", "end"), + ("[[", "]]", "process"), + ("[", "]", "process"), + ("(", ")", "process"), + ("{", "}", "decision"), +] + +ID_RE = re.compile(r"\s*([A-Za-z0-9_]+)") +CLASS_RE = re.compile(r":::([\w,]+)") +ARROW_RE = re.compile( + r"""\s*(?: + --\s+(?P.+?)\s+--> + | -\.\s+(?P.+?)\s+\.-> + | ==\s+(?P.+?)\s+==> + | (?P(?:-{2,}|-\.+-|={2,})(?P[>xo])) + )\s*(?:\|(?P[^|]*)\|)?\s*""", + re.X, +) + + +def split_text(text: str) -> tuple[str, str]: + text = text.strip() + if len(text) >= 2 and text[0] == text[-1] == '"': + text = text[1:-1] + parts = re.split(r"|\\n", text) + parts = [p.strip() for p in parts if p.strip()] + if not parts: + return "", "" + return parts[0], " · ".join(parts[1:]) + + +class Parser: + def __init__(self) -> None: + self.spec = Spec() + self.group_stack: list[str] = [] + + def parse(self, text: str) -> Spec: + lines = text.splitlines() + if lines and lines[0].strip() == "---": + end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None) + if end is not None: + for fm in lines[1:end]: + if ":" in fm: + k, v = fm.split(":", 1) + self.spec.meta[k.strip().lower()] = v.strip() + lines = lines[end + 1 :] + for raw in lines: + line = raw.strip() + if not line: + continue + m = re.match(r"%%\s*([A-Za-z_]+)\s*:\s*(.*)$", line) + if m: + self.spec.meta[m.group(1).lower()] = m.group(2).strip() + continue + if line.startswith("%%"): + continue + line = re.sub(r"\s%%.*$", "", line).strip() + for stmt in line.split(";"): + stmt = stmt.strip() + if stmt: + self.statement(stmt) + return self.spec + + def statement(self, s: str) -> None: + m = re.match(r"^(graph|flowchart)\b\s*(\w+)?", s) + if m: + d = (m.group(2) or "LR").upper() + if d in ("TD", "TB", "BT"): + self.spec.direction = "TD" + else: + self.spec.direction = "LR" + if d in ("RL", "BT"): + warn(f"direction {d} is not supported; using {self.spec.direction}") + return + m = re.match(r"^subgraph\s+(.+)$", s) + if m: + body = m.group(1).strip() + mm = re.match(r'^([^\[\s]+)\s*(?:\[\s*"?(.*?)"?\s*\])?$', body) + if mm: + gid, title = mm.group(1), mm.group(2) or mm.group(1) + else: + gid, title = body, body + parent = self.group_stack[-1] if self.group_stack else None + self.spec.groups[gid] = Group(gid, title, parent) + self.group_stack.append(gid) + return + if s == "end": + if self.group_stack: + self.group_stack.pop() + return + m = re.match(r"^class\s+([\w,\s]+?)\s+(\w+)$", s) + if m: + for nid in re.split(r"[,\s]+", m.group(1)): + if nid: + self.apply_class(self.node(nid), m.group(2)) + return + if re.match(r"^(classDef|style|linkStyle|click|direction)\b", s): + return + self.chain(s) + + def node(self, nid: str) -> Node: + n = self.spec.nodes.get(nid) + if n is None: + n = Node(nid, nid) + self.spec.nodes[nid] = n + if self.group_stack and n.group is None: + n.group = self.group_stack[-1] + return n + + def apply_class(self, n: Node, cls: str) -> None: + cls = TONE_ALIASES.get(cls, cls) + if cls in TONES: + n.tone = cls + elif cls in LAYOUT_CLASSES: + n.layout = cls + else: + warn(f"unknown class '{cls}' on node '{n.id}' (ignored)") + + def node_token(self, s: str, pos: int) -> tuple[Node, int]: + m = ID_RE.match(s, pos) + if not m: + raise ValueError(f"expected a node id at: {s[pos:]!r}") + n = self.node(m.group(1)) + pos = m.end() + for opener, closer, shape in SHAPES: + if s.startswith(opener, pos): + end = s.find(closer, pos + len(opener)) + if end < 0: + raise ValueError(f"unclosed node text in: {s!r}") + n.title, n.sub = split_text(s[pos + len(opener) : end]) + n.shape = shape + pos = end + len(closer) + break + m = CLASS_RE.match(s, pos) + if m: + for cls in m.group(1).split(","): + self.apply_class(n, cls) + pos = m.end() + return n, pos + + def chain(self, s: str) -> None: + src, pos = self.node_token(s, 0) + while pos < len(s): + m = ARROW_RE.match(s, pos) + if not m or m.end() == pos: + raise ValueError(f"cannot parse {s[pos:]!r} in: {s!r}") + label = m.group("l1") or m.group("l2") or m.group("l3") or m.group("lp") or "" + arrow_text = m.group(0) + dashed = "." in arrow_text.split("|")[0] and not m.group("l1") and not m.group("l3") + fail = m.group("head") == "x" + dst, pos = self.node_token(s, m.end()) + self.spec.edges.append(Edge(src.id, dst.id, label.strip(), dashed, fail)) + src = dst + + +# ---------------------------------------------------------------- layout + + +@dataclass +class Placement: + layer: int + row: int + + +def assign_positions(spec: Spec) -> tuple[dict[str, Placement], set[int]]: + """Return (id -> Placement, indices of back edges).""" + ids = list(spec.nodes) + out: dict[str, list[tuple[int, Edge]]] = {i: [] for i in ids} + for idx, e in enumerate(spec.edges): + out[e.src].append((idx, e)) + + back: set[int] = set() + state: dict[str, int] = {} + + def dfs(u: str) -> None: + state[u] = 1 + for idx, e in out[u]: + if state.get(e.dst) == 1: + back.add(idx) + elif e.dst not in state: + dfs(e.dst) + state[u] = 2 + + for u in ids: + if u not in state: + dfs(u) + + fwd = [(i, e) for i, e in enumerate(spec.edges) if i not in back] + fpred: dict[str, list[Edge]] = {i: [] for i in ids} + fsucc: dict[str, list[Edge]] = {i: [] for i in ids} + primary: dict[str, str] = {} + for _, e in fwd: + if e.src == e.dst: + continue + fpred[e.dst].append(e) + fsucc[e.src].append(e) + primary.setdefault(e.src, e.dst) + + def is_drop(e: Edge) -> bool: + v = spec.nodes[e.dst] + if v.layout == "beside": + return False + if v.layout == "below": + return True + return primary[e.src] != e.dst and len(fpred[e.dst]) == 1 and not fsucc[e.dst] + + order_index = {nid: i for i, nid in enumerate(ids)} + indeg = {i: len(fpred[i]) for i in ids} + layer: dict[str, int] = {} + pref: dict[str, int] = {} + placed: dict[str, Placement] = {} + occupied: set[tuple[int, int]] = set() + ready: list[tuple[int, int, int, str]] = [] + + def push(v: str) -> None: + if fpred[v]: + layer[v] = max(layer[e.src] + (0 if is_drop(e) else 1) for e in fpred[v]) + anchor = fpred[v][0] + pref[v] = placed[anchor.src].row + (1 if is_drop(anchor) else 0) + else: + layer[v] = 0 + pref[v] = 0 + heapq.heappush(ready, (layer[v], pref[v], order_index[v], v)) + + for v in ids: + if indeg[v] == 0: + push(v) + while ready: + _, _, _, v = heapq.heappop(ready) + r = pref[v] + while (layer[v], r) in occupied: + r += 1 + occupied.add((layer[v], r)) + placed[v] = Placement(layer[v], r) + for e in fsucc[v]: + indeg[e.dst] -= 1 + if indeg[e.dst] == 0: + push(e.dst) + for v in ids: + if v not in placed: + warn(f"node '{v}' could not be placed (cycle without entry?)") + placed[v] = Placement(0, len(placed)) + return placed, back + + +# ---------------------------------------------------------------- routing + +Cell = tuple[int, int] +OPPOSITE = {"L": "R", "R": "L", "T": "B", "B": "T"} + + +@dataclass +class Piece: + sides: frozenset[str] + arrow: str | None + dashed: bool + fail: bool + label: str = "" + + def same_geometry(self, other: "Piece") -> bool: + return (self.sides, self.arrow, self.dashed, self.fail) == ( + other.sides, + other.arrow, + other.dashed, + other.fail, + ) + + +def side_toward(frm: Cell, to: Cell) -> str: + if to[0] > frm[0]: + return "R" + if to[0] < frm[0]: + return "L" + if to[1] > frm[1]: + return "B" + return "T" + + +def walk(points: list[Cell]) -> list[Cell] | None: + cells: list[Cell] = [] + for (x0, y0), (x1, y1) in zip(points, points[1:]): + if x0 != x1 and y0 != y1: + return None + dx = (x1 > x0) - (x1 < x0) + dy = (y1 > y0) - (y1 < y0) + x, y = x0, y0 + while (x, y) != (x1, y1): + x += dx + y += dy + cells.append((x, y)) + return cells + + +def candidate_paths(a: Cell, b: Cell, direction: str) -> list[list[Cell]]: + ax, ay = a + bx, by = b + paths: list[list[Cell]] = [] + + def add(pts: list[Cell]) -> None: + dedup: list[Cell] = [] + for p in pts: + if not dedup or dedup[-1] != p: + dedup.append(p) + if len(dedup) >= 2 and dedup not in paths: + paths.append(dedup) + + if ax == bx or ay == by: + add([a, b]) + rows = [ay, by, ay + 1, by + 1, ay - 1, by - 1] + cols = [ax, bx, ax + 1, bx - 1, ax - 1, bx + 1] + row_paths = [[a, (ax, cy), (bx, cy), b] for cy in rows] + col_paths = [[a, (cx, ay), (cx, by), b] for cx in cols] + for p in row_paths + col_paths if direction == "LR" else col_paths + row_paths: + add(p) + return paths + + +def route( + a: Cell, b: Cell, edge: Edge, direction: str, blocked: set[Cell], pieces: dict[Cell, Piece] +) -> list[tuple[Cell, Piece]]: + fallback: list[tuple[Cell, Piece]] | None = None + for pts in candidate_paths(a, b, direction): + cells = walk(pts) + if cells is None or cells[-1] != b: + continue + body = cells[:-1] + if a in body or b in body or len(set(body)) != len(body): + continue + result: list[tuple[Cell, Piece]] = [] + prev = a + ok = True + for i, c in enumerate(body): + nxt = body[i + 1] if i + 1 < len(body) else b + entry = side_toward(c, prev) + exit_ = side_toward(c, nxt) + piece = Piece( + frozenset({entry, exit_}), + exit_ if nxt == b else None, + edge.dashed, + edge.fail, + ) + existing = pieces.get(c) + if c in blocked or (existing is not None and not existing.same_geometry(piece)): + ok = False + result.append((c, piece)) + prev = c + if fallback is None: + fallback = result + if ok: + return result + if fallback is None: + raise ValueError(f"no route from {a} to {b}") + warn(f"edge {edge.src} -> {edge.dst} overlaps another element; check the figure") + return fallback + + +def attach_label(result: list[tuple[Cell, Piece]], label: str) -> None: + if not label: + return + def straight(sides: str, wide_axis: int) -> list[Piece]: + cells = [(c, p) for c, p in result if p.sides == frozenset(sides)] + wide = [p for c, p in cells if c[wide_axis] % 2 == 0] + return wide or [p for _, p in cells] + + target = (straight("LR", 0) or straight("TB", 1) or [result[0][1]])[0] + target.label = label + + +# ---------------------------------------------------------------- render + +ARROW = { + "R": f"border-left:12px solid {{c}};border-top:8px solid transparent;border-bottom:8px solid transparent;", + "L": f"border-right:12px solid {{c}};border-top:8px solid transparent;border-bottom:8px solid transparent;", + "B": f"border-top:12px solid {{c}};border-left:8px solid transparent;border-right:8px solid transparent;", + "T": f"border-bottom:12px solid {{c}};border-left:8px solid transparent;border-right:8px solid transparent;", +} + + +def esc(s: str) -> str: + return html.escape(s, quote=True) + + +def arrow_div(side: str, color: str, extra: str = "") -> str: + return f'
' + + +def label_div(label: str, fail: bool, extra: str = "") -> str: + if not label: + return "" + m = re.match(r"^\[(.+)\]$", label) + if m: + return ( + f'
{esc(m.group(1))}
' + ) + color = FAIL_INK if fail else MUTED + return ( + f'
{esc(label)}
' + ) + + +def line_style(horizontal: bool, dashed: bool, color: str) -> str: + if horizontal: + return f"flex:1;height:0;border-top:3px dashed {color};" if dashed else f"flex:1;height:3px;background:{color};" + return f"flex:1;width:0;border-left:3px dashed {color};" if dashed else f"flex:1;width:3px;background:{color};" + + +def render_piece(p: Piece, area: str) -> str: + color = FAIL if p.fail else INK + common = f"{area}align-self:stretch;justify-self:stretch;min-width:0;min-height:0;" + if p.sides == frozenset("LR"): + return ( + f'
' + f'{label_div(p.label, p.fail, "grid-row:1;align-self:end;justify-self:center;padding-bottom:5px;white-space:normal;text-align:center;max-width:100%;")}' + f'
' + f'{arrow_div("L", color) if p.arrow == "L" else ""}' + f'
' + f'{arrow_div("R", color) if p.arrow == "R" else ""}' + f"
" + ) + if p.sides == frozenset("TB"): + return ( + f'
' + f'
' + f'{arrow_div("T", color) if p.arrow == "T" else ""}' + f'
' + f'{arrow_div("B", color) if p.arrow == "B" else ""}' + f"
" + f'{label_div(p.label, p.fail, "grid-column:3;align-self:center;padding-left:8px;")}' + f"
" + ) + return render_corner(p, common, color) + + +def render_corner(p: Piece, common: str, color: str) -> str: + style = "dashed" if p.dashed else "solid" + quadrants: dict[tuple[int, int], list[str]] = {} + if "L" in p.sides: + quadrants.setdefault((1, 1), []).append(f"border-bottom:3px {style} {color};margin-bottom:-1.5px;margin-right:-1.5px;") + if "T" in p.sides: + quadrants.setdefault((1, 1), []).append(f"border-right:3px {style} {color};margin-right:-1.5px;margin-bottom:-1.5px;") + if "R" in p.sides: + quadrants.setdefault((1, 2), []).append(f"border-bottom:3px {style} {color};margin-bottom:-1.5px;margin-left:-1.5px;") + if "B" in p.sides: + quadrants.setdefault((2, 1), []).append(f"border-right:3px {style} {color};margin-right:-1.5px;margin-top:-1.5px;") + parts = [ + f'
' + for (r, c), styles in quadrants.items() + ] + if p.arrow: + place = { + "T": "grid-row:1;grid-column:1/3;justify-self:center;align-self:start;", + "B": "grid-row:2;grid-column:1/3;justify-self:center;align-self:end;", + "L": "grid-row:1/3;grid-column:1;align-self:center;justify-self:start;", + "R": "grid-row:1/3;grid-column:2;align-self:center;justify-self:end;", + }[p.arrow] + parts.append(arrow_div(p.arrow, color, place)) + if p.label: + inside = ("B" if "B" in p.sides else "T", "L" if "L" in p.sides else "R") + row = 1 if inside[0] == "B" else 2 + col = 2 if inside[1] == "L" else 1 + align = f"align-self:{'end' if row == 1 else 'start'};justify-self:{'start' if col == 2 else 'end'};" + parts.append(label_div(p.label, p.fail, f"grid-row:{row};grid-column:{col};{align}padding:6px;")) + return ( + f'
' + + "".join(parts) + + "
" + ) + + +def badge_div(n: int) -> str: + return ( + f'
{n}
' + ) + + +Port = tuple[str, bool] + + +def stub(port: Port | None) -> str: + if port is None: + return '
' + color, dashed = port + return f'
' + + +def render_node(n: Node, area: str, step: int | None, ports: dict[str, Port]) -> str: + fill, shadow, sub_color = TONES[n.tone] + badge = badge_div(step) if step else "" + title_color = INK + if n.shape == "end": + fill, title_color, sub_color = INK, "#FFFFFF", ACCENT_LINE + title = f'
{esc(n.title)}
' + sub = ( + f'
{esc(n.sub)}
' + if n.sub + else "" + ) + frame = f"border:2.5px solid {INK};background:{fill};box-shadow:4px 4px 0 {shadow};" + if n.shape == "decision": + return ( + f'
' + f"{badge}{stub(ports.get('L'))}" + f'
' + f'
{esc(n.title)}
' + f"
{stub(ports.get('R'))}
" + ) + if n.shape == "store": + box = "padding:19px 21px;border-radius:50%/18px;align-items:center;text-align:center;" + elif n.shape in ("terminal", "end"): + box = "padding:13px 24px;border-radius:999px;align-items:center;text-align:center;" + else: + box = "padding:13px 15px;border-radius:10px;" + return ( + f'
' + f"{badge}{title}{sub}
" + ) + + +def render_group(g: Group, area: str, bleed: int) -> str: + return ( + f'
' + f'
{esc(g.title)}
' + f"
" + ) + + +FONT_FILE = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "fonts", "rubik-latin.woff2" +) + + +def font_face() -> str: + """Embed Rubik so the page renders offline and headless Chrome never waits on a webfont.""" + if not os.path.exists(FONT_FILE): + return ( + '\n' + '' + ) + with open(FONT_FILE, "rb") as fh: + data = base64.b64encode(fh.read()).decode("ascii") + return ( + "" + ) + + +def render(spec: Spec) -> str: + placed, back = assign_positions(spec) + if len(spec.nodes) > 9: + warn(f"{len(spec.nodes)} nodes; the style caps figures at nine") + accented = sum(1 for n in spec.nodes.values() if n.tone != "plain") + if accented > 3: + warn(f"{accented} accented nodes; the style allows at most three") + + def cell_of(nid: str) -> Cell: + p = placed[nid] + return (2 * p.layer, 2 * p.row) if spec.direction == "LR" else (2 * p.row, 2 * p.layer) + + node_cells = {nid: cell_of(nid) for nid in spec.nodes} + blocked = set(node_cells.values()) + pieces: dict[Cell, Piece] = {} + ports: dict[str, dict[str, Port]] = {nid: {} for nid in spec.nodes} + for e in spec.edges: + if e.src == e.dst: + warn(f"self-loop on '{e.src}' skipped") + continue + if spec.nodes[e.dst].tone == "fail": + e.fail = True + result = route(node_cells[e.src], node_cells[e.dst], e, spec.direction, blocked, pieces) + attach_label(result, e.label) + port: Port = (FAIL if e.fail else INK, e.dashed) + ports[e.src][side_toward(node_cells[e.src], result[0][0])] = port + ports[e.dst][side_toward(node_cells[e.dst], result[-1][0])] = port + if e.fail and spec.direction == "LR" and node_cells[e.dst][1] < node_cells[e.src][1]: + warn(f"failure edge {e.src} -> {e.dst} routes upward; the style says never do that") + for c, piece in result: + existing = pieces.get(c) + if existing is None: + pieces[c] = piece + elif piece.label and not existing.label: + existing.label = piece.label + + xs = [c[0] for c in list(blocked) + list(pieces)] + ys = [c[1] for c in list(blocked) + list(pieces)] + minx, maxx, miny, maxy = min(xs), max(xs), min(ys), max(ys) + cols = " ".join(NODE_COL if x % 2 == 0 else EDGE_COL for x in range(minx, maxx + 1)) + rows = " ".join(NODE_ROW if y % 2 == 0 else EDGE_ROW for y in range(miny, maxy + 1)) + + def area(c: Cell, span: Cell | None = None) -> str: + x0, y0 = c[0] - minx + 1, c[1] - miny + 1 + if span is None: + return f"grid-column:{x0};grid-row:{y0};" + x1, y1 = span[0] - minx + 2, span[1] - miny + 2 + return f"grid-column:{x0}/{x1};grid-row:{y0}/{y1};" + + steps = [s.strip() for s in spec.meta.get("steps", "").split(",") if s.strip()] + step_of = {nid: i + 1 for i, nid in enumerate(steps)} + for nid in steps: + if nid not in spec.nodes: + warn(f"steps: unknown node '{nid}'") + + def group_chain(nid: str) -> list[str]: + chain: list[str] = [] + gid = spec.nodes[nid].group + while gid is not None: + chain.append(gid) + gid = spec.groups[gid].parent + return chain + + members_of = { + gid: [nid for nid in spec.nodes if gid in group_chain(nid)] for gid in spec.groups + } + + def nesting_depth(gid: str) -> int: + children = [g.id for g in spec.groups.values() if g.parent == gid and members_of[g.id]] + return 1 + max(nesting_depth(c) for c in children) if children else 0 + + items: list[str] = [] + for gid in sorted(spec.groups, key=nesting_depth, reverse=True): + members = [node_cells[nid] for nid in members_of[gid]] + if not members: + continue + lo = (min(c[0] for c in members), min(c[1] for c in members)) + hi = (max(c[0] for c in members), max(c[1] for c in members)) + for nid, c in node_cells.items(): + if nid not in members_of[gid] and lo[0] <= c[0] <= hi[0] and lo[1] <= c[1] <= hi[1]: + warn(f"group '{gid}' box also covers node '{nid}'") + items.append(render_group(spec.groups[gid], area(lo, hi), 14 + 22 * nesting_depth(gid))) + for nid, n in spec.nodes.items(): + items.append(render_node(n, area(node_cells[nid]), step_of.get(nid), ports[nid])) + for c, p in pieces.items(): + items.append(render_piece(p, area(c))) + + grid = ( + f'
' + "".join(items) + "
" + ) + note = spec.meta.get("note", "") + note_html = ( + f'
' + f'
' + f'
{esc(note)}
' + if note + else "" + ) + for key in ("fig", "pattern", "caption"): + if key in spec.meta: + warn(f"'%% {key}:' is ignored; figures carry no label or caption") + + width = spec.meta.get("width", "1200").rstrip("px") + title = spec.meta.get("title", "Flow figure") + return f""" + + + + +{esc(title)} +{font_face()} + + + +
+
{grid}{note_html}
+
+ + +""" + + +# ---------------------------------------------------------------- export + +CHROME_CANDIDATES = [ + "google-chrome", + "chromium", + "chromium-browser", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", +] + + +def find_chrome() -> str | None: + for c in CHROME_CANDIDATES: + if os.path.isabs(c) and os.path.exists(c): + return c + found = shutil.which(c) + if found: + return found + return None + + +def run_chrome_proc(proc: subprocess.Popen, done: Callable[[], bool], timeout: float = 60) -> None: + """Wait until `done()` reports Chrome's output is complete, then kill Chrome. + + Headless Chrome reliably produces its output but does not reliably exit + afterwards (its shutdown can wait on component extensions), so waiting + for the process is not an option. + """ + deadline = time.monotonic() + timeout + try: + while time.monotonic() < deadline: + if done(): + return + if proc.poll() is not None: + raise SystemExit("flowfig: Chrome exited without producing the export") + time.sleep(0.2) + raise SystemExit(f"flowfig: Chrome did not produce output within {timeout:.0f} seconds") + finally: + if proc.poll() is None: + proc.kill() + proc.wait() + + +def export_png(html_path: str, png_path: str, scale: float, width: int) -> None: + chrome = find_chrome() + if not chrome: + raise SystemExit("flowfig: no Chrome or Chromium found for --png") + url = "file://" + os.path.abspath(html_path) + workdir = tempfile.mkdtemp(prefix="flowfig-chrome-") + base = [ + chrome, + "--headless=new", + f"--user-data-dir={os.path.join(workdir, 'profile')}", + "--no-first-run", + "--no-default-browser-check", + "--use-mock-keychain", + "--disable-extensions", + "--disable-background-networking", + "--disable-component-update", + "--hide-scrollbars", + "--disable-gpu", + ] + dom_path = os.path.join(workdir, "dom.html") + png_tmp = os.path.join(workdir, "shot.png") + + def dom_done() -> bool: + return os.path.exists(dom_path) and open(dom_path, "rb").read().rstrip().endswith(b"") + + def png_done() -> bool: + return os.path.exists(png_tmp) and open(png_tmp, "rb").read()[-8:] == b"IEND\xaeB`\x82" + + try: + with open(dom_path, "wb") as fh: + proc_args = base + [f"--window-size={width},800", "--dump-dom", url] + proc = subprocess.Popen(proc_args, stdout=fh, stderr=subprocess.DEVNULL) + run_chrome_proc(proc, dom_done) + dom = open(dom_path, "rb").read() + m = re.search(rb'
]*data-h="(\d+)"', dom) + height = int(m.group(1)) + 80 if m else 800 + m = re.search(rb'
]*data-w="(\d+)"', dom) + if m: + width = max(width, int(m.group(1)) + 64) + run_chrome_proc( + subprocess.Popen( + base + + [ + f"--window-size={width},{height}", + f"--force-device-scale-factor={scale}", + f"--screenshot={png_tmp}", + url, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ), + png_done, + ) + shutil.move(png_tmp, png_path) + finally: + shutil.rmtree(workdir, ignore_errors=True) + + +MEASURE_SCRIPT = ( + "" +) + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("spec", nargs="?", help="Mermaid spec file (default: stdin)") + ap.add_argument("-o", "--out", help="HTML output path (default: SPEC with .html, or stdout)") + ap.add_argument("--png", help="also export a PNG through headless Chrome") + ap.add_argument("--scale", type=float, default=2, help="device scale factor for --png (default 2)") + args = ap.parse_args(argv) + + text = open(args.spec, encoding="utf-8").read() if args.spec else sys.stdin.read() + try: + spec = Parser().parse(text) + except ValueError as exc: + raise SystemExit(f"flowfig: {exc}") + if not spec.nodes: + raise SystemExit("flowfig: the spec has no nodes") + page = render(spec) + + out = args.out + if out is None and args.spec: + out = os.path.splitext(args.spec)[0] + ".html" + if out: + with open(out, "w", encoding="utf-8") as fh: + fh.write(page) + print(out) + else: + sys.stdout.write(page) + + if args.png: + width = int(spec.meta.get("width", "1200").rstrip("px")) + 64 + with tempfile.TemporaryDirectory() as tmp: + measured = os.path.join(tmp, "fig.html") + with open(measured, "w", encoding="utf-8") as fh: + fh.write(page.replace("", MEASURE_SCRIPT + "")) + export_png(measured, args.png, args.scale, width) + print(args.png) + return 0 + + +if __name__ == "__main__": + sys.exit(main())