@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.
Files changed (195) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +378 -339
  4. package/CHANGELOG.zh-CN.md +377 -338
  5. package/CONTRIBUTING.md +371 -361
  6. package/CONTRIBUTING.zh-CN.md +371 -361
  7. package/README.md +545 -493
  8. package/README.openclaw.md +359 -320
  9. package/README.openclaw.zh-CN.md +359 -320
  10. package/README.zh-CN.md +545 -493
  11. package/agents/fec-code-reviewer.md +24 -3
  12. package/agents/fec-debugger.md +1 -1
  13. package/agents/fec-doc-updater.md +1 -1
  14. package/agents/fec-e2e-runner.md +1 -1
  15. package/agents/fec-figma-implementer.md +1 -1
  16. package/agents/fec-performance-optimizer.md +1 -1
  17. package/agents/fec-refactor-cleaner.md +4 -1
  18. package/agents/fec-refactoring-expert.md +81 -0
  19. package/agents/fec-security-reviewer.md +17 -1
  20. package/agents/fec-test-planner.md +1 -1
  21. package/agents/fec-typescript-reviewer.md +24 -8
  22. package/agents/fec-ui-checker.md +11 -11
  23. package/commands/fec-debug.md +1 -1
  24. package/commands/fec-refactor-plan.md +17 -0
  25. package/commands/fec-refactor.md +18 -0
  26. package/commands/fec-review.md +40 -10
  27. package/commands/fec-scaffold.md +1 -1
  28. package/commands/fec-smell.md +17 -0
  29. package/commands/fec-tdd.md +6 -2
  30. package/dist/bin/frontend-craft.js +190 -38
  31. package/dist/hooks/fec-run-tests.js +5 -1
  32. package/dist/hooks/fec-session-start.js +1 -1
  33. package/docs/example-prompts.md +157 -140
  34. package/docs/ja-JP/README.md +534 -493
  35. package/docs/ko-KR/README.md +534 -493
  36. package/docs/project-structure.md +151 -131
  37. package/docs/refactoring/README.md +50 -0
  38. package/docs/refactoring/catalog.md +105 -0
  39. package/docs/refactoring/smell-refactoring-matrix.md +41 -0
  40. package/docs/review-and-diagram-workflows.md +39 -0
  41. package/docs/runtimes/antigravity.md +18 -10
  42. package/docs/runtimes/augment.md +18 -10
  43. package/docs/runtimes/claude.md +191 -177
  44. package/docs/runtimes/claude.zh-CN.md +189 -175
  45. package/docs/runtimes/cline.md +18 -10
  46. package/docs/runtimes/codebuddy.md +18 -10
  47. package/docs/runtimes/codex.md +18 -10
  48. package/docs/runtimes/compatibility.md +27 -0
  49. package/docs/runtimes/copilot.md +18 -10
  50. package/docs/runtimes/cursor.md +18 -10
  51. package/docs/runtimes/gemini.md +18 -10
  52. package/docs/runtimes/kilo.md +18 -10
  53. package/docs/runtimes/openclaw.md +18 -10
  54. package/docs/runtimes/opencode.md +18 -10
  55. package/docs/runtimes/qoder.md +18 -19
  56. package/docs/runtimes/trae.md +18 -10
  57. package/docs/runtimes/windsurf.md +18 -10
  58. package/docs/superpowers/plans/2026-09-26-content-integrity.md +145 -0
  59. package/docs/superpowers/plans/2026-09-26-diagram-quality.md +170 -0
  60. package/docs/superpowers/specs/2026-09-26-diagram-quality-design.md +77 -0
  61. package/docs/superpowers/specs/2026-09-26-project-optimization-design.md +95 -0
  62. package/docs/zh-CN/example-prompts.md +157 -140
  63. package/docs/zh-CN/project-structure.md +148 -128
  64. package/docs/zh-CN/review-and-diagram-workflows.md +39 -0
  65. package/docs/zh-TW/README.md +534 -493
  66. package/localized/zh-CN/agents/fec-code-reviewer.md +22 -3
  67. package/localized/zh-CN/agents/fec-refactor-cleaner.md +3 -0
  68. package/localized/zh-CN/agents/fec-refactoring-expert.md +53 -0
  69. package/localized/zh-CN/agents/fec-security-reviewer.md +16 -0
  70. package/localized/zh-CN/agents/fec-typescript-reviewer.md +23 -7
  71. package/localized/zh-CN/commands/fec-refactor-plan.md +15 -0
  72. package/localized/zh-CN/commands/fec-refactor.md +14 -0
  73. package/localized/zh-CN/commands/fec-review.md +40 -9
  74. package/localized/zh-CN/commands/fec-smell.md +13 -0
  75. package/localized/zh-CN/commands/fec-tdd.md +4 -0
  76. package/localized/zh-CN/skills/eval_queries.json +244 -0
  77. package/localized/zh-CN/skills/fec-code-review/SKILL.md +20 -1
  78. package/localized/zh-CN/skills/fec-code-review/references/report-template.md +11 -1
  79. package/localized/zh-CN/skills/fec-code-smells/SKILL.md +33 -0
  80. package/localized/zh-CN/skills/fec-code-smells/references/detection-guide.md +25 -0
  81. package/localized/zh-CN/skills/fec-code-smells/references/smell-catalog.md +817 -0
  82. package/localized/zh-CN/skills/fec-code-smells/references/smell-to-refactoring-map.md +265 -0
  83. package/localized/zh-CN/skills/fec-image-generation/SKILL.md +31 -1
  84. package/localized/zh-CN/skills/fec-image-generation/assets/quality-examples/README.md +14 -0
  85. package/localized/zh-CN/skills/fec-image-generation/assets/quality-examples/architecture.json +121 -0
  86. package/localized/zh-CN/skills/fec-image-generation/assets/quality-examples/invalid-explicit.json +41 -0
  87. package/localized/zh-CN/skills/fec-image-generation/assets/quality-examples/sequence.json +53 -0
  88. package/localized/zh-CN/skills/fec-image-generation/assets/quality-examples/sequence.mmd +21 -0
  89. package/localized/zh-CN/skills/fec-image-generation/assets/quality-examples/workflow.json +98 -0
  90. package/localized/zh-CN/skills/fec-image-generation/assets/quality-examples/workflow.mmd +7 -0
  91. package/localized/zh-CN/skills/fec-image-generation/references/diagram-workflows.md +10 -3
  92. package/localized/zh-CN/skills/fec-image-generation/references/html-technical-diagrams.md +8 -1
  93. package/localized/zh-CN/skills/fec-image-generation/references/png-qa-autofix.md +9 -2
  94. package/localized/zh-CN/skills/fec-image-generation/scripts/diagram-browser.mjs +90 -0
  95. package/localized/zh-CN/skills/fec-image-generation/scripts/diagram-layout.mjs +176 -0
  96. package/localized/zh-CN/skills/fec-image-generation/scripts/export-diagram.mjs +48 -80
  97. package/localized/zh-CN/skills/fec-image-generation/scripts/mermaid-render.mjs +64 -0
  98. package/localized/zh-CN/skills/fec-image-generation/scripts/png-qa.mjs +35 -4
  99. package/localized/zh-CN/skills/fec-image-generation/scripts/tech-diagram-render.mjs +193 -53
  100. package/localized/zh-CN/skills/fec-refactor-clean/SKILL.md +3 -0
  101. package/localized/zh-CN/skills/fec-refactoring/SKILL.md +83 -0
  102. package/localized/zh-CN/skills/fec-refactoring/references/principles.md +43 -0
  103. package/localized/zh-CN/skills/fec-refactoring/references/refactoring-vs-feature-change.md +25 -0
  104. package/localized/zh-CN/skills/fec-refactoring/references/when-to-refactor.md +17 -0
  105. package/localized/zh-CN/skills/fec-refactoring/references/workflow.md +59 -0
  106. package/localized/zh-CN/skills/fec-refactoring-api/SKILL.md +25 -0
  107. package/localized/zh-CN/skills/fec-refactoring-api/references/api-refactorings.md +496 -0
  108. package/localized/zh-CN/skills/fec-refactoring-catalog/SKILL.md +21 -0
  109. package/localized/zh-CN/skills/fec-refactoring-catalog/references/catalog-index.md +99 -0
  110. package/localized/zh-CN/skills/fec-refactoring-catalog/references/composition-guide.md +49 -0
  111. package/localized/zh-CN/skills/fec-refactoring-catalog/references/selection-guide.md +41 -0
  112. package/localized/zh-CN/skills/fec-refactoring-control/SKILL.md +25 -0
  113. package/localized/zh-CN/skills/fec-refactoring-control/references/conditional-refactorings.md +299 -0
  114. package/localized/zh-CN/skills/fec-refactoring-data/SKILL.md +25 -0
  115. package/localized/zh-CN/skills/fec-refactoring-data/references/data-refactorings.md +248 -0
  116. package/localized/zh-CN/skills/fec-refactoring-encapsulation/SKILL.md +25 -0
  117. package/localized/zh-CN/skills/fec-refactoring-encapsulation/references/encapsulation-refactorings.md +452 -0
  118. package/localized/zh-CN/skills/fec-refactoring-functions/SKILL.md +25 -0
  119. package/localized/zh-CN/skills/fec-refactoring-functions/references/function-refactorings.md +567 -0
  120. package/localized/zh-CN/skills/fec-refactoring-inheritance/SKILL.md +25 -0
  121. package/localized/zh-CN/skills/fec-refactoring-inheritance/references/inheritance-refactorings.md +550 -0
  122. package/localized/zh-CN/skills/fec-refactoring-move-features/SKILL.md +25 -0
  123. package/localized/zh-CN/skills/fec-refactoring-move-features/references/moving-refactorings.md +453 -0
  124. package/localized/zh-CN/skills/fec-refactoring-validation/SKILL.md +52 -0
  125. package/localized/zh-CN/skills/fec-refactoring-validation/references/behavior-preservation.md +15 -0
  126. package/localized/zh-CN/skills/fec-refactoring-validation/references/refactoring-test-checklist.md +25 -0
  127. package/localized/zh-CN/skills/fec-security-review/SKILL.md +16 -0
  128. package/localized/zh-CN/skills/fec-security-review/references/report-template.md +10 -0
  129. package/localized/zh-CN/skills/fec-tdd-workflow/SKILL.md +3 -0
  130. package/localized/zh-CN/skills/metadata.json +408 -50
  131. package/localized/zh-CN/skills/relations.json +271 -3
  132. package/localized/zh-CN/templates/shared/rules/fec-agent-workflow.md +1 -0
  133. package/openclaw.plugin.json +2 -3
  134. package/package.json +27 -17
  135. package/skills/eval_queries.json +244 -0
  136. package/skills/fec-code-review/SKILL.md +20 -1
  137. package/skills/fec-code-review/references/report-template.md +11 -1
  138. package/skills/fec-code-smells/SKILL.md +33 -0
  139. package/skills/fec-code-smells/references/detection-guide.md +25 -0
  140. package/skills/fec-code-smells/references/smell-catalog.md +861 -0
  141. package/skills/fec-code-smells/references/smell-to-refactoring-map.md +265 -0
  142. package/skills/fec-data-fetching/SKILL.md +1 -1
  143. package/skills/fec-image-generation/SKILL.md +31 -1
  144. package/skills/fec-image-generation/assets/quality-examples/README.md +14 -0
  145. package/skills/fec-image-generation/assets/quality-examples/architecture.json +121 -0
  146. package/skills/fec-image-generation/assets/quality-examples/invalid-explicit.json +41 -0
  147. package/skills/fec-image-generation/assets/quality-examples/sequence.json +53 -0
  148. package/skills/fec-image-generation/assets/quality-examples/sequence.mmd +21 -0
  149. package/skills/fec-image-generation/assets/quality-examples/workflow.json +98 -0
  150. package/skills/fec-image-generation/assets/quality-examples/workflow.mmd +7 -0
  151. package/skills/fec-image-generation/references/diagram-workflows.md +10 -3
  152. package/skills/fec-image-generation/references/html-technical-diagrams.md +8 -1
  153. package/skills/fec-image-generation/references/png-qa-autofix.md +9 -2
  154. package/skills/fec-image-generation/scripts/diagram-browser.mjs +90 -0
  155. package/skills/fec-image-generation/scripts/diagram-layout.mjs +176 -0
  156. package/skills/fec-image-generation/scripts/export-diagram.mjs +48 -80
  157. package/skills/fec-image-generation/scripts/mermaid-render.mjs +64 -0
  158. package/skills/fec-image-generation/scripts/png-qa.mjs +35 -4
  159. package/skills/fec-image-generation/scripts/tech-diagram-render.mjs +193 -53
  160. package/skills/fec-legacy-web-standard/SKILL.md +1 -1
  161. package/skills/fec-refactor-clean/SKILL.md +3 -0
  162. package/skills/fec-refactoring/SKILL.md +83 -0
  163. package/skills/fec-refactoring/references/principles.md +60 -0
  164. package/skills/fec-refactoring/references/refactoring-vs-feature-change.md +27 -0
  165. package/skills/fec-refactoring/references/when-to-refactor.md +31 -0
  166. package/skills/fec-refactoring/references/workflow.md +90 -0
  167. package/skills/fec-refactoring-api/SKILL.md +25 -0
  168. package/skills/fec-refactoring-api/references/api-refactorings.md +496 -0
  169. package/skills/fec-refactoring-catalog/SKILL.md +28 -0
  170. package/skills/fec-refactoring-catalog/references/catalog-index.md +99 -0
  171. package/skills/fec-refactoring-catalog/references/composition-guide.md +49 -0
  172. package/skills/fec-refactoring-catalog/references/selection-guide.md +47 -0
  173. package/skills/fec-refactoring-control/SKILL.md +25 -0
  174. package/skills/fec-refactoring-control/references/conditional-refactorings.md +299 -0
  175. package/skills/fec-refactoring-data/SKILL.md +25 -0
  176. package/skills/fec-refactoring-data/references/data-refactorings.md +248 -0
  177. package/skills/fec-refactoring-encapsulation/SKILL.md +25 -0
  178. package/skills/fec-refactoring-encapsulation/references/encapsulation-refactorings.md +452 -0
  179. package/skills/fec-refactoring-functions/SKILL.md +25 -0
  180. package/skills/fec-refactoring-functions/references/function-refactorings.md +567 -0
  181. package/skills/fec-refactoring-inheritance/SKILL.md +25 -0
  182. package/skills/fec-refactoring-inheritance/references/inheritance-refactorings.md +550 -0
  183. package/skills/fec-refactoring-move-features/SKILL.md +25 -0
  184. package/skills/fec-refactoring-move-features/references/moving-refactorings.md +453 -0
  185. package/skills/fec-refactoring-validation/SKILL.md +59 -0
  186. package/skills/fec-refactoring-validation/references/behavior-preservation.md +53 -0
  187. package/skills/fec-refactoring-validation/references/refactoring-test-checklist.md +42 -0
  188. package/skills/fec-security-review/SKILL.md +16 -0
  189. package/skills/fec-security-review/references/report-template.md +10 -0
  190. package/skills/fec-tdd-workflow/SKILL.md +5 -1
  191. package/skills/metadata.json +434 -71
  192. package/skills/relations.json +264 -0
  193. package/templates/shared/rules/fec-agent-workflow.md +1 -0
  194. package/docs/superpowers/plans/2026-05-29-fec-debug.md +0 -491
  195. package/docs/superpowers/specs/2026-05-29-fec-debug-design.md +0 -340
