@bovinphang/frontend-craft 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +378 -339
- package/CHANGELOG.zh-CN.md +377 -338
- package/CONTRIBUTING.md +371 -361
- package/CONTRIBUTING.zh-CN.md +371 -361
- package/README.md +545 -493
- package/README.openclaw.md +359 -320
- package/README.openclaw.zh-CN.md +359 -320
- package/README.zh-CN.md +545 -493
- package/agents/fec-code-reviewer.md +24 -3
- package/agents/fec-debugger.md +1 -1
- package/agents/fec-doc-updater.md +1 -1
- package/agents/fec-e2e-runner.md +1 -1
- package/agents/fec-figma-implementer.md +1 -1
- package/agents/fec-performance-optimizer.md +1 -1
- package/agents/fec-refactor-cleaner.md +4 -1
- package/agents/fec-refactoring-expert.md +81 -0
- package/agents/fec-security-reviewer.md +17 -1
- package/agents/fec-test-planner.md +1 -1
- package/agents/fec-typescript-reviewer.md +24 -8
- package/agents/fec-ui-checker.md +11 -11
- package/commands/fec-debug.md +1 -1
- package/commands/fec-refactor-plan.md +17 -0
- package/commands/fec-refactor.md +18 -0
- package/commands/fec-review.md +40 -10
- package/commands/fec-scaffold.md +1 -1
- package/commands/fec-smell.md +17 -0
- package/commands/fec-tdd.md +6 -2
- package/dist/bin/frontend-craft.js +190 -38
- package/dist/hooks/fec-run-tests.js +5 -1
- package/dist/hooks/fec-session-start.js +1 -1
- package/docs/example-prompts.md +157 -140
- package/docs/ja-JP/README.md +534 -493
- package/docs/ko-KR/README.md +534 -493
- package/docs/project-structure.md +151 -131
- package/docs/refactoring/README.md +50 -0
- package/docs/refactoring/catalog.md +105 -0
- package/docs/refactoring/smell-refactoring-matrix.md +41 -0
- package/docs/review-and-diagram-workflows.md +39 -0
- package/docs/runtimes/antigravity.md +18 -10
- package/docs/runtimes/augment.md +18 -10
- package/docs/runtimes/claude.md +191 -177
- package/docs/runtimes/claude.zh-CN.md +189 -175
- package/docs/runtimes/cline.md +18 -10
- package/docs/runtimes/codebuddy.md +18 -10
- package/docs/runtimes/codex.md +18 -10
- package/docs/runtimes/compatibility.md +27 -0
- package/docs/runtimes/copilot.md +18 -10
- package/docs/runtimes/cursor.md +18 -10
- package/docs/runtimes/gemini.md +18 -10
- package/docs/runtimes/kilo.md +18 -10
- package/docs/runtimes/openclaw.md +18 -10
- package/docs/runtimes/opencode.md +18 -10
- package/docs/runtimes/qoder.md +18 -19
- package/docs/runtimes/trae.md +18 -10
- package/docs/runtimes/windsurf.md +18 -10
- package/docs/superpowers/plans/2026-09-26-content-integrity.md +145 -0
- package/docs/superpowers/plans/2026-09-26-diagram-quality.md +170 -0
- package/docs/superpowers/specs/2026-09-26-diagram-quality-design.md +77 -0
- package/docs/superpowers/specs/2026-09-26-project-optimization-design.md +95 -0
- package/docs/zh-CN/example-prompts.md +157 -140
- package/docs/zh-CN/project-structure.md +148 -128
- package/docs/zh-CN/review-and-diagram-workflows.md +39 -0
- package/docs/zh-TW/README.md +534 -493
- package/localized/zh-CN/agents/fec-code-reviewer.md +22 -3
- package/localized/zh-CN/agents/fec-refactor-cleaner.md +3 -0
- package/localized/zh-CN/agents/fec-refactoring-expert.md +53 -0
- package/localized/zh-CN/agents/fec-security-reviewer.md +16 -0
- package/localized/zh-CN/agents/fec-typescript-reviewer.md +23 -7
- package/localized/zh-CN/commands/fec-refactor-plan.md +15 -0
- package/localized/zh-CN/commands/fec-refactor.md +14 -0
- package/localized/zh-CN/commands/fec-review.md +40 -9
- package/localized/zh-CN/commands/fec-smell.md +13 -0
- package/localized/zh-CN/commands/fec-tdd.md +4 -0
- package/localized/zh-CN/skills/eval_queries.json +244 -0
- package/localized/zh-CN/skills/fec-code-review/SKILL.md +20 -1
- package/localized/zh-CN/skills/fec-code-review/references/report-template.md +11 -1
- package/localized/zh-CN/skills/fec-code-smells/SKILL.md +33 -0
- package/localized/zh-CN/skills/fec-code-smells/references/detection-guide.md +25 -0
- package/localized/zh-CN/skills/fec-code-smells/references/smell-catalog.md +817 -0
- package/localized/zh-CN/skills/fec-code-smells/references/smell-to-refactoring-map.md +265 -0
- package/localized/zh-CN/skills/fec-image-generation/SKILL.md +31 -1
- package/localized/zh-CN/skills/fec-image-generation/assets/quality-examples/README.md +14 -0
- package/localized/zh-CN/skills/fec-image-generation/assets/quality-examples/architecture.json +121 -0
- package/localized/zh-CN/skills/fec-image-generation/assets/quality-examples/invalid-explicit.json +41 -0
- package/localized/zh-CN/skills/fec-image-generation/assets/quality-examples/sequence.json +53 -0
- package/localized/zh-CN/skills/fec-image-generation/assets/quality-examples/sequence.mmd +21 -0
- package/localized/zh-CN/skills/fec-image-generation/assets/quality-examples/workflow.json +98 -0
- package/localized/zh-CN/skills/fec-image-generation/assets/quality-examples/workflow.mmd +7 -0
- package/localized/zh-CN/skills/fec-image-generation/references/diagram-workflows.md +10 -3
- package/localized/zh-CN/skills/fec-image-generation/references/html-technical-diagrams.md +8 -1
- package/localized/zh-CN/skills/fec-image-generation/references/png-qa-autofix.md +9 -2
- package/localized/zh-CN/skills/fec-image-generation/scripts/diagram-browser.mjs +90 -0
- package/localized/zh-CN/skills/fec-image-generation/scripts/diagram-layout.mjs +176 -0
- package/localized/zh-CN/skills/fec-image-generation/scripts/export-diagram.mjs +48 -80
- package/localized/zh-CN/skills/fec-image-generation/scripts/mermaid-render.mjs +64 -0
- package/localized/zh-CN/skills/fec-image-generation/scripts/png-qa.mjs +35 -4
- package/localized/zh-CN/skills/fec-image-generation/scripts/tech-diagram-render.mjs +193 -53
- package/localized/zh-CN/skills/fec-refactor-clean/SKILL.md +3 -0
- package/localized/zh-CN/skills/fec-refactoring/SKILL.md +83 -0
- package/localized/zh-CN/skills/fec-refactoring/references/principles.md +43 -0
- package/localized/zh-CN/skills/fec-refactoring/references/refactoring-vs-feature-change.md +25 -0
- package/localized/zh-CN/skills/fec-refactoring/references/when-to-refactor.md +17 -0
- package/localized/zh-CN/skills/fec-refactoring/references/workflow.md +59 -0
- package/localized/zh-CN/skills/fec-refactoring-api/SKILL.md +25 -0
- package/localized/zh-CN/skills/fec-refactoring-api/references/api-refactorings.md +496 -0
- package/localized/zh-CN/skills/fec-refactoring-catalog/SKILL.md +21 -0
- package/localized/zh-CN/skills/fec-refactoring-catalog/references/catalog-index.md +99 -0
- package/localized/zh-CN/skills/fec-refactoring-catalog/references/composition-guide.md +49 -0
- package/localized/zh-CN/skills/fec-refactoring-catalog/references/selection-guide.md +41 -0
- package/localized/zh-CN/skills/fec-refactoring-control/SKILL.md +25 -0
- package/localized/zh-CN/skills/fec-refactoring-control/references/conditional-refactorings.md +299 -0
- package/localized/zh-CN/skills/fec-refactoring-data/SKILL.md +25 -0
- package/localized/zh-CN/skills/fec-refactoring-data/references/data-refactorings.md +248 -0
- package/localized/zh-CN/skills/fec-refactoring-encapsulation/SKILL.md +25 -0
- package/localized/zh-CN/skills/fec-refactoring-encapsulation/references/encapsulation-refactorings.md +452 -0
- package/localized/zh-CN/skills/fec-refactoring-functions/SKILL.md +25 -0
- package/localized/zh-CN/skills/fec-refactoring-functions/references/function-refactorings.md +567 -0
- package/localized/zh-CN/skills/fec-refactoring-inheritance/SKILL.md +25 -0
- package/localized/zh-CN/skills/fec-refactoring-inheritance/references/inheritance-refactorings.md +550 -0
- package/localized/zh-CN/skills/fec-refactoring-move-features/SKILL.md +25 -0
- package/localized/zh-CN/skills/fec-refactoring-move-features/references/moving-refactorings.md +453 -0
- package/localized/zh-CN/skills/fec-refactoring-validation/SKILL.md +52 -0
- package/localized/zh-CN/skills/fec-refactoring-validation/references/behavior-preservation.md +15 -0
- package/localized/zh-CN/skills/fec-refactoring-validation/references/refactoring-test-checklist.md +25 -0
- package/localized/zh-CN/skills/fec-security-review/SKILL.md +16 -0
- package/localized/zh-CN/skills/fec-security-review/references/report-template.md +10 -0
- package/localized/zh-CN/skills/fec-tdd-workflow/SKILL.md +3 -0
- package/localized/zh-CN/skills/metadata.json +408 -50
- package/localized/zh-CN/skills/relations.json +271 -3
- package/localized/zh-CN/templates/shared/rules/fec-agent-workflow.md +1 -0
- package/openclaw.plugin.json +2 -3
- package/package.json +27 -17
- package/skills/eval_queries.json +244 -0
- package/skills/fec-code-review/SKILL.md +20 -1
- package/skills/fec-code-review/references/report-template.md +11 -1
- package/skills/fec-code-smells/SKILL.md +33 -0
- package/skills/fec-code-smells/references/detection-guide.md +25 -0
- package/skills/fec-code-smells/references/smell-catalog.md +861 -0
- package/skills/fec-code-smells/references/smell-to-refactoring-map.md +265 -0
- package/skills/fec-data-fetching/SKILL.md +1 -1
- package/skills/fec-image-generation/SKILL.md +31 -1
- package/skills/fec-image-generation/assets/quality-examples/README.md +14 -0
- package/skills/fec-image-generation/assets/quality-examples/architecture.json +121 -0
- package/skills/fec-image-generation/assets/quality-examples/invalid-explicit.json +41 -0
- package/skills/fec-image-generation/assets/quality-examples/sequence.json +53 -0
- package/skills/fec-image-generation/assets/quality-examples/sequence.mmd +21 -0
- package/skills/fec-image-generation/assets/quality-examples/workflow.json +98 -0
- package/skills/fec-image-generation/assets/quality-examples/workflow.mmd +7 -0
- package/skills/fec-image-generation/references/diagram-workflows.md +10 -3
- package/skills/fec-image-generation/references/html-technical-diagrams.md +8 -1
- package/skills/fec-image-generation/references/png-qa-autofix.md +9 -2
- package/skills/fec-image-generation/scripts/diagram-browser.mjs +90 -0
- package/skills/fec-image-generation/scripts/diagram-layout.mjs +176 -0
- package/skills/fec-image-generation/scripts/export-diagram.mjs +48 -80
- package/skills/fec-image-generation/scripts/mermaid-render.mjs +64 -0
- package/skills/fec-image-generation/scripts/png-qa.mjs +35 -4
- package/skills/fec-image-generation/scripts/tech-diagram-render.mjs +193 -53
- package/skills/fec-legacy-web-standard/SKILL.md +1 -1
- package/skills/fec-refactor-clean/SKILL.md +3 -0
- package/skills/fec-refactoring/SKILL.md +83 -0
- package/skills/fec-refactoring/references/principles.md +60 -0
- package/skills/fec-refactoring/references/refactoring-vs-feature-change.md +27 -0
- package/skills/fec-refactoring/references/when-to-refactor.md +31 -0
- package/skills/fec-refactoring/references/workflow.md +90 -0
- package/skills/fec-refactoring-api/SKILL.md +25 -0
- package/skills/fec-refactoring-api/references/api-refactorings.md +496 -0
- package/skills/fec-refactoring-catalog/SKILL.md +28 -0
- package/skills/fec-refactoring-catalog/references/catalog-index.md +99 -0
- package/skills/fec-refactoring-catalog/references/composition-guide.md +49 -0
- package/skills/fec-refactoring-catalog/references/selection-guide.md +47 -0
- package/skills/fec-refactoring-control/SKILL.md +25 -0
- package/skills/fec-refactoring-control/references/conditional-refactorings.md +299 -0
- package/skills/fec-refactoring-data/SKILL.md +25 -0
- package/skills/fec-refactoring-data/references/data-refactorings.md +248 -0
- package/skills/fec-refactoring-encapsulation/SKILL.md +25 -0
- package/skills/fec-refactoring-encapsulation/references/encapsulation-refactorings.md +452 -0
- package/skills/fec-refactoring-functions/SKILL.md +25 -0
- package/skills/fec-refactoring-functions/references/function-refactorings.md +567 -0
- package/skills/fec-refactoring-inheritance/SKILL.md +25 -0
- package/skills/fec-refactoring-inheritance/references/inheritance-refactorings.md +550 -0
- package/skills/fec-refactoring-move-features/SKILL.md +25 -0
- package/skills/fec-refactoring-move-features/references/moving-refactorings.md +453 -0
- package/skills/fec-refactoring-validation/SKILL.md +59 -0
- package/skills/fec-refactoring-validation/references/behavior-preservation.md +53 -0
- package/skills/fec-refactoring-validation/references/refactoring-test-checklist.md +42 -0
- package/skills/fec-security-review/SKILL.md +16 -0
- package/skills/fec-security-review/references/report-template.md +10 -0
- package/skills/fec-tdd-workflow/SKILL.md +5 -1
- package/skills/metadata.json +434 -71
- package/skills/relations.json +264 -0
- package/templates/shared/rules/fec-agent-workflow.md +1 -0
- package/docs/superpowers/plans/2026-05-29-fec-debug.md +0 -491
- package/docs/superpowers/specs/2026-05-29-fec-debug-design.md +0 -340
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
| ER diagram | Mermaid ER, DBML, Graphviz, SVG | Cardinality labels, table names, key fields, crossing relations. |
|
|
8
8
|
| UML class | Mermaid class, PlantUML, Graphviz | Inheritance direction, method/property wrapping, package grouping. |
|
|
9
9
|
| Sequence | Mermaid sequence, PlantUML | Lifeline order, activation spans, async/return arrows, message text. |
|
|
10
|
-
| Technical architecture | HTML `architecture` IR,
|
|
10
|
+
| Technical architecture | draw.io, Mermaid/C4, HTML `architecture` IR, SVG/HTML | Layer grouping, trust boundaries, data flow direction, legends. |
|
|
11
11
|
| Agent / memory architecture | HTML `architecture` IR with semantic node types and flow arrows, SVG/HTML, draw.io | Read/write path separation, tool-call loops, memory tiers, retrieval labels, flow legend. |
|
|
12
12
|
| ML / deep learning | SVG/HTML/canvas | Tensor shapes, layer order, branch joins, repeated blocks, annotations. |
|
|
13
|
-
| Flowchart / process workflow | HTML `workflow` IR,
|
|
13
|
+
| Flowchart / process workflow | Mermaid flowchart, HTML `workflow` IR, SVG, draw.io | Decision labels, terminal states, loop-back readability, connector spacing, exception path clarity. |
|
|
14
14
|
| Live session sketch | Interactive local browser server | Incremental reveal, session isolation, readable labels after manual dragging, export handoff. |
|
|
15
15
|
|
|
16
16
|
## Interactive live diagrams
|
|
@@ -50,7 +50,7 @@ Coordinates are PNG pixel coordinates after export. Boxes should cover visible l
|
|
|
50
50
|
## Diagram repair playbook
|
|
51
51
|
|
|
52
52
|
- **Overlapping nodes:** increase rank/column spacing, split clusters, or move low-priority annotations into a legend.
|
|
53
|
-
- **Truncated labels:** widen the node, add manual line breaks,
|
|
53
|
+
- **Truncated labels:** widen the node, add manual line breaks, preserve the full text, and adjust source layout.
|
|
54
54
|
- **Connector through labels:** add waypoints, route orthogonally around boxes, or move labels above the segment.
|
|
55
55
|
- **Connector stacking:** separate parallel edges with different waypoints or bundle them with a shared labeled bus.
|
|
56
56
|
- **Edge clipping:** increase outer padding or export viewBox/canvas size.
|
|
@@ -72,3 +72,10 @@ Use HTML `workflow` IR when the user wants a browser-ready process map, approval
|
|
|
72
72
|
## Export guidance
|
|
73
73
|
|
|
74
74
|
Prefer exporting at 2x scale for raster PNGs, then downsample only if text remains crisp. Keep the editable source committed or attached wherever future diagram changes are expected.
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
## Quality delivery contract
|
|
78
|
+
|
|
79
|
+
Prefer local Mermaid for standard sequence/workflow diagrams, reuse fec-drawio-studio for manually editable architecture, and optionally use Graphviz for dense topology. Fall back to compatible JSON/HTML if local tools are missing, without automatic installation/downloads. Preview, standalone SVG and PNG share source theme styles; export with `--theme light|dark`. PNG export waits for fonts and measures text; use `--manifest source.layout.json --output-manifest image.actual.json` for scaled pixel coordinates. QA must read the actual manifest, not reuse estimated coordinates unchanged for a 2x PNG.
|
|
80
|
+
|
|
81
|
+
The extended manifest retains canvas/boxes/connectors and adds coordinateSpace, scale, measurement, groups, labels and issues. Group containment is not node overlap. New checks include node-text-overflow, label-content-loss, label-out-of-bounds, edge-label-collision, text-overlap and degenerate-connector. Legacy manifests retain estimated checks. Diagnose constrained explicit sizes/waypoints without dropping text, changing relationships or silently moving coordinates. Increasing export resolution cannot fix source overflow. Visually review the final PNG and record tool availability/version, theme, scale, repair rounds and remaining defects; report partial acceptance when unresolved.
|
|
@@ -226,7 +226,7 @@ node skills/fec-image-generation/scripts/png-qa.mjs \
|
|
|
226
226
|
Repair the source JSON or rendering dimensions rather than editing pixels:
|
|
227
227
|
|
|
228
228
|
- Overlap: split dense lanes or add rows/lanes/stages.
|
|
229
|
-
- Truncated label:
|
|
229
|
+
- Truncated label: wrap complete labels, preserve text, or widen nodes.
|
|
230
230
|
- Connector through label: reduce edge labels or separate nearby nodes.
|
|
231
231
|
- Dense flow: split into overview and detail diagrams.
|
|
232
232
|
- Edge clipping: increase output space or simplify the path.
|
|
@@ -234,3 +234,10 @@ Repair the source JSON or rendering dimensions rather than editing pixels:
|
|
|
234
234
|
## Delivery Notes
|
|
235
235
|
|
|
236
236
|
Deliver the `.html` source together with any exported SVG/PNG/JPG requested by the user. Report the route used, source file, exported file, QA result, and any naming or domain assumptions that still need human confirmation.
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
## Quality delivery contract
|
|
240
|
+
|
|
241
|
+
Prefer local Mermaid for standard sequence/workflow diagrams, reuse fec-drawio-studio for manually editable architecture, and optionally use Graphviz for dense topology. Fall back to compatible JSON/HTML if local tools are missing, without automatic installation/downloads. Preview, standalone SVG and PNG share source theme styles; export with `--theme light|dark`. PNG export waits for fonts and measures text; use `--manifest source.layout.json --output-manifest image.actual.json` for scaled pixel coordinates. QA must read the actual manifest, not reuse estimated coordinates unchanged for a 2x PNG.
|
|
242
|
+
|
|
243
|
+
The extended manifest retains canvas/boxes/connectors and adds coordinateSpace, scale, measurement, groups, labels and issues. Group containment is not node overlap. New checks include node-text-overflow, label-content-loss, label-out-of-bounds, edge-label-collision, text-overlap and degenerate-connector. Legacy manifests retain estimated checks. Diagnose constrained explicit sizes/waypoints without dropping text, changing relationships or silently moving coordinates. Increasing export resolution cannot fix source overflow. Visually review the final PNG and record tool availability/version, theme, scale, repair rounds and remaining defects; report partial acceptance when unresolved.
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
| ----- | ------ |
|
|
15
15
|
| `blank-image` | Check export target, renderer errors, hidden layers, transparent fill, or failed image generation. |
|
|
16
16
|
| `edge-clipping` | Add canvas padding, expand SVG viewBox, reduce subject scale, or export a larger frame. |
|
|
17
|
-
| `box-overlap` | Increase spacing, reflow to grid/layers, split clusters, or
|
|
17
|
+
| `box-overlap` | Increase spacing, reflow to grid/layers, split clusters, or wrap complete labels. |
|
|
18
18
|
| `box-out-of-bounds` | Move the node inside canvas or increase canvas dimensions. |
|
|
19
|
-
| `label-overflow` | Wrap text, widen the box,
|
|
19
|
+
| `label-overflow` | Wrap text, widen the box, preserve complete copy, and increase font/container size together. |
|
|
20
20
|
| `connector-through-label` | Add waypoints around boxes, move labels, or use orthogonal routing. |
|
|
21
21
|
| `connector-stacking` | Offset parallel connectors or replace repeated lines with a labeled grouped path. |
|
|
22
22
|
| `manifest-canvas-mismatch` | Regenerate the manifest from final export dimensions. |
|
|
@@ -28,3 +28,10 @@ The helper can only reason about overlaps and connectors when the manifest coord
|
|
|
28
28
|
## Reporting
|
|
29
29
|
|
|
30
30
|
Report the number of QA rounds and the final issue list. If issues remain because the source renderer cannot expose layout coordinates, say which checks were image-only and which require manual visual review.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
## Quality delivery contract
|
|
34
|
+
|
|
35
|
+
Prefer local Mermaid for standard sequence/workflow diagrams, reuse fec-drawio-studio for manually editable architecture, and optionally use Graphviz for dense topology. Fall back to compatible JSON/HTML if local tools are missing, without automatic installation/downloads. Preview, standalone SVG and PNG share source theme styles; export with `--theme light|dark`. PNG export waits for fonts and measures text; use `--manifest source.layout.json --output-manifest image.actual.json` for scaled pixel coordinates. QA must read the actual manifest, not reuse estimated coordinates unchanged for a 2x PNG.
|
|
36
|
+
|
|
37
|
+
The extended manifest retains canvas/boxes/connectors and adds coordinateSpace, scale, measurement, groups, labels and issues. Group containment is not node overlap. New checks include node-text-overflow, label-content-loss, label-out-of-bounds, edge-label-collision, text-overlap and degenerate-connector. Legacy manifests retain estimated checks. Diagnose constrained explicit sizes/waypoints without dropping text, changing relationships or silently moving coordinates. Increasing export resolution cannot fix source overflow. Visually review the final PNG and record tool availability/version, theme, scale, repair rounds and remaining defects; report partial acceptance when unresolved.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
export function findBrowser() {
|
|
9
|
+
const candidates = [process.env.FEC_BROWSER_PATH,
|
|
10
|
+
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
|
|
11
|
+
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
|
|
12
|
+
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
|
13
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
14
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
15
|
+
"google-chrome", "chromium", "chromium-browser", "msedge", "brave-browser", "vivaldi"];
|
|
16
|
+
for (const candidate of candidates) {
|
|
17
|
+
if (!candidate) continue;
|
|
18
|
+
if (path.isAbsolute(candidate) ? fs.existsSync(candidate) : spawnSync(candidate, ["--version"], { timeout: 5000, windowsHide: true, stdio: "ignore" }).status === 0) return candidate;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @typedef {{evaluate:(expression:string)=>Promise<unknown>,screenshot:(width:number,height:number,scale?:number)=>Promise<Buffer>}} DiagramSession */
|
|
24
|
+
|
|
25
|
+
/** @template T @param {string} html @param {{theme:'light'|'dark',timeoutMs:number}} options @param {(session:DiagramSession)=>Promise<T>} action @returns {Promise<T>} */
|
|
26
|
+
export async function withDiagramPage(html, options, action) {
|
|
27
|
+
const browser = findBrowser();
|
|
28
|
+
if (!browser) throw new Error("PNG/JPG export requires a local Chromium browser. Export SVG instead, or install a Chromium browser and rerun this command.");
|
|
29
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fec-diagram-browser-"));
|
|
30
|
+
const page = path.join(dir, "diagram.html"), profile = path.join(dir, "profile");
|
|
31
|
+
fs.writeFileSync(page, html);
|
|
32
|
+
const child = spawn(browser, ["--headless=new", "--disable-gpu", "--no-first-run", "--no-default-browser-check", "--hide-scrollbars", "--remote-debugging-port=0", "--remote-allow-origins=http://localhost", `--user-data-dir=${profile}`, pathToFileURL(page).href], { windowsHide: true, stdio: "ignore" });
|
|
33
|
+
let childError = "";
|
|
34
|
+
child.on("error", (error) => { childError = error.message; });
|
|
35
|
+
/** @type {WebSocket | undefined} */
|
|
36
|
+
let socket;
|
|
37
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
38
|
+
let timer;
|
|
39
|
+
try {
|
|
40
|
+
const work = async () => {
|
|
41
|
+
const deadline = Date.now() + Math.min(5000, options.timeoutMs);
|
|
42
|
+
const portFile = path.join(profile, "DevToolsActivePort");
|
|
43
|
+
while (!fs.existsSync(portFile) && Date.now() < deadline && (child.exitCode === null || child.exitCode === 0) && !childError) await new Promise((resolve) => setTimeout(resolve, 50));
|
|
44
|
+
if (!fs.existsSync(portFile)) throw new Error(`Browser raster export failed: browser did not start a debugging session${childError ? ` (${childError})` : ""}.`);
|
|
45
|
+
const port = Number(fs.readFileSync(portFile, "utf8").split("\n")[0]);
|
|
46
|
+
const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(Math.min(5000, options.timeoutMs)) })).json();
|
|
47
|
+
const target = pages.find(/** @param {{type:string}} p */ (p) => p.type === "page");
|
|
48
|
+
if (!target?.webSocketDebuggerUrl) throw new Error("Browser did not create a diagram page.");
|
|
49
|
+
socket = new WebSocket(target.webSocketDebuggerUrl);
|
|
50
|
+
await new Promise((resolve, reject) => { socket?.addEventListener("open", resolve, { once: true }); socket?.addEventListener("error", reject, { once: true }); });
|
|
51
|
+
/** @type {Map<number,{resolve:(value:Record<string,unknown>)=>void,reject:(reason:Error)=>void}>} */
|
|
52
|
+
const pending = new Map();
|
|
53
|
+
socket.addEventListener("close", () => { for (const entry of pending.values()) entry.reject(new Error("Browser session closed before export completed.")); pending.clear(); });
|
|
54
|
+
let serial = 0;
|
|
55
|
+
socket.addEventListener("message", (event) => {
|
|
56
|
+
const message = JSON.parse(String(event.data));
|
|
57
|
+
const entry = pending.get(message.id);
|
|
58
|
+
if (!entry) return;
|
|
59
|
+
pending.delete(message.id);
|
|
60
|
+
if (message.error) entry.reject(new Error(message.error.message)); else entry.resolve(message.result ?? {});
|
|
61
|
+
});
|
|
62
|
+
/** @param {string} method @param {Record<string,unknown>} [params] @returns {Promise<Record<string,unknown>>} */
|
|
63
|
+
const call = (method, params = {}) => new Promise((resolve, reject) => { const id = ++serial; pending.set(id, { resolve, reject }); socket?.send(JSON.stringify({ id, method, params })); });
|
|
64
|
+
/** @param {string} expression */
|
|
65
|
+
const evaluate = async (expression) => {
|
|
66
|
+
const result = await call("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
|
|
67
|
+
if (result.exceptionDetails) throw new Error(`Diagram evaluation failed: ${JSON.stringify(result.exceptionDetails)}`);
|
|
68
|
+
return /** @type {{value?:unknown}} */ (result.result).value;
|
|
69
|
+
};
|
|
70
|
+
/** @type {DiagramSession} */
|
|
71
|
+
const session = { evaluate, screenshot: async (width, height, scale = 1) => {
|
|
72
|
+
await call("Emulation.setDeviceMetricsOverride", { width: Math.ceil(width), height: Math.ceil(height), deviceScaleFactor: scale, mobile: false });
|
|
73
|
+
const result = await call("Page.captureScreenshot", { format: "png", captureBeyondViewport: true, clip: { x: 0, y: 0, width, height, scale: 1 } });
|
|
74
|
+
return Buffer.from(String(result.data), "base64");
|
|
75
|
+
} };
|
|
76
|
+
await evaluate(`(async()=>{if(document.readyState!=="complete")await new Promise(r=>window.addEventListener("load",r,{once:true}));await document.fonts.ready;await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));})()`);
|
|
77
|
+
return await action(session);
|
|
78
|
+
};
|
|
79
|
+
return await Promise.race([work(), new Promise(/** @param {(reason:Error)=>void} reject */ (_, reject) => { timer = setTimeout(() => reject(new Error("Browser export timed out.")), options.timeoutMs); })]);
|
|
80
|
+
} finally {
|
|
81
|
+
if (timer) clearTimeout(timer);
|
|
82
|
+
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ id: 999999, method: "Browser.close" }));
|
|
83
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
84
|
+
socket?.close();
|
|
85
|
+
child.kill();
|
|
86
|
+
// The Chromium profile is owned by this invocation only. Windows may release locks after exit.
|
|
87
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
88
|
+
try { fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch { /* A locked temporary profile can be removed by OS cleanup. */ }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/** @typedef {{x:number,y:number,width:number,height:number}} Rect */
|
|
4
|
+
/** @typedef {[number,number]} Point */
|
|
5
|
+
/** @typedef {{code:string,id:string,message:string}} QualityIssue */
|
|
6
|
+
/** @typedef {{lines:string[],width:number,height:number,fontSize:number,lineHeight:number}} TextLayout */
|
|
7
|
+
/** @typedef {Rect & {id:string,label:string,sublabel:string,type:string,actor?:string,step?:string,shape?:"box"|"diamond"|"terminal"}} Box */
|
|
8
|
+
|
|
9
|
+
/** @param {string} text @param {number} fontSize */
|
|
10
|
+
function textWidth(text, fontSize) {
|
|
11
|
+
return [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)]
|
|
12
|
+
.reduce((sum, item) => sum + (/^[\x00-\x7f]+$/.test(item.segment) ? fontSize * 0.6 : fontSize), 0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** @param {string} text @param {number} maxWidth @param {number} [fontSize] @param {number} [lineHeight] @returns {TextLayout} */
|
|
16
|
+
export function layoutText(text, maxWidth, fontSize = 14, lineHeight = 20) {
|
|
17
|
+
const limit = Math.max(fontSize, maxWidth);
|
|
18
|
+
/** @type {string[]} */
|
|
19
|
+
const lines = [];
|
|
20
|
+
for (const paragraph of text.split(/\r?\n/)) {
|
|
21
|
+
let line = "";
|
|
22
|
+
for (const { segment: word } of new Intl.Segmenter(undefined, { granularity: "word" }).segment(paragraph)) {
|
|
23
|
+
if (line && textWidth(word,fontSize) <= limit && textWidth(line+word,fontSize)>limit) {lines.push(line);line="";}
|
|
24
|
+
for (const { segment } of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(word)) {
|
|
25
|
+
if (line && textWidth(line + segment, fontSize) > limit) {
|
|
26
|
+
lines.push(line);
|
|
27
|
+
line = "";
|
|
28
|
+
}
|
|
29
|
+
line += segment;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
lines.push(line);
|
|
33
|
+
}
|
|
34
|
+
return { lines, width: Math.max(0, ...lines.map((line) => textWidth(line, fontSize))), height: lines.length * lineHeight, fontSize, lineHeight };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @param {Box} source @param {{width:boolean,height:boolean}} explicit */
|
|
38
|
+
export function fitNodeText(source, explicit) {
|
|
39
|
+
const box = { ...source };
|
|
40
|
+
if (![box.width, box.height].every((v) => Number.isFinite(v) && v > 0)) throw new Error(`Invalid size for node "${box.id}".`);
|
|
41
|
+
const diamond = box.shape === "diamond" || box.type === "decision";
|
|
42
|
+
const semantic = ["agent", "model", "memory", "vectorstore", "graphdb", "tool", "document", "queue", "browser", "user", "gateway"].includes(box.type);
|
|
43
|
+
if (!explicit.width) box.width = Math.max(box.width, diamond ? 240 : 200);
|
|
44
|
+
const available = diamond ? box.width * 0.5 - 12 : (box.type === "queue" ? box.width - 64 : box.width - 32);
|
|
45
|
+
const main = layoutText(box.label, available);
|
|
46
|
+
const sub = layoutText(box.sublabel, available, 12, 16);
|
|
47
|
+
const header = ["database", "vectorstore"].includes(box.type) ? 30 : box.type === "queue" ? 20 : semantic ? 46 : box.actor ? 40 : 20;
|
|
48
|
+
const contentHeight = main.height + (box.sublabel ? sub.height + 6 : 0);
|
|
49
|
+
const needed = diamond ? (contentHeight + 24) * 2 : header + contentHeight + (["database", "vectorstore"].includes(box.type) ? 30 : 18);
|
|
50
|
+
if (!explicit.height) box.height = Math.max(box.height, needed, 72);
|
|
51
|
+
/** @type {QualityIssue[]} */
|
|
52
|
+
const issues = [];
|
|
53
|
+
if (box.height < needed || available < main.width) issues.push({ code: "node-text-overflow", id: box.id, message: `Node "${box.id}" has an explicit size too small for its complete text.` });
|
|
54
|
+
return { box, labels: [main, sub], issues };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @param {Rect} box @param {'top'|'right'|'bottom'|'left'} side @returns {Point} */
|
|
58
|
+
export function boundaryPort(box, side) {
|
|
59
|
+
if (side === "top") return [box.x + box.width / 2, box.y];
|
|
60
|
+
if (side === "bottom") return [box.x + box.width / 2, box.y + box.height];
|
|
61
|
+
if (side === "left") return [box.x, box.y + box.height / 2];
|
|
62
|
+
return [box.x + box.width, box.y + box.height / 2];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Strict interior intersection; boundary contact is allowed. @param {Point} a @param {Point} b @param {Rect} r */
|
|
66
|
+
export function segmentHits(a, b, r) {
|
|
67
|
+
let lo = 0, hi = 1;
|
|
68
|
+
for (const axis of [0, 1]) {
|
|
69
|
+
const start = axis === 0 ? r.x : r.y;
|
|
70
|
+
const end = start + (axis === 0 ? r.width : r.height);
|
|
71
|
+
const delta = b[axis] - a[axis];
|
|
72
|
+
if (Math.abs(delta) < 1e-8) {
|
|
73
|
+
if (a[axis] <= start + 1e-6 || a[axis] >= end - 1e-6) return false;
|
|
74
|
+
} else {
|
|
75
|
+
const t1 = (start - a[axis]) / delta, t2 = (end - a[axis]) / delta;
|
|
76
|
+
lo = Math.max(lo, Math.min(t1, t2)); hi = Math.min(hi, Math.max(t1, t2));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return hi - lo > 1e-6;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @param {Rect} a @param {Rect} b */
|
|
83
|
+
export function overlaps(a, b) {
|
|
84
|
+
return Math.min(a.x + a.width, b.x + b.width) > Math.max(a.x, b.x) + 1e-6 && Math.min(a.y + a.height, b.y + b.height) > Math.max(a.y, b.y) + 1e-6;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @param {Point[]} points @returns {Point[]} */
|
|
88
|
+
function simplify(points) {
|
|
89
|
+
return points.filter((p, i) => {
|
|
90
|
+
if (i && p[0] === points[i - 1][0] && p[1] === points[i - 1][1]) return false;
|
|
91
|
+
if (!i || i === points.length - 1) return true;
|
|
92
|
+
const prev = points[i - 1], next = points[i + 1];
|
|
93
|
+
return !((prev[0] === p[0] && next[0] === p[0]) || (prev[1] === p[1] && next[1] === p[1]));
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @param {Rect & {id:string}} from @param {Rect & {id:string}} to @param {Rect[]} obstacles @param {Point[][]} occupied @param {Point[]} [waypoints] */
|
|
98
|
+
export function routeConnector(from, to, obstacles, occupied, waypoints) {
|
|
99
|
+
const horizontal = Math.abs(to.x - from.x) >= Math.abs(to.y - from.y);
|
|
100
|
+
const forward = horizontal ? to.x >= from.x : to.y >= from.y;
|
|
101
|
+
const start = boundaryPort(from, horizontal ? (forward ? "right" : "left") : (forward ? "bottom" : "top"));
|
|
102
|
+
const end = boundaryPort(to, horizontal ? (forward ? "left" : "right") : (forward ? "top" : "bottom"));
|
|
103
|
+
/** @type {QualityIssue[]} */
|
|
104
|
+
const issues = [];
|
|
105
|
+
if (waypoints) {
|
|
106
|
+
const points = [start, ...waypoints, end];
|
|
107
|
+
if (points.some((p, i) => i > 0 && obstacles.some((r) => segmentHits(points[i - 1], p, r)))) issues.push({ code: "route-obstructed", id: `${from.id}-${to.id}`, message: "Explicit waypoints cross a non-endpoint node." });
|
|
108
|
+
return { points, issues };
|
|
109
|
+
}
|
|
110
|
+
if (from.id === to.id) {
|
|
111
|
+
const x = from.x + from.width + 40 + occupied.length * 4;
|
|
112
|
+
return { points: /** @type {Point[]} */ ([[from.x + from.width, from.y + from.height / 3], [x, from.y + from.height / 3], [x, from.y + from.height * 2 / 3], [from.x + from.width, from.y + from.height * 2 / 3]]), issues };
|
|
113
|
+
}
|
|
114
|
+
const padded = obstacles.map((r) => ({ x: r.x - 12, y: r.y - 12, width: r.width + 24, height: r.height + 24 }));
|
|
115
|
+
const xSet = new Set([start[0], end[0], 28]), ySet = new Set([start[1], end[1], 28]);
|
|
116
|
+
for (const r of [...padded, from, to]) { xSet.add(r.x - 16); xSet.add(r.x + r.width + 16); ySet.add(r.y - 16); ySet.add(r.y + r.height + 16); }
|
|
117
|
+
for (const route of occupied) for (const [x, y] of route) { xSet.add(x - 16); xSet.add(x + 16); ySet.add(y - 16); ySet.add(y + 16); }
|
|
118
|
+
const xs = [...xSet].filter((x) => x >= 24).sort((a, b) => a - b), ys = [...ySet].filter((y) => y >= 24).sort((a, b) => a - b);
|
|
119
|
+
if (xs.length > 128 || ys.length > 128) return { points: [start, end], issues: [{ code: "route-unresolved", id: `${from.id}-${to.id}`, message: "Routing grid exceeded its bounded search budget." }] };
|
|
120
|
+
const nx = xs.length, ny = ys.length;
|
|
121
|
+
const startId = ys.indexOf(start[1]) * nx + xs.indexOf(start[0]), endId = ys.indexOf(end[1]) * nx + xs.indexOf(end[0]);
|
|
122
|
+
/** @param {number} id @returns {Point} */
|
|
123
|
+
const point = (id) => [xs[id % nx], ys[Math.floor(id / nx)]];
|
|
124
|
+
/** @type {Array<{node:number,dir:number,cost:number,key:string}>} */
|
|
125
|
+
const pending = [{ node: startId, dir: 0, cost: 0, key: `${startId}:0` }];
|
|
126
|
+
const best = new Map([[`${startId}:0`, 0]]), previous = new Map();
|
|
127
|
+
let finish = "";
|
|
128
|
+
while (pending.length) {
|
|
129
|
+
pending.sort((a, b) => a.cost - b.cost || a.node - b.node || a.dir - b.dir);
|
|
130
|
+
const current = pending.shift();
|
|
131
|
+
if (!current || current.cost !== best.get(current.key)) continue;
|
|
132
|
+
if (current.node === endId) { finish = current.key; break; }
|
|
133
|
+
const ix = current.node % nx, iy = Math.floor(current.node / nx);
|
|
134
|
+
for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
|
|
135
|
+
if (ix + dx < 0 || ix + dx >= nx || iy + dy < 0 || iy + dy >= ny) continue;
|
|
136
|
+
const next = (iy + dy) * nx + ix + dx, a = point(current.node), b = point(next);
|
|
137
|
+
if ([...padded, from, to].some((r) => segmentHits(a, b, r))) continue;
|
|
138
|
+
const dir = dx ? 1 : 2;
|
|
139
|
+
let penalty = 0;
|
|
140
|
+
for (const route of occupied) for (let i = 1; i < route.length; i++) {
|
|
141
|
+
const p = route[i - 1], q = route[i];
|
|
142
|
+
if ((a[0] === b[0] && p[0] === q[0] && a[0] === p[0] && Math.min(b[1], a[1]) < Math.max(p[1], q[1]) && Math.max(b[1], a[1]) > Math.min(p[1], q[1])) || (a[1] === b[1] && p[1] === q[1] && a[1] === p[1] && Math.min(b[0], a[0]) < Math.max(p[0], q[0]) && Math.max(b[0], a[0]) > Math.min(p[0], q[0]))) penalty += 80;
|
|
143
|
+
}
|
|
144
|
+
const cost = current.cost + Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]) + (current.dir && current.dir !== dir ? 24 : 0) + penalty;
|
|
145
|
+
const key = `${next}:${dir}`;
|
|
146
|
+
if (cost < (best.get(key) ?? Infinity)) { best.set(key, cost); previous.set(key, current.key); pending.push({ node: next, dir, cost, key }); }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!finish) return { points: [start, end], issues: [{ code: "route-unresolved", id: `${from.id}-${to.id}`, message: "No obstacle-free route exists without moving explicit nodes." }] };
|
|
150
|
+
/** @type {Point[]} */
|
|
151
|
+
const route = [];
|
|
152
|
+
for (let key = finish; key; key = previous.get(key)) route.unshift(point(Number(key.split(":")[0])));
|
|
153
|
+
return { points: simplify(route), issues };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** @param {Point[]} points @param {TextLayout} layout @param {Rect[]} obstacles @param {Point[][]} [routes] */
|
|
157
|
+
export function placeEdgeLabel(points, layout, obstacles, routes = []) {
|
|
158
|
+
const lengths = points.slice(1).map((p, i) => Math.hypot(p[0] - points[i][0], p[1] - points[i][1]));
|
|
159
|
+
let remaining = lengths.reduce((a, b) => a + b, 0) / 2;
|
|
160
|
+
let cx = points[0][0], cy = points[0][1];
|
|
161
|
+
for (let i = 0; i < lengths.length; i++) {
|
|
162
|
+
if (remaining <= lengths[i]) {
|
|
163
|
+
const ratio = lengths[i] ? remaining / lengths[i] : 0;
|
|
164
|
+
cx = points[i][0] + (points[i + 1][0] - points[i][0]) * ratio;
|
|
165
|
+
cy = points[i][1] + (points[i + 1][1] - points[i][1]) * ratio;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
remaining -= lengths[i];
|
|
169
|
+
}
|
|
170
|
+
let rect = { x: cx - layout.width / 2, y: cy - layout.height - 12, width: layout.width, height: layout.height };
|
|
171
|
+
for (const offset of [12,24,36,52,72,96]) for (const direction of ["above","below","left","right"]) {
|
|
172
|
+
const candidate = direction === "above" ? {...rect,y:cy-layout.height-offset} : direction === "below" ? {...rect,y:cy+offset} : direction === "left" ? {...rect,x:cx-layout.width-offset,y:cy-layout.height/2} : {...rect,x:cx+offset,y:cy-layout.height/2};
|
|
173
|
+
if (candidate.x >= 24 && candidate.y >= 24 && !obstacles.some((r) => overlaps(candidate, r)) && !routes.some(route=>route.slice(1).some((point,index)=>segmentHits(route[index],point,{x:candidate.x-4,y:candidate.y-4,width:candidate.width+8,height:candidate.height+8})))) return { rect: candidate, issues: /** @type {QualityIssue[]} */ ([]) };
|
|
174
|
+
}
|
|
175
|
+
return { rect, issues: [{ code: "edge-label-collision", id: "edge", message: "No free label position near route midpoint." }] };
|
|
176
|
+
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// @ts-check
|
|
3
3
|
|
|
4
|
+
import { analyzeManifest } from "./png-qa.mjs";
|
|
5
|
+
import { withDiagramPage } from "./diagram-browser.mjs";
|
|
4
6
|
import { spawnSync } from "node:child_process";
|
|
5
7
|
import fs from "node:fs";
|
|
6
8
|
import os from "node:os";
|
|
7
9
|
import path from "node:path";
|
|
8
|
-
|
|
10
|
+
/** @typedef {{id:string,x:number,y:number,width:number,height:number,[key:string]:unknown}} LabelRect */
|
|
11
|
+
/** @typedef {{canvas:{width:number,height:number},boxes:LabelRect[],groups?:LabelRect[],labels?:LabelRect[],connectors:Array<{points:Array<[number,number]>,[key:string]:unknown}>,[key:string]:unknown}} Manifest */
|
|
9
12
|
|
|
10
13
|
const SUPPORTED_FORMATS = new Set(["svg", "png", "jpg", "jpeg"]);
|
|
11
14
|
const JPEG_FORMATS = new Set(["jpg", "jpeg"]);
|
|
@@ -15,7 +18,7 @@ try {
|
|
|
15
18
|
const inputPath = path.resolve(requireArg(args, "input"));
|
|
16
19
|
const format = requireArg(args, "format").toLowerCase();
|
|
17
20
|
const scale = args.scale === undefined ? 2 : parseScale(args.scale);
|
|
18
|
-
const background = args.background ?? "#0f172a";
|
|
21
|
+
const background = args.background ?? (args.theme === "dark" ? "#0f172a" : "#ffffff");
|
|
19
22
|
|
|
20
23
|
if (!SUPPORTED_FORMATS.has(format)) {
|
|
21
24
|
throw usageError(`Unsupported --format "${format}". Expected svg, png, jpg, or jpeg.`);
|
|
@@ -24,14 +27,48 @@ try {
|
|
|
24
27
|
throw usageError(`Input file does not exist: ${inputPath}`);
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
let svg = extractSvg(inputPath);
|
|
31
|
+
const theme = args.theme ?? "light";
|
|
32
|
+
if (theme !== "light" && theme !== "dark") throw usageError("--theme must be light or dark");
|
|
33
|
+
svg = svg.replace(/data-theme=["'][^"']*["']/, `data-theme="${theme}"`);
|
|
34
|
+
/** @type {Manifest|null} */
|
|
35
|
+
const manifest = args.manifest ? JSON.parse(fs.readFileSync(path.resolve(args.manifest), "utf8")) : null;
|
|
28
36
|
const outputPath = path.resolve(resolveOutputPath(inputPath, format, args.output));
|
|
29
37
|
|
|
38
|
+
for(const metadataPath of [args["output-manifest"],args["qa-report"]]) if(metadataPath) fs.mkdirSync(path.dirname(path.resolve(metadataPath)),{recursive:true});
|
|
30
39
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
31
40
|
if (format === "svg") {
|
|
32
41
|
fs.writeFileSync(outputPath, `${svg.trim()}\n`, "utf8");
|
|
42
|
+
if (manifest && args["output-manifest"]) fs.writeFileSync(path.resolve(args["output-manifest"]), JSON.stringify({...manifest,coordinateSpace:"svg",scale:1,measurement:"estimated"},null,2)+"\n");
|
|
43
|
+
if (args["qa-report"]) fs.writeFileSync(path.resolve(args["qa-report"]),JSON.stringify({status:"estimated",measurement:"estimated",theme,issues:manifest?.issues??[],visualQA:"pending"},null,2)+"\n");
|
|
33
44
|
} else {
|
|
34
|
-
|
|
45
|
+
const dimensions = measureSvg(svg);
|
|
46
|
+
const pageHtml = `<!doctype html><meta charset="utf-8"><style>html,body{margin:0;padding:0;background:${background};}svg.tech-diagram,body>svg{display:block;width:${dimensions.width}px;min-width:0;height:${dimensions.height}px;}</style>${svg}`;
|
|
47
|
+
const result = await withDiagramPage(pageHtml, { theme, timeoutMs: 30000 }, async (session) => {
|
|
48
|
+
await session.evaluate(`(async()=>{let last="";for(let i=0;i<8;i++){let next=Array.from(document.querySelectorAll('[data-label-id]')).map(el=>el.getBBox().width).join(',');if(next===last)return;last=next;await new Promise(r=>requestAnimationFrame(r));}throw new Error('Text layout did not stabilize');})()`);
|
|
49
|
+
const labels = await session.evaluate(`Array.from(document.querySelectorAll('[data-label-id]')).map(el=>{const r=el.getBBox();return {id:el.getAttribute('data-label-id'),x:r.x,y:r.y,width:r.width,height:r.height,fontFamily:getComputedStyle(el).fontFamily,fontSize:getComputedStyle(el).fontSize};})`);
|
|
50
|
+
const png = await session.screenshot(dimensions.width, dimensions.height, scale);
|
|
51
|
+
return { png, labels };
|
|
52
|
+
});
|
|
53
|
+
if (result.png.length < 24 || result.png.readUInt32BE(16) !== Math.ceil(dimensions.width * scale) || result.png.readUInt32BE(20) !== Math.ceil(dimensions.height * scale)) throw usageError("Browser raster export failed: unexpected image dimensions.");
|
|
54
|
+
if(manifest) {
|
|
55
|
+
const measured=/** @type {LabelRect[]} */ (result.labels);
|
|
56
|
+
const missing=(manifest.labels??[]).filter(label=>!measured.some(actual=>actual.id===label.id));
|
|
57
|
+
if(missing.length) throw usageError("Browser measurement missing manifest labels: "+missing.map(label=>label.id).join(", "));
|
|
58
|
+
}
|
|
59
|
+
if (JPEG_FORMATS.has(format)) {
|
|
60
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "fec-diagram-jpeg-"));
|
|
61
|
+
try { const pngPath = path.join(temp, "image.png"); fs.writeFileSync(pngPath, result.png); convertPngToJpeg(pngPath, outputPath); } finally { fs.rmSync(temp, { recursive: true, force: true }); }
|
|
62
|
+
} else fs.writeFileSync(outputPath, result.png);
|
|
63
|
+
if (manifest) {
|
|
64
|
+
manifest.labels = (manifest.labels ?? []).map((label) => ({ ...label, .../** @type {LabelRect[]} */ (result.labels).find((actual) => actual.id === label.id) }));
|
|
65
|
+
manifest.measurement = "browser";
|
|
66
|
+
if (args["output-manifest"]) fs.writeFileSync(path.resolve(args["output-manifest"]), JSON.stringify(scaleManifest(manifest, scale), null, 2) + "\n");
|
|
67
|
+
}
|
|
68
|
+
/** @type {Array<{code:string,message:string}>} */
|
|
69
|
+
const boundaryIssues=[];
|
|
70
|
+
if(manifest) analyzeManifest({width:Math.ceil(dimensions.width*scale),height:Math.ceil(dimensions.height*scale)},scaleManifest(manifest,scale),boundaryIssues,new Set(),false);
|
|
71
|
+
if (args["qa-report"]) fs.writeFileSync(path.resolve(args["qa-report"]), JSON.stringify({ status: boundaryIssues.length ? "partial" : "measured", issues:boundaryIssues, measurement: "browser", theme, width: dimensions.width * scale, height: dimensions.height * scale, note: "Run PNG QA and inspect the final image; browser measurement alone is not approval." }, null, 2) + "\n");
|
|
35
72
|
}
|
|
36
73
|
|
|
37
74
|
console.log(`Exported ${format.toUpperCase()} diagram: ${outputPath}`);
|
|
@@ -126,51 +163,6 @@ function extractSvg(inputPath) {
|
|
|
126
163
|
* @param {number} scale
|
|
127
164
|
* @param {string} background
|
|
128
165
|
*/
|
|
129
|
-
function exportRaster(svg, outputPath, format, scale, background) {
|
|
130
|
-
const browser = findBrowser();
|
|
131
|
-
if (!browser) {
|
|
132
|
-
throw usageError(
|
|
133
|
-
"PNG/JPG export requires a local Chromium browser (Chrome, Edge, Chromium, Brave, or Vivaldi) because Node.js has no built-in SVG rasterizer. Export SVG instead, or install a Chromium browser and rerun this command.",
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "fec-diagram-export-"));
|
|
138
|
-
const htmlPath = path.join(tempDir, "diagram.html");
|
|
139
|
-
const screenshotPath = path.join(tempDir, "diagram.png");
|
|
140
|
-
const { width, height } = measureSvg(svg);
|
|
141
|
-
const viewportWidth = Math.ceil(width * scale);
|
|
142
|
-
const viewportHeight = Math.ceil(height * scale);
|
|
143
|
-
|
|
144
|
-
try {
|
|
145
|
-
fs.writeFileSync(htmlPath, renderScreenshotHtml(svg, width, height, scale, background), "utf8");
|
|
146
|
-
|
|
147
|
-
const result = spawnSync(browser, [
|
|
148
|
-
"--headless=new",
|
|
149
|
-
"--disable-gpu",
|
|
150
|
-
"--hide-scrollbars",
|
|
151
|
-
`--window-size=${viewportWidth},${viewportHeight}`,
|
|
152
|
-
`--screenshot=${screenshotPath}`,
|
|
153
|
-
pathToFileURL(htmlPath).href,
|
|
154
|
-
], {
|
|
155
|
-
encoding: "utf8",
|
|
156
|
-
timeout: 30000,
|
|
157
|
-
windowsHide: true,
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
if (result.status !== 0 || !fs.existsSync(screenshotPath)) {
|
|
161
|
-
throw usageError(`Browser raster export failed: ${result.stderr || result.stdout || `exit code ${result.status}`}`.trim());
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (JPEG_FORMATS.has(format)) {
|
|
165
|
-
convertPngToJpeg(screenshotPath, outputPath);
|
|
166
|
-
} else {
|
|
167
|
-
fs.copyFileSync(screenshotPath, outputPath);
|
|
168
|
-
}
|
|
169
|
-
} finally {
|
|
170
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
166
|
/**
|
|
175
167
|
* @param {string} pngPath
|
|
176
168
|
* @param {string} outputPath
|
|
@@ -259,37 +251,6 @@ function convertWithSips(pngPath, outputPath) {
|
|
|
259
251
|
return result.status === 0;
|
|
260
252
|
}
|
|
261
253
|
|
|
262
|
-
function findBrowser() {
|
|
263
|
-
const envBrowser = process.env.CHROME_PATH || process.env.EDGE_PATH || process.env.BROWSER;
|
|
264
|
-
const candidates = [
|
|
265
|
-
envBrowser,
|
|
266
|
-
process.platform === "win32" ? "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" : undefined,
|
|
267
|
-
process.platform === "win32" ? "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe" : undefined,
|
|
268
|
-
process.platform === "win32" ? "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe" : undefined,
|
|
269
|
-
process.platform === "win32" ? "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe" : undefined,
|
|
270
|
-
process.platform === "darwin" ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" : undefined,
|
|
271
|
-
process.platform === "darwin" ? "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" : undefined,
|
|
272
|
-
"google-chrome",
|
|
273
|
-
"google-chrome-stable",
|
|
274
|
-
"chromium",
|
|
275
|
-
"chromium-browser",
|
|
276
|
-
"microsoft-edge",
|
|
277
|
-
"msedge",
|
|
278
|
-
"brave-browser",
|
|
279
|
-
"vivaldi",
|
|
280
|
-
].filter(Boolean);
|
|
281
|
-
|
|
282
|
-
for (const candidate of candidates) {
|
|
283
|
-
if (typeof candidate !== "string") continue;
|
|
284
|
-
if (path.isAbsolute(candidate) && fs.existsSync(candidate)) return candidate;
|
|
285
|
-
if (!path.isAbsolute(candidate)) {
|
|
286
|
-
const result = spawnSync(candidate, ["--version"], { encoding: "utf8", timeout: 5000, windowsHide: true });
|
|
287
|
-
if (result.status === 0) return candidate;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
return null;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
254
|
/**
|
|
294
255
|
* @param {string} value
|
|
295
256
|
*/
|
|
@@ -362,3 +323,10 @@ svg {
|
|
|
362
323
|
function usageError(message) {
|
|
363
324
|
return new Error(message);
|
|
364
325
|
}
|
|
326
|
+
|
|
327
|
+
/** @param {Manifest} manifest @param {number} scale */
|
|
328
|
+
function scaleManifest(manifest, scale) {
|
|
329
|
+
/** @param {LabelRect} r */
|
|
330
|
+
const rect = (r) => ({ ...r, x: r.x * scale, y: r.y * scale, width: r.width * scale, height: r.height * scale });
|
|
331
|
+
return { ...manifest, coordinateSpace: "png", scale, canvas: { width: Math.ceil(manifest.canvas.width * scale), height: Math.ceil(manifest.canvas.height * scale) }, boxes: manifest.boxes.map(rect), groups: (manifest.groups ?? []).map(rect), labels: (manifest.labels ?? []).map(rect), connectors: manifest.connectors.map((c) => ({ ...c, points: c.points.map(([x, y]) => [x * scale, y * scale]) })) };
|
|
332
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
// Use installed tools only. Never invoke npx or a shell that could download packages.
|
|
9
|
+
try {
|
|
10
|
+
/** @type {Record<string,string>} */
|
|
11
|
+
const args = {};
|
|
12
|
+
for (let i = 2; i < process.argv.length; i += 2) {
|
|
13
|
+
const key = process.argv[i];
|
|
14
|
+
if (!key.startsWith("--") || !process.argv[i + 1]) throw new Error("Expected --input source.mmd --output image.svg [--cli path] [--theme default|dark|neutral|forest] [--timeout 30000] [--report file.json]");
|
|
15
|
+
if (!["input", "output", "cli", "theme", "timeout", "report", "format"].includes(key.slice(2))) throw new Error(`Unknown argument: ${key}`);
|
|
16
|
+
args[key.slice(2)] = process.argv[i + 1];
|
|
17
|
+
}
|
|
18
|
+
if (!args.input || !args.output) throw new Error("--input and --output are required.");
|
|
19
|
+
const input = path.resolve(args.input), output = path.resolve(args.output);
|
|
20
|
+
if (!fs.existsSync(input)) throw new Error(`Input does not exist: ${input}`);
|
|
21
|
+
if (!/\.(svg|png|pdf)$/i.test(output)) throw new Error("Output must end in .svg, .png or .pdf.");
|
|
22
|
+
const theme = args.theme === "light" ? "default" : args.theme ?? "default";
|
|
23
|
+
if (args.format && !["json","markdown"].includes(args.format)) throw new Error("--format must be json or markdown");
|
|
24
|
+
if (!["default", "dark", "neutral", "forest"].includes(theme)) throw new Error("Unsupported Mermaid theme.");
|
|
25
|
+
const timeout = Number(args.timeout ?? 30000);
|
|
26
|
+
if (!Number.isFinite(timeout) || timeout < 100 || timeout > 300000) throw new Error("--timeout must be between 100 and 300000 milliseconds.");
|
|
27
|
+
/** @type {string[]} */
|
|
28
|
+
const candidates = args.cli ? [path.resolve(args.cli)] : (process.env.PATH ?? "").split(path.delimiter).flatMap((dir) => [path.join(dir, "mmdc"), path.join(dir, "mmdc.cmd")]);
|
|
29
|
+
let cli = candidates.find((candidate) => fs.existsSync(candidate));
|
|
30
|
+
if (cli?.endsWith(".cmd")) {
|
|
31
|
+
// npm Windows shims are shell scripts. Execute their installed JS entry directly.
|
|
32
|
+
const entry = path.join(path.dirname(cli), "node_modules", "@mermaid-js", "mermaid-cli", "src", "cli.js");
|
|
33
|
+
cli = fs.existsSync(entry) ? entry : undefined;
|
|
34
|
+
}
|
|
35
|
+
if (!cli) throw new Error("Mermaid CLI unavailable. Use the JSON/HTML renderer or provide an installed mmdc JS entry with --cli. No packages were installed.");
|
|
36
|
+
const js = /\.[cm]?js$/i.test(cli);
|
|
37
|
+
const command = js ? process.execPath : cli;
|
|
38
|
+
const prefix = js ? [cli] : [];
|
|
39
|
+
const version = spawnSync(command, [...prefix, "--version"], { encoding: "utf8", timeout, windowsHide: true });
|
|
40
|
+
if (version.error || version.status !== 0) throw new Error(`Mermaid CLI unavailable: ${version.error?.message ?? version.stderr}`);
|
|
41
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fec-mermaid-"));
|
|
42
|
+
try {
|
|
43
|
+
const artifact = path.join(dir, `diagram${path.extname(output)}`);
|
|
44
|
+
const result = spawnSync(command, [...prefix, "-i", input, "-o", artifact, "-t", theme], { encoding: "utf8", timeout, windowsHide: true });
|
|
45
|
+
if (result.error || result.status !== 0) throw new Error(`Mermaid render failed: ${result.error?.message ?? result.stderr}`);
|
|
46
|
+
if (!fs.existsSync(artifact) || fs.statSync(artifact).size === 0) throw new Error("Mermaid render failed: no fresh artifact produced.");
|
|
47
|
+
const bytes = fs.readFileSync(artifact);
|
|
48
|
+
if (output.endsWith(".svg") && !bytes.toString("utf8").includes("<svg")) throw new Error("Mermaid render failed: invalid SVG.");
|
|
49
|
+
if (output.endsWith(".png") && bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a") throw new Error("Mermaid render failed: invalid PNG.");
|
|
50
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
51
|
+
fs.copyFileSync(artifact, output);
|
|
52
|
+
if (args.report) {
|
|
53
|
+
fs.mkdirSync(path.dirname(path.resolve(args.report)), { recursive: true });
|
|
54
|
+
fs.writeFileSync(args.report, JSON.stringify({ ok:true,engine:"mermaid",format:path.extname(output).slice(1),verified:false,route: "mermaid", version: version.stdout.trim(), input, output, theme, status: "rendered", visualQA: "pending" }, null, 2) + "\n");
|
|
55
|
+
}
|
|
56
|
+
const report = {ok:true,engine:"mermaid",version:version.stdout.trim(),input,output,format:path.extname(output).slice(1),verified:false,reason:"Final image visual QA is pending."};
|
|
57
|
+
console.log(args.format === "json" ? JSON.stringify(report) : `Rendered Mermaid diagram: ${output}\nVisual QA: pending`);
|
|
58
|
+
} finally {
|
|
59
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
63
|
+
process.exitCode = 1;
|
|
64
|
+
}
|