@@ -7,10 +7,10 @@
7
7
  | ER 图 | Mermaid ER、DBML、Graphviz、SVG | 基数标签、表名、关键字段、交叉关系。 |
8
8
  | UML 类图 | Mermaid class、PlantUML、Graphviz | 继承方向、方法/属性换行、包分组。 |
9
9
  | 序列图 | Mermaid sequence、PlantUML | lifeline 顺序、activation 范围、异步/返回箭头、消息文本。 |
10
- | 技术架构 | HTML `architecture` IR、Mermaid flowchart、C4/Structurizr、SVG/HTML、draw.io | 分层分组、信任边界、数据流方向、图例。 |
10
+ | 技术架构 | draw.io、Mermaid/C4、HTML `architecture` IR、SVG/HTML | 分层分组、信任边界、数据流方向、图例。 |
11
11
  | Agent / memory 架构 | 带语义节点类型和 flow 连线的 HTML `architecture` IR、SVG/HTML、draw.io | read/write 路径分离、tool-call 回路、memory 分层、retrieval 标签、flow legend。 |
12
12
  | ML / 深度学习 | SVG/HTML/canvas | tensor shape、层顺序、分支汇合、重复块、注释。 |
13
- | 流程图 / 流程工作流 | HTML `workflow` IR、Mermaid flowchart、SVG、draw.io | 决策标签、终止状态、回环可读性、连接线间距、异常路径清晰度。 |
13
+ | 流程图 / 流程工作流 | Mermaid flowchart、HTML `workflow` IR、SVG、draw.io | 决策标签、终止状态、回环可读性、连接线间距、异常路径清晰度。 |
14
14
  | 会话实时草图 | 本地交互式浏览器服务 | 增量呈现、session 隔离、拖拽后的标签可读性、导出交接。 |
15
15
 
16
16
  ## 交互式实时图表
@@ -50,7 +50,7 @@ node skills/fec-image-generation/scripts/interactive-diagram-server.mjs --port 6
50
50
  ## 图表修复手册
51
51
 
52
52
  - **节点重叠:** 增加 rank/列间距、拆分簇,或将低优先级注释移入图例。
53
- - **标签截断:** 加宽节点、添加手动换行、缩短标签,或提高导出倍率。
53
+ - **标签截断:** 加宽节点、添加手动换行、保留完整文字并调整源布局。
54
54
  - **连接线穿过标签:** 添加 waypoints、沿框体周围正交布线,或将标签移到线段上方。
55
55
  - **连接线堆叠:** 用不同 waypoints 分离平行边,或用带共享标签的 bus 汇总。
56
56
  - **边缘裁剪:** 增加外边距,或扩大导出 viewBox/canvas 尺寸。
@@ -72,3 +72,10 @@ UML 覆盖优先走确定性源:class、ER、state-machine、activity 和 sequ
72
72
  ## 导出指引
73
73
 
74
74
  位图 PNG 优先以 2x 倍率导出;只有在文字仍清晰时才下采样。凡是未来可能修改图表的地方,都应提交或附上可编辑源文件。
75
+
76
+
77
+ ## 质量交付约定
78
+
79
+ 标准序列/流程图优先本地 Mermaid;架构图需要手工编辑时复用 fec-drawio-studio,大型拓扑可选 Graphviz。无本地工具时使用兼容 JSON/HTML,不自动安装或下载。HTML 预览、独立 SVG 和 PNG 使用同源主题样式;导出指定 `--theme light|dark`。PNG 导出等待字体并测量文字,使用 `--manifest source.layout.json --output-manifest image.actual.json` 写出缩放后的像素坐标。QA 必须读取 actual manifest,不能把估算坐标直接用于 2x PNG。
80
+
81
+ 扩展 manifest 保留 canvas/boxes/connectors,增加 coordinateSpace、scale、measurement、groups、labels 与 issues。分组包含节点不算节点重叠。新增 node-text-overflow、label-content-loss、label-out-of-bounds、edge-label-collision、text-overlap 与 degenerate-connector 检查。旧 manifest 保留估算检测。显式尺寸或 waypoints 不满足约束时定位问题,不删字、不改关系、不静默移动。提高导出分辨率不能修复源级溢出。最终 PNG 必须视觉审阅;记录工具可用性、版本、主题、比例、修复轮次和残留问题,未解决时标记部分验收。
@@ -226,7 +226,7 @@ node skills/fec-image-generation/scripts/png-qa.mjs \
226
226
  修复应回到 JSON 源或渲染尺寸:
227
227
 
228
228
  - 重叠:拆分密集分组,或增加节点/分组间距。
229
- - 标签截断:缩短标签、添加 `sublabel` 或加宽节点。
229
+ - 标签截断:对完整标签换行、保留文字或加宽节点。
230
230
  - 连线穿过标签:减少边标签,添加 `waypoints`,或移动节点。
231
231
  - 流向过密:拆成总览图和细节图。
232
232
  - 边缘裁剪:增加输出空间或简化路径。
@@ -234,3 +234,10 @@ node skills/fec-image-generation/scripts/png-qa.mjs \
234
234
  ## 交付说明
235
235
 
236
236
  交付 `.html` 源和用户要求的 SVG/PNG/JPG。说明使用的路线、源文件、导出文件、QA 结果,以及仍需人工确认的命名或领域假设。
237
+
238
+
239
+ ## 质量交付约定
240
+
241
+ 标准序列/流程图优先本地 Mermaid;架构图需要手工编辑时复用 fec-drawio-studio,大型拓扑可选 Graphviz。无本地工具时使用兼容 JSON/HTML,不自动安装或下载。HTML 预览、独立 SVG 和 PNG 使用同源主题样式;导出指定 `--theme light|dark`。PNG 导出等待字体并测量文字,使用 `--manifest source.layout.json --output-manifest image.actual.json` 写出缩放后的像素坐标。QA 必须读取 actual manifest,不能把估算坐标直接用于 2x PNG。
242
+
243
+ 扩展 manifest 保留 canvas/boxes/connectors,增加 coordinateSpace、scale、measurement、groups、labels 与 issues。分组包含节点不算节点重叠。新增 node-text-overflow、label-content-loss、label-out-of-bounds、edge-label-collision、text-overlap 与 degenerate-connector 检查。旧 manifest 保留估算检测。显式尺寸或 waypoints 不满足约束时定位问题,不删字、不改关系、不静默移动。提高导出分辨率不能修复源级溢出。最终 PNG 必须视觉审阅;记录工具可用性、版本、主题、比例、修复轮次和残留问题,未解决时标记部分验收。
@@ -14,9 +14,9 @@
14
14
  | ----- | ------ |
15
15
  | `blank-image` | 检查导出目标、渲染器错误、隐藏图层、透明填充或图像生成失败。 |
16
16
  | `edge-clipping` | 增加画布留白、扩大 SVG viewBox、降低主体缩放,或导出更大的画幅。 |
17
- | `box-overlap` | 增加间距、重排为网格/图层、拆分簇,或缩短标签。 |
17
+ | `box-overlap` | 增加间距、重排为网格/图层、拆分簇,或对完整标签换行。 |
18
18
  | `box-out-of-bounds` | 将节点移入画布,或增大画布尺寸。 |
19
- | `label-overflow` | 文本换行、加宽框体、减少文案,或同时增大字体/容器尺寸。 |
19
+ | `label-overflow` | 文本换行、加宽框体、保留完整文案,并同时增大字体/容器尺寸。 |
20
20
  | `connector-through-label` | 在框体周围添加 waypoints、移动标签,或使用正交布线。 |
21
21
  | `connector-stacking` | 偏移平行连接线,或用带标签的分组路径替代重复线条。 |
22
22
  | `manifest-canvas-mismatch` | 根据最终导出尺寸重新生成 manifest。 |
@@ -28,3 +28,10 @@
28
28
  ## 报告要求
29
29
 
30
30
  报告 QA 轮数和最终问题列表。若由于源渲染器无法暴露布局坐标而仍有遗留问题,说明哪些检查只能基于图像,哪些需要人工视觉复核。
31
+
32
+
33
+ ## 质量交付约定
34
+
35
+ 标准序列/流程图优先本地 Mermaid;架构图需要手工编辑时复用 fec-drawio-studio,大型拓扑可选 Graphviz。无本地工具时使用兼容 JSON/HTML,不自动安装或下载。HTML 预览、独立 SVG 和 PNG 使用同源主题样式;导出指定 `--theme light|dark`。PNG 导出等待字体并测量文字,使用 `--manifest source.layout.json --output-manifest image.actual.json` 写出缩放后的像素坐标。QA 必须读取 actual manifest,不能把估算坐标直接用于 2x PNG。
36
+
37
+ 扩展 manifest 保留 canvas/boxes/connectors,增加 coordinateSpace、scale、measurement、groups、labels 与 issues。分组包含节点不算节点重叠。新增 node-text-overflow、label-content-loss、label-out-of-bounds、edge-label-collision、text-overlap 与 degenerate-connector 检查。旧 manifest 保留估算检测。显式尺寸或 waypoints 不满足约束时定位问题,不删字、不改关系、不静默移动。提高导出分辨率不能修复源级溢出。最终 PNG 必须视觉审阅;记录工具可用性、版本、主题、比例、修复轮次和残留问题,未解决时标记部分验收。
@@ -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
- import { pathToFileURL } from "node:url";
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
- const svg = extractSvg(inputPath);
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
- exportRaster(svg, outputPath, format, scale, background);
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
+ }