@stacksjs/defaults 0.75.361 → 0.75.362

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 (208) hide show
  1. package/ai/skills/stacks-ai/SKILL.md +22 -0
  2. package/ai/skills/stacks-native/CATALOG.md +2 -2
  3. package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
  4. package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
  5. package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
  6. package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
  7. package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
  8. package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
  9. package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
  10. package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
  11. package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
  12. package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
  13. package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
  14. package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
  15. package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
  16. package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
  17. package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
  18. package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
  19. package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
  20. package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
  21. package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
  22. package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
  23. package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
  24. package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
  25. package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
  26. package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
  27. package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
  28. package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
  29. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
  30. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
  31. package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
  32. package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
  33. package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
  34. package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
  35. package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
  36. package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
  37. package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
  38. package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
  39. package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
  40. package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
  41. package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
  42. package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
  43. package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
  44. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
  45. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
  46. package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
  47. package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
  48. package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
  49. package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
  50. package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
  51. package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
  52. package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
  53. package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
  54. package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
  55. package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
  56. package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
  57. package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
  58. package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
  59. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
  60. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
  61. package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
  62. package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
  63. package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
  64. package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
  65. package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
  66. package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
  67. package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
  68. package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
  69. package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
  70. package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
  71. package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
  72. package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
  73. package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
  74. package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
  75. package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
  76. package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
  77. package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
  78. package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
  79. package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
  80. package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
  81. package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
  82. package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
  83. package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
  84. package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
  85. package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
  86. package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
  87. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
  88. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
  89. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
  90. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
  91. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
  92. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
  93. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
  94. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
  95. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
  96. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
  97. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
  98. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
  99. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
  100. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
  101. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
  102. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
  103. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
  104. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
  105. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
  106. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
  107. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
  108. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
  109. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
  110. package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
  111. package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
  112. package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
  113. package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
  114. package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
  115. package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
  116. package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
  117. package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
  118. package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
  119. package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
  120. package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
  121. package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
  122. package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
  123. package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
  124. package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
  125. package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
  126. package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
  127. package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
  128. package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
  129. package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
  130. package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
  131. package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
  132. package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
  133. package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
  134. package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
  135. package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
  136. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
  137. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
  138. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
  139. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
  140. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
  141. package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
  142. package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
  143. package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
  144. package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
  145. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
  146. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
  147. package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
  148. package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
  149. package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
  150. package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
  151. package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
  152. package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
  153. package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
  154. package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
  155. package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
  156. package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
  157. package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
  158. package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
  159. package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
  160. package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
  161. package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
  162. package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
  163. package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
  164. package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
  165. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
  166. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
  167. package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
  168. package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
  169. package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
  170. package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
  171. package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
  172. package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
  173. package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
  174. package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
  175. package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
  176. package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
  177. package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
  178. package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
  179. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
  180. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
  181. package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
  182. package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
  183. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
  184. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
  185. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
  186. package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
  187. package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
  188. package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
  189. package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
  190. package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
  191. package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
  192. package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
  193. package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
  194. package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
  195. package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
  196. package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
  197. package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
  198. package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
  199. package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
  200. package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
  201. package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
  202. package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
  203. package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
  204. package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
  205. package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
  206. package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
  207. package/ide/vscode/package.json +1 -1
  208. package/package.json +2 -2
@@ -0,0 +1,224 @@
1
+ import fs from 'node:fs';
2
+ import { softwareNoticesHtml } from '../renderers/shared/software-notices.mjs';
3
+ import path from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { renderInline } from './inline.mjs';
6
+ import { parseExplainerDiff } from './diff.mjs';
7
+ import { computeExplainerRevision } from './revision.mjs';
8
+ import { layoutExplainerPanels } from './layout.mjs';
9
+ import { compileExplainerChart } from './charts.mjs';
10
+ import { packPageBundle } from './page-bundle.mjs';
11
+
12
+ const css = fs.readFileSync(new URL('./reader.css', import.meta.url), 'utf8');
13
+ const js = fs.readFileSync(new URL('./reader.js', import.meta.url), 'utf8');
14
+ const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character]));
15
+ const json = value => JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, character => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`);
16
+ const hash = text => createHash('sha256').update(text).digest('hex');
17
+ // Toolbar glyphs, drawn with currentColor so they follow the theme.
18
+ const icon = path => `<svg class="tool-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="${path}"/></svg>`;
19
+ const icons = {
20
+ chevron: icon('M4.5 6.5 8 10l3.5-3.5'),
21
+ check: icon('M3.5 8.5 6.5 11.5 12.5 4.5'),
22
+ image: icon('M2.5 3.5h11v9h-11zM2.5 10.5l3-3 3 3 2-2 3 3M10.5 6.25a.75.75 0 1 0 0 .01'),
23
+ print: icon('M4.5 6V2.5h7V6M4.5 11.5h-2v-5h11v5h-2M4.5 9.5h7v4h-7z'),
24
+ expand: icon('M9.5 2.5h4v4M13.5 2.5 9.5 6.5M6.5 13.5h-4v-4M2.5 13.5l4-4'),
25
+ close: icon('M4 4l8 8M12 4l-8 8'),
26
+ export: icon('M8 2.5v7M5 6.5l3 3 3-3M3 11v2.5h10V11'),
27
+ };
28
+ // The Archify symbol (docs/assets/archify-mark.svg), for the favicon and the colophon.
29
+ const mark = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 360" fill="none"><path d="M151 94 81 164C67 178 60 191 60 212V248H97V212C97 204 100 199 106 193L179 120Z" fill="#0856F5"/><path d="M238 135C252 132 261 126 270 116L321 167C334 180 339 194 339 213V248H303V213C303 204 300 199 294 193Z" fill="#1DBBB7"/><rect x="146" y="16" width="110" height="104" rx="30" fill="#0856F5"/><rect x="18" y="238" width="110" height="104" rx="28" fill="#0856F5"/><rect x="274" y="238" width="108" height="104" rx="28" fill="#1DBBB7"/></svg>';
30
+ const favicon = `data:image/svg+xml,${encodeURIComponent(mark)}`;
31
+ const dictionaries = {
32
+ en:{answer:'Answer',answerMark:'A',scope:'Applies to',sources:'Sources',print:'Print / PDF',image:'Long image',imageHints:['Sharpest','Smallest','Opens anywhere'],export:'Export',style:'Style',styles:{clean:'Clean',editorial:'Editorial',product:'Product',swiss:'Swiss'},auto:'System',light:'Light',dark:'Dark',typical:'Typical value',explore:'Open interactive view',exploreTitle:'Zoom, find nodes, trace paths, upstream and downstream, export images or motion',close:'Close',loading:'Opening diagram…',graph:'Diagram structure',changes:'Structure changes',added:'Added',removed:'Removed',modified:'Modified',copy:'Copy',copied:'Copied',walk:'Step through',walkMessages:'Play messages',walkFlow:'Walk the flow',prev:'Back',next:'Next',end:'Done',ask:'Ask',askQuote:'Ask about this',askPlaceholder:'What is unclear, or what should this part cover?',askCopy:'Copy question',askCopied:'Copied. Paste it to your agent.',askCancel:'Cancel',askChips:['I do not follow this part','Go deeper','Give an example','What is this based on?'],askPage:'Page',askFile:'File',askWhere:'Where',askQuoteLabel:'Quote',askQuestion:'Question',askNote:'Copies the page, the panel and its ID, so your agent can revise just this part.',askPatch:'Revise only this panel with Archify panel replacement (patch explainer), keeping its ID.',tagline:'Turn anything you want to understand, plan, or share into an interactive visual.',facts:{panels:n=>`${n} ${n === 1 ? 'panel' : 'panels'}`,diagrams:n=>`${n} ${n === 1 ? 'diagram' : 'diagrams'} validated`,sources:n=>`${n} ${n === 1 ? 'source' : 'sources'}`,pinned:rev=>`code pinned to ${rev}`}},
33
+ zh:{answer:'结论',answerMark:'答',scope:'适用于',sources:'来源',print:'打印 / PDF',image:'长图',imageHints:['最清晰','体积最小','兼容最好'],export:'导出',style:'样式',styles:{clean:'简洁',editorial:'杂志',product:'产品',swiss:'网格'},auto:'系统',light:'浅色',dark:'深色',typical:'典型值',explore:'打开交互图',exploreTitle:'缩放、搜索节点、追踪路径和上下游,导出图片或动画',close:'关闭',loading:'正在打开交互图…',graph:'图的结构',changes:'结构改动',added:'新增',removed:'删除',modified:'修改',copy:'复制',copied:'已复制',walk:'逐步看',walkMessages:'逐条看消息',walkFlow:'沿流程看',prev:'上一步',next:'下一步',end:'结束',ask:'追问',askQuote:'追问这段',askPlaceholder:'哪里没看懂,或者希望这块补充什么?',askCopy:'复制追问',askCopied:'已复制,粘贴给 agent 即可',askCancel:'取消',askChips:['没看懂这块','展开讲讲','举个例子','依据是什么?'],askPage:'页面',askFile:'文件',askWhere:'位置',askQuoteLabel:'引用',askQuestion:'问题',askNote:'会一并复制页面、面板位置和 ID,agent 可以只改这一块。',askPatch:'请用 Archify 的面板替换(patch explainer)只更新这个面板,保留它的 ID。',tagline:'把你想理解、规划或分享的事,变成可以互动的可视化作品。',facts:{panels:n=>`${n} 个面板`,diagrams:n=>`${n} 张图已通过结构校验`,sources:n=>`${n} 个来源`,pinned:rev=>`代码固定在提交 ${rev}`}}
34
+ };
35
+
36
+ /** Render a validated canonical explainer with separately prepared, immutable resources. */
37
+ export function renderExplainer(document, options = {}) {
38
+ const { preparedBundle, sourceText = JSON.stringify(document), sourceMap = {}, sourcePath, preparedSources = {}, preparedDiagrams = {}, diagnostics = [], pageDirectory } = options;
39
+ const locale = document.meta.locale || 'zh-CN';
40
+ // Chinese pages get Chinese controls; every other language gets the English ones.
41
+ const english = !locale.startsWith('zh');
42
+ const words = dictionaries[english ? 'en' : 'zh'];
43
+ const sources = preparedSources.sources || preparedSources;
44
+ const assets = preparedSources.assets || {};
45
+ const diagrams = preparedDiagrams.diagrams || preparedDiagrams;
46
+ const sourceSha256 = hash(sourceText);
47
+ const revision = computeExplainerRevision({document,sourceText,preparedSources,preparedDiagrams,dependencies:preparedBundle?.dependencies});
48
+ const footnotes = [];
49
+ const citations = new Map();
50
+ const inline = (text, panelId, blockId) => renderInline(String(text ?? ''), {document,sources,diagrams,panelId,blockId,footnotes,citations});
51
+ function unavailable(block, message) {
52
+ const panelIndex = document.panels.findIndex(panel => panel.blocks.includes(block));
53
+ const pointer = `/panels/${panelIndex}/blocks/${document.panels[panelIndex]?.blocks.indexOf(block)}`;
54
+ const diagnostic = {code:'explainer/render-resource',severity:'error',message,subject:{component:block.type,path:pointer,input:sourcePath,...sourceMap[pointer]},evidence:{reason:message,example:'Prepare Git excerpts and native diagrams before rendering.'},supportedFixes:[]};
55
+ throw Object.assign(new Error(message), {code:diagnostic.code,diagnostics:[diagnostic],archifyDiagnostics:[diagnostic]});
56
+ }
57
+ const nativeLink = (target, text) => {
58
+ const nativeHash = target.node ? `focus=${encodeURIComponent(target.node)}` : target.route ? `route=${encodeURIComponent(target.route[0])}~${encodeURIComponent(target.route[1])}` : '';
59
+ return `<a class="diagram-link" href="#${escape(target.diagram)}" data-diagram-link="${escape(target.diagram)}" data-diagram-hash="${escape(nativeHash)}"${target.edges ? ` data-diagram-edges="${escape(JSON.stringify(target.edges))}"` : ''}>${text}<span aria-hidden="true"> ↗</span></a>`;
60
+ };
61
+ function blockHtml(block, panelId) {
62
+ const i = text => inline(text,panelId,block.id);
63
+ let body;
64
+ switch (block.type) {
65
+ case 'prose': body = block.text.split(/\n\s*\n/).map(text => `<p>${i(text)}</p>`).join(''); break;
66
+ case 'list': { const tag = block.ordered ? 'ol' : 'ul'; if (block.checklist) { body = `<ul class="checklist">${block.items.map(text => `<li><label><input type="checkbox"><span>${i(text)}</span></label></li>`).join('')}</ul>`; break; } body = `<${tag} class="text-list">${block.items.map(text => `<li>${i(text)}</li>`).join('')}</${tag}>`; break; }
67
+ case 'callout': body = `<aside class="callout ${escape(block.level)}">${block.title ? `<h3>${i(block.title)}</h3>` : ''}<p>${i(block.text)}</p></aside>`; break;
68
+ case 'comparison': body = `<div class="table-scroll"><table><thead><tr><th scope="col">${block.labelColumn ? i(block.labelColumn) : ''}</th>${block.columns.map(text => `<th scope="col">${i(text)}</th>`).join('')}</tr></thead><tbody>${block.rows.map(row => `<tr id="${escape(row.id)}"><th scope="row">${i(row.label)}</th>${row.cells.map(cell => `<td>${cell.assessment ? `<span class="assessment ${escape(cell.assessment)}"><span class="assessment-meter" aria-hidden="true"><i></i><i></i><i></i></span><b>${i(cell.verdict || ({good:english?'Favorable':'有利',caution:english?'Tradeoff':'有代价',bad:english?'Unfavorable':'不利'}[cell.assessment]))}</b></span>` : ''}${cell.assessment && cell.text === cell.verdict ? '' : `<span class="cell-text">${i(cell.text)}</span>`}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`; break;
69
+ case 'facts': body = `<dl class="facts">${block.entries.map(entry => `<div><dt>${i(entry.key)}</dt><dd>${i(entry.value)}</dd></div>`).join('')}</dl>`; break;
70
+ case 'metric': {
71
+ const over = block.value !== undefined && block.limit !== undefined && block.value > block.limit;
72
+ const unit = escape(block.unit || '');
73
+ body = `<div class="metric${over ? ' metric-over-limit' : ''}" data-metric-status="${over ? 'over-limit' : block.value === undefined ? 'limit-only' : 'within-limit'}"><span class="metric-label">${i(block.label)}</span><p class="metric-value">${block.value === undefined ? `${english?'Limit':'上限'} ${escape(block.limit)}<span>${unit}</span>` : `${escape(block.value)}${block.unit ? `<span>${unit}</span>` : ''}${block.limit === undefined ? '' : `<small> / ${escape(block.limit)}${unit}</small>`}`}</p>${block.value !== undefined && block.limit > 0 ? `<meter min="0" max="${escape(block.limit)}" value="${escape(Math.max(0, Math.min(block.value,block.limit)))}" aria-label="${escape(block.label)}">${escape(block.value)} / ${escape(block.limit)}</meter>` : ''}${over ? `<p class="metric-overflow">${english?'Over limit by':'超限'} ${escape(block.value - block.limit)}${block.unit ? ` ${unit}` : ''}</p>` : ''}${block.typical ? `<span class="typical">${words.typical}</span>` : ''}${block.note ? `<p class="fine">${i(block.note)}</p>` : ''}</div>`;
74
+ break;
75
+ }
76
+ case 'steps': body = `<ol class="steps">${block.items.map(item => `<li id="${escape(item.id)}"><b>${item.target ? nativeLink(item.target,i(item.text)) : i(item.text)}</b>${item.detail ? `<p>${i(item.detail)}</p>` : ''}</li>`).join('')}</ol>`; break;
77
+ case 'diagram': { const diagram = diagrams[block.id]; if (!diagram?.svg || !diagram?.html) unavailable(block,`Missing prepared native diagram: ${block.id}`); const title = diagram.spec?.meta?.title || block.id; const svg = staticSvg(diagram.svg, block.id); const previewClass = previewPrefix(block.id); const nativeStyle = scopeNativeCss(diagram.styles ?? nativeStyles(diagram.html), '.' + previewClass, previewClass + '-', diagram.svg); const nativeWidth = Number(diagram.svg.match(/viewBox=["'][^"']*?[\d.-]+[ ,]+[\d.-]+[ ,]+([\d.]+)/)?.[1]); const lanes = diagram.spec?.lanes || [], singleLane = lanes.length === 1 ? ' single-lane bare-lanes' : lanes.length && lanes.every(lane => /^lane-\d+$/.test(lane.id)) ? ' bare-lanes' : ''; body = `<figure class="diagram"><figcaption><span class="diagram-title">${escape(title)}</span><span class="diagram-actions interactive"><button class="tool-button explore-button" type="button" data-open-diagram="${escape(block.id)}" title="${words.exploreTitle}">${icons.expand}<span>${words.explore}</span></button></span></figcaption><div class="diagram-preview ${previewClass}${singleLane}" role="img" aria-label="${escape(title)}"${block.nodeSources ? ` data-node-sources="${escape(JSON.stringify(block.nodeSources))}"` : ''}${nativeWidth > 0 ? ` style="--native-width:${Math.round(nativeWidth)}px"` : ''}><style>${nativeStyle}</style>${svg}</div></figure><p class="diagram-fit-status fine" role="status" hidden></p><details class="diagram-semantics" open><summary>${words.graph}</summary><ul>${(diagram.semantic?.nodes || []).map(node => `<li>${escape(node.label || node.id)} <code>${escape(node.id)}</code></li>`).join('')}${(diagram.semantic?.edges || []).map(edge => `<li><code>${escape(edge.from)}</code> → <code>${escape(edge.to)}</code>${edge.label ? ` · ${escape(edge.label)}` : ''}</li>`).join('')}</ul></details>${block.changes?.length ? `<dl class="changes"><dt>${words.changes}</dt>${block.changes.map(change => `<dd><span class="chip ${escape(change.change)}">${words[change.change]}</span> <code>${escape(change.target)}</code></dd>`).join('')}</dl>` : ''}`; break; }
78
+ case 'chart': { const text = block.text ?? preparedSources.charts?.[block.id]; if (typeof text !== 'string') unavailable(block,`Missing prepared chart data: ${block.id}`); const chart = compileExplainerChart(text); if (chart.error) unavailable(block, chart.error); body = `<figure class="chart" style="--native-width:${Math.round(chart.width)}px">${chart.svg.replace(/^<svg\b[^>]*>/, tag => tag.replace(/\s(?:width|height)="[^"]*"/g, ''))}${block.title ? `<figcaption>${i(block.title)}</figcaption>` : ''}</figure>`; break; }
79
+ case 'snippet': body = `<figure class="snippet"><figcaption><span class="snippet-label">${block.title ? i(block.title) : ''}${block.language ? `<code class="snippet-language">${escape(block.language)}</code>` : ''}</span><button class="text-button interactive" type="button" data-copy>${words.copy}</button></figcaption><pre><code${block.language ? ` class="language-${escape(block.language)}"` : ''}>${escape(block.text)}</code></pre></figure>`; break;
80
+ case 'code': { const source = sources[block.source]; if (!source?.path || typeof source.text !== 'string') unavailable(block,`Code requires a Git excerpt: ${block.source}`); body = `<figure class="code"><figcaption>${i(`[](source:${block.source})`)}<span>${escape(source.path)}:${escape(source.lines?.join('–'))}</span></figcaption><pre><code${block.language ? ` class="language-${escape(block.language)}"` : ''}>${escape(source.text)}</code></pre></figure>`; break; }
81
+ case 'diff': {
82
+ const parsed = parseExplainerDiff(block.text,{component:'diff',input:sourcePath,panelId});
83
+ if(parsed.diagnostics.some(diagnostic=>diagnostic.severity === 'error')) throw Object.assign(new Error(`Malformed diff: ${block.id}`),{diagnostics:parsed.diagnostics,archifyDiagnostics:parsed.diagnostics});
84
+ body = `<figure class="diff" data-diff-format="${parsed.format}"><figcaption>${block.caption ? `<span>${i(block.caption)}</span>` : ''}<span class="diff-summary"><span class="added">+${parsed.counts.added} ${words.added}</span> · <span class="removed">−${parsed.counts.removed} ${words.removed}</span></span></figcaption><div class="diff-scroll"><table class="diff-code" aria-label="${escape(block.caption || (english?'Code changes':'代码改动'))}"><thead><tr><th scope="col">${english?'Old':'原行'}</th><th scope="col">${english?'New':'新行'}</th><th scope="col">${english?'Change':'改动'}</th></tr></thead><tbody>${parsed.rows.map(row=>`<tr class="diff-line ${escape(row.kind)}" data-diff-kind="${escape(row.kind)}"${row.oldLine === null ? '' : ` data-old-line="${row.oldLine}"`}${row.newLine === null ? '' : ` data-new-line="${row.newLine}"`}><td class="diff-number">${row.oldLine ?? ''}</td><td class="diff-number">${row.newLine ?? ''}</td><td><code>${escape(row.raw)}</code></td></tr>`).join('')}</tbody></table></div></figure>`;
85
+ break;
86
+ }
87
+ case 'image': { const asset = assets[block.id]; if (!asset?.dataUrl || !/^data:image\/(png|jpeg|webp|gif);base64,/.test(asset.dataUrl)) unavailable(block,`Missing contained image: ${block.id}`); body = `<figure class="image"><img src="${escape(asset.dataUrl)}" alt="${escape(block.alt)}">${block.caption ? `<figcaption>${i(block.caption)}</figcaption>` : ''}</figure>`; break; }
88
+ default: throw new Error(`Unsupported explainer block: ${block.type}`);
89
+ }
90
+ return `<div class="block block-${escape(block.type)}" id="${escape(block.id)}" data-block-type="${escape(block.type)}">${body}</div>`;
91
+ }
92
+ const scopeHtml = scope => { const parts = scopeParts(scope); return parts.length > 1 ? parts.map(part=>`<span class="scope-chip">${inline(part)}</span>`).join('') : `<span>${inline(scope)}</span>`; };
93
+ // Render in reading order so the first citation receives number one.
94
+ const answerHtml = `<header class="panel-head"><span class="panel-id answer-id" aria-hidden="true">${words.answerMark}</span><h2>${words.answer}</h2></header><div class="panel-body"><p class="verdict">${inline(document.answer.summary)}</p>${document.answer.reasons?.length ? `<ol class="reasons">${document.answer.reasons.map(reason=>`<li>${inline(reason)}</li>`).join('')}</ol>` : ''}</div>${document.answer.scope ? `<p class="answer-scope"><span class="scope-label">${words.scope}</span>${scopeHtml(document.answer.scope)}</p>` : ''}`;
95
+ const rows=layoutExplainerPanels(document,diagrams);
96
+ const panels=rows.flatMap((row,rowIndex)=>row.map(item=>{
97
+ if(item.spacer)return `<div class="board-spacer span${item.span}" data-row="${rowIndex}" aria-hidden="true"></div>`;
98
+ if(item.answer)return `<section id="answer" class="panel answer-panel span${item.span}" data-row="${rowIndex}" data-span-min="${item.min}">${answerHtml}</section>`;
99
+ const panel=item.panel,index=document.panels.indexOf(panel);
100
+ const measure=panel.blocks.every(block=>['prose','list','steps','callout','facts','metric'].includes(block.type)) ? ' data-measure="text"' : '';
101
+ const blocks=panel.blocks.map(block=>blockHtml(block,panel.id)), figures=panel.blocks.filter(block=>block.type === 'diagram');
102
+ // Steps that point into the panel's one diagram are read beside it: text left, diagram right.
103
+ const guided=figures.length === 1 && panel.blocks.some(block=>block.type === 'steps' && block.items.some(step=>step.target?.diagram === figures[0].id));
104
+ const body=guided ? `<div class="panel-body guided"><div class="guide-figure">${blocks[panel.blocks.indexOf(figures[0])]}</div><div class="guide-text">${blocks.filter((_,n)=>panel.blocks[n] !== figures[0]).join('')}</div></div>` : `<div class="panel-body">${blocks.join('')}</div>`;
105
+ return `<section class="panel span${item.span}${figures.length ? ' diagram-panel' : ''}" id="${escape(panel.id)}" data-row="${rowIndex}" data-span-min="${item.min}"${item.explicit ? ` data-span-fixed="${item.span}"` : ''}${measure} data-title="${escape(panel.title)}"><header class="panel-head"><a class="panel-id" href="#${escape(panel.id)}" aria-label="${escape(panel.title)}">${index+1}</a><h2>${inline(panel.title,panel.id)}</h2></header>${body}</section>`;
106
+ })).join('');
107
+ for(const source of document.sources)if(!citations.has(source.id))citations.set(source.id,citations.size+1);
108
+ const sourceList=[...citations].map(([id,number])=>{
109
+ const source=sources[id] || document.sources.find(source=>source.id===id);
110
+ const location=source?.path ? `<code>${escape(source.path)}${source.lines?':'+escape(source.lines.join('–')):''}${source.revision?' @ '+escape(source.revision):''}</code>` : '';
111
+ const title=source?.url ? `<a href="${escape(source.url)}" target="_blank" rel="noopener noreferrer">${escape(source.title)}</a>` : escape(source?.title || id);
112
+ return `<li id="source-${escape(id)}" value="${number}">${title}${location}</li>`;
113
+ }).join('');
114
+ for (const [id,diagram] of Object.entries(diagrams)) { if (diagram.sha256 && diagram.sha256 !== hash(diagram.html)) throw new Error(`Native child hash mismatch: ${id}`); if (diagram.bytes !== undefined && diagram.bytes !== Buffer.byteLength(diagram.html)) throw new Error(`Native child byte count mismatch: ${id}`); }
115
+ const children = Object.fromEntries(Object.entries(diagrams).map(([id,diagram]) => [id,{diagramType:diagram.diagramType,html:diagram.html,sha256:diagram.sha256 || hash(diagram.html),bytes:diagram.bytes ?? Buffer.byteLength(diagram.html),semantic:diagram.semantic,checks:portableChecks(diagram.checks),composition:diagram.composition,specSha256:diagram.specSha256}]));
116
+ // What a reader can check, stated next to the maker's mark.
117
+ const pinned = Object.values(sources).find(source=>source?.path && source.revision)?.revision;
118
+ const facts = [words.facts.panels(document.panels.length),Object.keys(diagrams).length && words.facts.diagrams(Object.keys(diagrams).length),citations.size && words.facts.sources(citations.size),pinned && words.facts.pinned(String(pinned).slice(0,7))].filter(Boolean);
119
+ const colophon = `<div class="colophon"><a class="colophon-brand" href="https://github.com/tt-a1i/archify" target="_blank" rel="noopener noreferrer"><span class="colophon-mark" aria-hidden="true">${mark}</span><span class="colophon-words"><span class="colophon-name">Archify</span><span class="colophon-tagline">${words.tagline}</span></span></a><p class="colophon-facts">${facts.map(fact=>`<span>${escape(fact)}</span>`).join('')}</p></div>`;
120
+ const footnotesHtml = footnotes.length ? `<section class="typical-footnotes"><h2>${english?'Typical value notes':'典型值说明'}</h2><ol>${footnotes.map(note=>`<li id="${escape(note.id)}">${escape(note.label)}: ${escape(note.text)}</li>`).join('')}</ol></section>` : '';
121
+ const manifest = {version:1,artifactId:document.meta.id,revision,documentSha256:hash(JSON.stringify(document)),sourceSha256,dependencies:[...(preparedSources.dependencies || []),...(preparedDiagrams.dependencies || [])].map(({contentBase64,...entry})=>entry),children:Object.fromEntries(Object.entries(children).map(([id,child])=>[id,{sha256:child.sha256,bytes:child.bytes,diagramType:child.diagramType,checks:child.checks,composition:child.composition,specSha256:child.specSha256}])),sources:Object.fromEntries(Object.entries(sources).map(([id,source])=>[id,Object.fromEntries(Object.entries(source || {}).filter(([key])=>key !== 'text'))])),assets:Object.fromEntries(Object.entries(assets).map(([id,asset])=>[id,{sha256:asset.sha256,bytes:asset.bytes,mime:asset.mime}])),diagnostics};
122
+ const bundle = preparedBundle || {schemaVersion:1,kind:'archify-explainer-prepared',sourceText,sourceMap,sourcePath,document,format:'json',diagnostics,preparedSources,preparedDiagrams,dependencies:[...(preparedSources.dependencies || []),...(preparedDiagrams.dependencies || [])],source:{sha256:sourceSha256,bytes:Buffer.byteLength(sourceText)}};
123
+ if (!preparedBundle) bundle.digest = hash(JSON.stringify(bundle));
124
+ if (bundle.sourceText !== sourceText || JSON.stringify(bundle.document) !== JSON.stringify(document)) throw new Error('Prepared bundle differs from rendered source or canonical document.');
125
+ const page = portableBundle(bundle, pageDirectory || bundle.baseDir || (bundle.sourcePath && path.dirname(bundle.sourcePath)));
126
+ manifest.diagnostics = page.diagnostics;
127
+ manifest.dependencies = (page.dependencies || []).map(({contentBase64,...entry})=>entry);
128
+ return `<!doctype html>\n<html lang="${escape(locale)}" data-style="${escape(document.meta.style || 'clean')}" data-archify-artifact="explainer" data-artifact="${escape(document.meta.id)}" data-revision="${revision}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><meta name="description" content="${escape(document.answer.summary)}"><title>${escape(document.meta.title)} · Archify</title><link rel="icon" type="image/svg+xml" href="${favicon}"><style>${css}</style>${softwareNoticesHtml}</head><body><a class="skip-link" href="#board">${english?'Skip to content':'跳至正文'}</a><div class="shell"><header class="masthead"><h1>${escape(document.meta.title)}</h1><div class="tools interactive"><div class="theme-controls" role="group" aria-label="${english?'Theme':'主题'}">${['auto','light','dark'].map(theme=>`<button type="button" data-theme-choice="${theme}" aria-pressed="${theme === 'auto'}">${words[theme]}</button>`).join('')}</div><div class="tool-menu style-menu"><button type="button" class="tool-button" data-style-toggle aria-haspopup="menu" aria-expanded="false" aria-label="${words.style}"><span class="style-swatch" data-swatch="${escape(document.meta.style || 'clean')}"></span><span data-style-label>${words.styles[document.meta.style || 'clean']}</span>${icons.chevron}</button><div class="menu-options style-options" role="menu" aria-label="${words.style}" hidden>${['clean','editorial','product','swiss'].map(style=>`<button type="button" role="menuitemradio" aria-checked="${style === (document.meta.style || 'clean')}" data-style-choice="${style}" tabindex="-1"><span class="style-swatch" data-swatch="${style}"></span><span>${words.styles[style]}</span>${icons.check}</button>`).join('')}</div></div><div class="tool-menu export-menu"><button type="button" class="tool-button" data-export-toggle aria-haspopup="menu" aria-expanded="false">${icons.export}<span>${words.export}</span>${icons.chevron}</button><div class="menu-options" role="menu" aria-label="${words.export}" hidden><p class="menu-label" role="presentation">${words.image}</p>${[['png','PNG'],['webp','WebP'],['jpeg','JPG']].map(([format,label],index)=>`<button type="button" role="menuitem" tabindex="-1" data-export-image="${format}" aria-label="${words.image} ${label}">${icons.image}<span>${label}</span><small>${words.imageHints[index]}</small></button>`).join('')}<hr role="separator"><button type="button" role="menuitem" tabindex="-1" data-print>${icons.print}<span>${words.print}</span></button></div></div></div></header><main id="board"><div class="board">${panels}</div></main><footer>${footnotesHtml}<section class="sources"><h2>${words.sources}</h2><ol>${sourceList}</ol></section>${colophon}</footer></div><dialog class="viewer-dialog" id="diagram-dialog" aria-labelledby="diagram-dialog-title"><header><h2 id="diagram-dialog-title"></h2><button type="button" class="viewer-close" data-close-diagram>${icons.close}<span>${words.close}</span><kbd>Esc</kbd></button></header><p class="dialog-loading" role="status">${words.loading}</p></dialog><div id="toast" role="status" aria-live="polite"></div><script type="application/json" id="archify-explainer-prepared">${json(packPageBundle(page))}</script><script type="application/json" id="explainer-manifest">${json(manifest)}</script><script type="application/json" id="explainer-reader-words">${json(words)}</script><script>${js}</script></body></html>`;
129
+ }
130
+
131
+ // Native check filenames refer to disposable build files, not reader resources.
132
+ function portableChecks(checks) {
133
+ if (!checks?.check) return checks;
134
+ const {file, ...check} = checks.check;
135
+ return {...checks,check};
136
+ }
137
+
138
+ // A page is shared, so the draft it carries names local files relative to the page, never by absolute
139
+ // path: the reader sees no author directories. `patch explainer` resolves them against the page again.
140
+ function portableBundle(bundle, directory) {
141
+ if (!directory) return bundle;
142
+ const local = value => {
143
+ if (typeof value !== 'string') return value;
144
+ const paths = path.win32.isAbsolute(value) && !path.posix.isAbsolute(value) ? path.win32 : path;
145
+ if (!paths.isAbsolute(value)) return value;
146
+ const relative = paths.relative(directory, value);
147
+ // Different Windows volumes have no relative path. Frozen content stays usable;
148
+ // a missing origin must not be mistaken for the output directory during patching.
149
+ return paths.isAbsolute(relative) ? null : relative || '.';
150
+ };
151
+ const subject = entry => entry?.subject ? {...entry,subject:{...entry.subject,...('input' in entry.subject ? {input:local(entry.subject.input)} : {}),...('output' in entry.subject ? {output:local(entry.subject.output)} : {})}} : entry;
152
+ const page = {...bundle,sourcePath:local(bundle.sourcePath),...('inputPath' in bundle ? {inputPath:local(bundle.inputPath)} : {}),...('baseDir' in bundle ? {baseDir:local(bundle.baseDir)} : {}),
153
+ preparedDiagrams:{...bundle.preparedDiagrams,diagrams:Object.fromEntries(Object.entries(bundle.preparedDiagrams?.diagrams || {}).map(([id,diagram])=>[id,{...diagram,checks:portableChecks(diagram.checks)}]))},
154
+ dependencies:(bundle.dependencies || []).map(entry=>entry.path ? {...entry,path:local(entry.path)} : entry),diagnostics:(bundle.diagnostics || []).map(subject)};
155
+ page.digest = hash(JSON.stringify({...page,digest:undefined}));
156
+ return page;
157
+ }
158
+
159
+ // Prefix native SVG identifiers in the preview only. Original native HTML is frozen above.
160
+ function staticSvg(svg, id) {
161
+ const prefix = previewPrefix(id) + '-';
162
+ // Only element IDs are prefixed; data-node-id and data-edge-id stay semantic for step links.
163
+ const ids = new Map([...svg.matchAll(/(?<![\w-])id="([^"]+)"/g)].map(match => [match[1],prefix + match[1]]));
164
+ return svg.replace(/(?<![\w-])id="([^"]+)"/g,(_,value)=>`id="${escape(ids.get(value))}"`)
165
+ .replace(/url\(#([^\)]+)\)/g,(_,value)=>`url(#${ids.get(value) || value})`)
166
+ .replace(/\b(href|xlink:href)="#([^"]+)"/g,(_,attr,value)=>`${attr}="#${escape(ids.get(value) || value)}"`)
167
+ .replace(/\b(aria-labelledby|aria-describedby)="([^"]+)"/g,(_,attr,value)=>`${attr}="${escape(value.split(/\s+/).map(id=>ids.get(id) || id).join(' '))}"`)
168
+ .replace(/\s(?:tabindex="[^"]*"|role="button"|aria-pressed="[^"]*")/g,'');
169
+ }
170
+
171
+ // A scope written as "a · b · c" reads as separate conditions; anything else stays a sentence.
172
+ function scopeParts(scope) { return scope.split(/\s+·\s+/).map(part=>part.trim()).filter(Boolean); }
173
+
174
+ // Split a selector list on its top-level commas; commas inside :is(), :not() or attribute values stay.
175
+ function selectorList(selector) {
176
+ const parts = []; let depth = 0, quote = '', start = 0;
177
+ for (let i = 0; i < selector.length; i++) {
178
+ const c = selector[i];
179
+ if (quote) { if (c === quote && selector[i-1] !== '\\') quote = ''; }
180
+ else if (c === '"' || c === "'") quote = c;
181
+ else if (c === '(' || c === '[') depth++;
182
+ else if (c === ')' || c === ']') depth--;
183
+ else if (c === ',' && !depth) { parts.push(selector.slice(start,i)); start = i + 1; }
184
+ }
185
+ return [...parts, selector.slice(start)];
186
+ }
187
+ const nativeStyles = html => [...String(html).matchAll(/<style\b[^>]*>[\s\S]*?<\/style>/g)].map(match => match[0]).join('\n');
188
+ function previewPrefix(id) { return `preview-${id.replace(/[^A-Za-z0-9_-]/g,'-')}`; }
189
+
190
+ // Native CSS is scoped to its static preview. No native body/chrome layout enters the reader.
191
+ // The preview sets text in page fonts and shows only the SVG, so font faces and rules that name a
192
+ // class absent from the SVG (Viewer chrome, toolbars, cards) are dropped.
193
+ function scopeNativeCss(styles, scope, prefix, svg = '') {
194
+ const css = styles.replace(/<style[^>]*>/gi,'').replace(/<\/style>/gi,'').replace(/\/\*[\s\S]*?\*\//g,'');
195
+ const present = new Set(svg ? [...svg.matchAll(/\bclass="([^"]*)"/g)].flatMap(match => match[1].split(/\s+/)) : []);
196
+ const applies = value => !svg || [...value.replace(/:not\([^)]*\)/g,'').matchAll(/\.([A-Za-z_][\w-]*)/g)].every(match => present.has(match[1]));
197
+ const defaults = []; const systemDark = []; const systemLight = [];
198
+ function rules(input, collectThemes = true) {
199
+ let result = ''; let cursor = 0;
200
+ while (cursor < input.length) {
201
+ const opening = input.indexOf('{',cursor); if(opening < 0) break;
202
+ const selector = input.slice(cursor,opening).trim(); let depth = 1; let end = opening + 1; let quote = '';
203
+ for(; end < input.length && depth; end++) { const c = input[end]; if(quote) {if(c === quote && input[end-1] !== '\\') quote = '';} else if(c === '\"' || c === "'") quote = c; else if(c === '{') depth++; else if(c === '}') depth--; }
204
+ const body = input.slice(opening + 1,end-1).replace(/url\(#([^\)]+)\)/g,(_,id)=>`url(#${prefix}${id})`); cursor = end;
205
+ if(selector.startsWith('@media') || selector.startsWith('@supports') || selector.startsWith('@layer')) { result += `${selector}{${rules(body,false)}}`; continue; }
206
+ if(selector.startsWith('@')) continue;
207
+ const selectors = selectorList(selector).filter(value=>!/(^|[\s>+~])body(?:\b|$)/.test(value) && applies(value)).map(value=>{
208
+ let next = value.trim().replace(/#([A-Za-z][\w-]*)/g,(_,id)=>'#' + prefix + id);
209
+ next = next.replace(/^:root|^html\b/,scope);
210
+ if (next.startsWith('[data-theme=') || next.startsWith('[data-preset=')) next = scope + next;
211
+ return next.startsWith(scope) ? next : `${scope} ${next}`;
212
+ });
213
+ const rootSelectors = selectorList(selector).map(value=>value.trim());
214
+ if (collectThemes && rootSelectors.includes(':root')) defaults.push(body);
215
+ if (collectThemes && rootSelectors.some(value=>/^(?:\:root)?\[data-theme=[\"']?dark[\"']?\]$/.test(value))) systemDark.push(body);
216
+ if (collectThemes && rootSelectors.some(value=>/^(?:\:root)?\[data-theme=[\"']?light[\"']?\]$/.test(value))) systemLight.push(body);
217
+ if(selectors.length) result += `${selectors.join(',')}{${body}}`;
218
+ }
219
+ return result;
220
+ }
221
+ const scoped = rules(css);
222
+ const light = systemLight.length ? systemLight : defaults;
223
+ return scoped + (systemLight.length ? `${scope}:not([data-theme]){${systemLight.join(';')}}` : '') + (systemDark.length ? `@media(prefers-color-scheme:dark){${scope}:not([data-theme]){${systemDark.join(';')}}}` : '') + (light.length ? `@media print{${scope},${scope}[data-theme],${scope}:not([data-theme]){${light.join(';')}}}` : '');
224
+ }
@@ -0,0 +1,20 @@
1
+ import { createHash } from 'node:crypto';
2
+ const hash = text => createHash('sha256').update(text).digest('hex');
3
+
4
+ /** Stable authored-content identity; excludes filesystem relocation and outer reader presentation. */
5
+ export function computeExplainerRevision({document,sourceText=JSON.stringify(document),preparedSources={},preparedDiagrams={},dependencies}) {
6
+ const sourceSha256 = hash(sourceText);
7
+ const sources = preparedSources.sources || preparedSources;
8
+ const assets = preparedSources.assets || {};
9
+ const diagrams = preparedDiagrams.diagrams || preparedDiagrams;
10
+ const stable = value => Array.isArray(value) ? value.map(stable) : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).filter(([,entry])=>entry !== undefined).sort(([a],[b])=>a < b ? -1 : a > b ? 1 : 0).map(([key,entry])=>[key,stable(entry)])) : value;
11
+ const ordered = entries => entries.map(stable).sort((a,b)=>JSON.stringify(a) < JSON.stringify(b) ? -1 : JSON.stringify(a) > JSON.stringify(b) ? 1 : 0);
12
+ const resourceIdentity = {
13
+ version:1,sourceSha256,documentSha256:hash(JSON.stringify(stable(document))),
14
+ dependencies:ordered((dependencies || [...(preparedSources.dependencies || []),...(preparedDiagrams.dependencies || [])]).map(({kind,id,sha256,bytes})=>({kind,id,sha256,bytes}))),
15
+ sources:Object.fromEntries(Object.entries(sources).map(([id,source])=>[id,{title:source.title,url:source.url,path:source.path,lines:source.lines,revision:source.revision,textSha256:typeof source.text === 'string' ? hash(source.text) : undefined}])),
16
+ children:Object.fromEntries(Object.entries(diagrams).map(([id,child])=>[id,{htmlSha256:hash(child.html),specSha256:hash(JSON.stringify(stable(child.spec || {})))}])),
17
+ assets:Object.fromEntries(Object.entries(assets).map(([id,asset])=>[id,{sha256:asset.sha256,dataSha256:hash(asset.dataUrl || ''),mime:asset.mime,bytes:asset.bytes}]))
18
+ };
19
+ return hash(JSON.stringify(stable(resourceIdentity)));
20
+ }
@@ -0,0 +1,318 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { createHash } from 'node:crypto';
5
+ import { throwDiagnosticError } from '../renderers/shared/diagnostics.mjs';
6
+ import { verifyRepositoryEvidence } from '../renderers/shared/repository-evidence.mjs';
7
+ import { parseRepositoryRemote } from '../renderers/shared/repository-location.mjs';
8
+ import { chartFromData, compileExplainerChart } from './charts.mjs';
9
+
10
+ const MAX_CODE_BYTES = 16 * 1024 * 1024;
11
+ const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
12
+ const MAX_DATA_BYTES = 2 * 1024 * 1024;
13
+ const MAX_ASSET_BYTES = 32 * 1024 * 1024;
14
+ const SECRET_NAME = /^(?:\.env(?:\..*)?|\.npmrc|\.netrc|\.pypirc|\.(?:ssh|aws|gnupg|kube)|(?:id_(?:rsa|dsa|ecdsa|ed25519))(?:\..*)?|(?:credentials?|secrets?)(?:[._-].*)?|.*\.(?:pem|key|p12|pfx|keystore))$/i;
15
+ const SECRET_CONTENT = /-----BEGIN (?:[A-Z ]*PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----|\b(?:AKIA|ASIA)[A-Z0-9]{16}\b|\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-(?:proj-)?[A-Za-z0-9_-]{20,})\b/i;
16
+ const CREDENTIAL_ASSIGNMENT = /\b(?:password|passwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret)\b["']?[ \t]*[:=][ \t]*([^\r\n]*)/gi;
17
+ const ENVIRONMENT_REFERENCE = /^(?:\$(?:[A-Za-z_][\w]*|\{[A-Za-z_][\w]*\})|(?:process\.env|os\.environ|env)(?:\.[A-Za-z_][\w]*|\[['"][A-Za-z_][\w]*['"]\])|(?:os\.getenv|getenv|env)\(\s*['"][A-Za-z_][\w]*['"]\s*\))$/;
18
+
19
+ export function containsExplainerSecret(text, sourcePath = '') {
20
+ if (SECRET_CONTENT.test(text)) return true;
21
+ const configuration = /\.(?:ya?ml|toml|ini|conf|config|properties|cfg|sh|bash)$/i.test(sourcePath);
22
+ for (const match of text.matchAll(CREDENTIAL_ASSIGNMENT)) {
23
+ const value = match[1].trim().replace(/\s+(?:#|\/\/).*$/, '').replace(/[;,]\s*$/, '').trim();
24
+ if (!value || /^(?:null|nil|none|undefined|~)$/i.test(value) || ENVIRONMENT_REFERENCE.test(value)) continue;
25
+ const quoted = value.match(/^(['"`])([\s\S]*?)\1/);
26
+ if (quoted) {
27
+ if (quoted[2].length >= 4 && !ENVIRONMENT_REFERENCE.test(quoted[2])) return true;
28
+ } else if (configuration) {
29
+ // Configuration scalars are values, whereas an unquoted JS/Python identifier can be a code reference.
30
+ return true;
31
+ }
32
+ }
33
+ return false;
34
+ }
35
+
36
+ function location(document, pointer, component) {
37
+ const map = document?.sourceMap;
38
+ const entry = map instanceof Map ? map.get(pointer) : map?.[pointer];
39
+ return { path: pointer, component, ...(document?.sourcePath ? { input: document.sourcePath } : {}), line: Number.isInteger(entry?.line) && entry.line > 0 ? entry.line : 1,
40
+ ...(Number.isInteger(entry?.column) ? { column: entry.column } : {}) };
41
+ }
42
+
43
+ function fail(document, pointer, component, reason, message, fix, example) {
44
+ const examples = {
45
+ source: '{"id":"router","title":"Router","path":"src/router.js","lines":[1,3]}',
46
+ code: '{"id":"code","type":"code","source":"router"}',
47
+ image: '{"id":"image","type":"image","path":"images/figure.png","alt":"Figure"}',
48
+ };
49
+ throwDiagnosticError(message, [{ code: `explainer/${reason}`, severity: 'error', message,
50
+ subject: location(document, pointer, component),
51
+ evidence: { reason, example: example || examples[component] || examples.source }, supportedFixes: [fix] }]);
52
+ }
53
+
54
+ function safePath(document, value, pointer, component) {
55
+ if (typeof value !== 'string' || !value || /^[\/\\]/.test(value)
56
+ || /[\\:\u0000-\u001f\u007f?#]/.test(value)
57
+ || value.split('/').some(part => !part || part === '.' || part === '..' || part.toLowerCase() === '.git')) {
58
+ fail(document, pointer, component, 'source-path', 'Resource paths must stay inside their root directory.',
59
+ 'Use a relative path with forward slashes and no dot segments.', 'src/router.js');
60
+ }
61
+ if (value.split('/').some(part => SECRET_NAME.test(part))) {
62
+ fail(document, pointer, component, 'secret-path', 'This resource belongs to a sensitive file class.',
63
+ 'Reference a public source file or remove this resource.');
64
+ }
65
+ return value;
66
+ }
67
+
68
+ function gitBytes(document, repoRoot, args, pointer, limit = MAX_CODE_BYTES) {
69
+ const result = spawnSync('git', ['--no-replace-objects', '-C', repoRoot, ...args], { maxBuffer: limit });
70
+ if (result.error || result.status !== 0 || !Buffer.isBuffer(result.stdout) || result.stdout.length > limit) {
71
+ fail(document, pointer, 'source', 'source-read', 'The pinned repository resource could not be read within the size limit.',
72
+ 'Use an available, readable source file smaller than 16 MiB.');
73
+ }
74
+ return result.stdout;
75
+ }
76
+
77
+ function deriveRepository(document, repoRoot) {
78
+ if (!repoRoot) fail(document, '/meta/repository', 'source', 'repository-root', 'Repository sources require a local repository.',
79
+ 'Pass --repo-root with the matching local Git checkout.', '--repo-root /absolute/path/to/repository');
80
+ const raw = gitBytes(document, repoRoot, ['remote', 'get-url', 'origin'], '/meta/repository').toString('utf8').trim();
81
+ const remote = parseRepositoryRemote(raw);
82
+ if (!remote) fail(document, '/meta/repository', 'source', 'repository-origin', 'The repository origin cannot be represented safely.',
83
+ 'Configure a credential-free repository origin without query or fragment.');
84
+ const revision = gitBytes(document, repoRoot, ['rev-parse', '--verify', 'HEAD^{commit}'], '/meta/repository').toString('utf8').trim();
85
+ return { url: remote.url, revision,
86
+ ...(remote.provider && remote.protocol === 'https:' && remote.endpoint === 'standard' ? {} : { link_mode: 'local-only' }) };
87
+ }
88
+
89
+ function resources(document) {
90
+ const result = [];
91
+ for (const [panelIndex, panel] of (document.panels || []).entries()) {
92
+ for (const [index, block] of (panel.blocks || []).entries()) result.push({ block, pointer: `/panels/${panelIndex}/blocks/${index}` });
93
+ }
94
+ return result;
95
+ }
96
+
97
+ function digest(bytes) {
98
+ return { sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length,
99
+ contentBase64: bytes.toString('base64') };
100
+ }
101
+
102
+ function imageMime(bytes) {
103
+ if (bytes.length >= 24 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
104
+ && bytes.toString('ascii', 12, 16) === 'IHDR') return 'image/png';
105
+ if (bytes.length >= 4 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return 'image/jpeg';
106
+ if (bytes.length >= 13 && /^(GIF87a|GIF89a)$/.test(bytes.toString('ascii', 0, 6))) return 'image/gif';
107
+ if (bytes.length >= 16 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') return 'image/webp';
108
+ return null;
109
+ }
110
+
111
+ function sameFile(left, right) {
112
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
113
+ }
114
+
115
+ function readImage(document, baseDir, authoredPath, pointer) {
116
+ if (!baseDir) fail(document, pointer, 'image', 'asset-root', 'Local images require an input directory.',
117
+ 'Provide the original input directory as baseDir.');
118
+ const relative = safePath(document, authoredPath, `${pointer}/path`, 'image');
119
+ const expected = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' }[path.extname(relative).toLowerCase()];
120
+ if (!expected) fail(document, `${pointer}/path`, 'image', 'asset-format', 'Only local PNG, JPEG, WebP and GIF images are supported.',
121
+ 'Use a PNG, JPEG, WebP or GIF image.');
122
+ const bytes = readContained(document, baseDir, relative, pointer, 'image', MAX_IMAGE_BYTES, '8 MiB');
123
+ const mime = imageMime(bytes);
124
+ if (mime !== expected) fail(document, pointer, 'image', 'asset-format', 'The image bytes do not match a supported image format.',
125
+ 'Use a correctly encoded image whose extension matches its bytes.');
126
+ return { path: relative, mime, ...digest(bytes), dataUrl: `data:${mime};base64,${bytes.toString('base64')}` };
127
+ }
128
+
129
+ // Read a regular file inside baseDir without following links, and confirm it did not change meanwhile.
130
+ function readContained(document, baseDir, relative, pointer, component, limit, limitText) {
131
+ const noun = component === 'image' ? 'image' : 'data file', Noun = noun[0].toUpperCase() + noun.slice(1);
132
+ let fd;
133
+ try {
134
+ const root = fs.realpathSync(baseDir);
135
+ const ancestors = [[root, fs.lstatSync(root)]];
136
+ let candidate = root;
137
+ for (const part of relative.split('/')) {
138
+ candidate = path.join(candidate, part);
139
+ const stat = fs.lstatSync(candidate);
140
+ if (stat.isSymbolicLink()) fail(document, `${pointer}/path`, component, 'asset-symlink', `${Noun} paths may not traverse symbolic links.`,
141
+ `Copy the ${noun} into the input directory as a regular file.`);
142
+ ancestors.push([candidate, stat]);
143
+ }
144
+ if (!ancestors.at(-1)[1].isFile()) fail(document, pointer, component, 'asset-file', `The ${noun} must be a regular file.`, `Use a regular ${noun}.`);
145
+ fd = fs.openSync(candidate, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
146
+ const before = fs.fstatSync(fd);
147
+ if (!sameFile(before, ancestors.at(-1)[1]) || before.size > limit) fail(document, pointer, component, 'asset-size',
148
+ `The ${noun} changed or exceeds the ${limitText} size limit.`, `Use a stable ${noun} smaller than ${limitText}.`);
149
+ const bytes = Buffer.alloc(before.size);
150
+ let offset = 0;
151
+ while (offset < bytes.length) {
152
+ const count = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
153
+ if (!count) break;
154
+ offset += count;
155
+ }
156
+ const unchanged = offset === bytes.length && sameFile(before, fs.fstatSync(fd))
157
+ && ancestors.every(([file, stat]) => { const current = fs.lstatSync(file); return !current.isSymbolicLink() && sameFile(current, stat); });
158
+ if (!unchanged) fail(document, pointer, component, 'asset-changed', `The ${noun} path or bytes changed while being frozen.`,
159
+ `Finish ${noun} updates before rendering again.`);
160
+ return bytes;
161
+ } catch (error) {
162
+ if (error.archifyDiagnostics) throw error;
163
+ fail(document, pointer, component, 'asset-read', `The ${noun} cannot be read safely inside the input directory.`,
164
+ `Use an accessible regular ${noun} inside the input directory.`);
165
+ } finally {
166
+ if (fd !== undefined) fs.closeSync(fd);
167
+ }
168
+ }
169
+
170
+ /** Prepare source locations and freeze code/image bytes; never mutate author input. */
171
+ export async function resolveExplainerSources(document, { repoRoot, baseDir, sourceMap, sourcePath } = {}) {
172
+ const context = { sourceMap, sourcePath };
173
+ const sources = Object.create(null);
174
+ const assets = Object.create(null);
175
+ const dependencies = [];
176
+ const entries = document.sources || [];
177
+ const blocks = resources(document);
178
+ const codeIds = new Set();
179
+ const blockIds = new Set();
180
+ for (const { block, pointer } of blocks) {
181
+ if (!['code', 'image'].includes(block.type)) continue;
182
+ if (typeof block.id !== 'string' || !block.id || blockIds.has(block.id)) fail(context, pointer, block.type, 'resource-id',
183
+ 'Resource blocks require a unique stable id.', 'Give each code or image block its own id.');
184
+ blockIds.add(block.id);
185
+ if (block.type === 'code') codeIds.add(block.source);
186
+ }
187
+ const repositorySources = [];
188
+ for (const [index, entry] of entries.entries()) {
189
+ const pointer = `/sources/${index}`;
190
+ if (typeof entry.id !== 'string' || !entry.id || Object.hasOwn(sources, entry.id)) fail(context, pointer, 'source', 'source-id',
191
+ 'Sources require unique ids.', 'Give each source a unique id.');
192
+ sources[entry.id] = null;
193
+ if (entry.path) {
194
+ safePath(context, entry.path, `${pointer}/path`, 'source');
195
+ if (entry.lines && (!Array.isArray(entry.lines) || entry.lines.length !== 2 || !entry.lines.every(Number.isSafeInteger)
196
+ || entry.lines[0] < 1 || entry.lines[1] < entry.lines[0])) fail(context, `${pointer}/lines`, 'source', 'source-lines',
197
+ 'Repository line ranges must be ordered and positive.', 'Use lines: [start, end].', '[1, 3]');
198
+ }
199
+ if (entry.url) {
200
+ let url; try { url = new URL(entry.url); } catch {}
201
+ if (!url || !['https:', 'http:'].includes(url.protocol) || url.username || url.password || /[\u0000-\u0020\u007f]/.test(entry.url)) fail(context, `${pointer}/url`, 'source', 'source-url',
202
+ 'Source links require credential-free HTTP(S) URLs.', 'Use https://example.com/article.');
203
+ }
204
+ sources[entry.id] = Object.freeze({id:entry.id,title:entry.title,...(entry.url?{url:entry.url}:{}),...(entry.path?{path:entry.path}:{}),...(entry.lines?{lines:[...entry.lines]}:{}),...(entry.revision?{revision:entry.revision}:{})});
205
+ if (codeIds.has(entry.id)) {
206
+ if (!entry.path || !entry.lines) fail(context,pointer,'code','code-source','Code requires a Git file and line range.','Add path and lines to the source.');
207
+ repositorySources.push({entry,index});
208
+ }
209
+ }
210
+
211
+ let repository = null;
212
+ if (repositorySources.length) {
213
+ const metadata = document.meta?.repository ? { ...document.meta.repository } : deriveRepository(context, repoRoot);
214
+ for (const { entry, index } of repositorySources) {
215
+ if (entry.revision !== undefined && String(entry.revision).toLowerCase() !== String(metadata.revision).toLowerCase()) fail(context, `/sources/${index}/revision`, 'source', 'source-revision',
216
+ 'All repository sources must use the document’s pinned revision.', 'Use the document repository revision or omit the source revision.');
217
+ }
218
+ let verified;
219
+ try {
220
+ verified = verifyRepositoryEvidence('architecture', { meta: { repository: metadata }, components: repositorySources.map(({ entry }) => ({
221
+ id: entry.id, sources: [{ path: entry.path, line: entry.lines[0], end_line: entry.lines[1] }],
222
+ })) }, repoRoot);
223
+ } catch (error) {
224
+ const original = error.archifyDiagnostics?.[0];
225
+ const match = original?.subject?.path?.match(/^\/components\/(\d+)/);
226
+ const index = match ? repositorySources[Number(match[1])]?.index : undefined;
227
+ const examples = {
228
+ 'repository-evidence/root-required': '--repo-root /absolute/path/to/repository',
229
+ 'repository-evidence/root-not-top-level': '--repo-root /absolute/path/to/repository',
230
+ 'repository-evidence/root-unreadable': '--repo-root /absolute/path/to/repository',
231
+ 'repository-evidence/revision-invalid': 'git rev-parse HEAD',
232
+ 'repository-evidence/revision-unavailable': 'git rev-parse HEAD',
233
+ 'repository-evidence/url-invalid': '{"url":"https://github.com/owner/repository"}',
234
+ 'repository-evidence/origin-mismatch': 'git remote get-url origin',
235
+ 'repository-evidence/line-out-of-range': '{"path":"src/router.js","lines":[1,3]}',
236
+ 'repository-evidence/file-missing': '{"path":"src/router.js","lines":[1,3]}',
237
+ };
238
+ fail(context, index === undefined ? '/meta/repository' : `/sources/${index}`, 'source', 'repository-location',
239
+ 'The declared repository location could not be verified.', 'Correct the repository origin, pinned commit, file path and line range.',
240
+ examples[original?.code]);
241
+ }
242
+ repository = Object.freeze({ ...verified.repository });
243
+ for (const { entry, index } of repositorySources) {
244
+ const reference = verified.nodes[entry.id][0];
245
+ const result = { id: entry.id, ...(entry.title === undefined ? {} : { title: entry.title }),
246
+ revision: repository.revision, path: reference.path, lines: Object.freeze([...entry.lines]),
247
+ ...(reference.href ? { url: reference.href } : {}) };
248
+ if (codeIds.has(entry.id)) {
249
+ const bytes = gitBytes(context, repoRoot, ['show', `${repository.revision}:${entry.path}`], `/sources/${index}`);
250
+ let text;
251
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch {
252
+ fail(context, `/sources/${index}`, 'code', 'source-encoding', 'Code sources must contain UTF-8 text.', 'Use a UTF-8 text source file.');
253
+ }
254
+ if (text.includes('\0') || containsExplainerSecret(text, entry.path)) fail(context, `/sources/${index}`, 'code', 'secret-content',
255
+ 'This source cannot be displayed because it contains sensitive or binary content.', 'Use a public source file without secrets.');
256
+ result.text = text.split(/\r\n|\n|\r/).slice(entry.lines[0] - 1, entry.lines[1]).join('\n');
257
+ const frozen = digest(bytes);
258
+ Object.assign(result, { sha256: frozen.sha256, bytes: frozen.bytes });
259
+ dependencies.push(Object.freeze({ kind: 'repository', id: entry.id, path: entry.path, revision: repository.revision, ...frozen }));
260
+ }
261
+ sources[entry.id] = Object.freeze(result);
262
+ }
263
+ }
264
+ // A cited repository file also carries its excerpt when the repository is at hand, so a reader can
265
+ // check the claim in place. A citation that cannot be verified or read stays a plain reference.
266
+ const cited = document.sources.map((entry,index)=>({entry,index})).filter(({entry})=>entry.path && entry.lines && !codeIds.has(entry.id));
267
+ if (cited.length && repoRoot) {
268
+ let metadata = null;
269
+ try { metadata = document.meta?.repository ? { ...document.meta.repository } : deriveRepository(context, repoRoot); } catch {}
270
+ for (const { entry, index } of metadata ? cited : []) {
271
+ try {
272
+ const verified = verifyRepositoryEvidence('architecture', { meta: { repository: metadata }, components: [{ id: entry.id, sources: [{ path: entry.path, line: entry.lines[0], end_line: entry.lines[1] }] }] }, repoRoot);
273
+ const reference = verified.nodes[entry.id][0];
274
+ const bytes = gitBytes(context, repoRoot, ['show', `${verified.repository.revision}:${entry.path}`], `/sources/${index}`);
275
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
276
+ if (text.includes('\0') || containsExplainerSecret(text, entry.path)) continue;
277
+ const frozen = digest(bytes);
278
+ sources[entry.id] = Object.freeze({ id: entry.id, ...(entry.title === undefined ? {} : { title: entry.title }), revision: verified.repository.revision, path: reference.path,
279
+ lines: Object.freeze([...entry.lines]), ...(reference.href ? { url: reference.href } : {}), text: text.split(/\r\n|\n|\r/).slice(entry.lines[0] - 1, entry.lines[1]).join('\n'), sha256: frozen.sha256, bytes: frozen.bytes });
280
+ dependencies.push(Object.freeze({ kind: 'repository', id: entry.id, path: entry.path, revision: verified.repository.revision, ...frozen }));
281
+ repository ||= Object.freeze({ ...verified.repository });
282
+ } catch {}
283
+ }
284
+ }
285
+ for (const { block, pointer } of blocks) {
286
+ if (block.type === 'code') {
287
+ const source = sources[block.source];
288
+ if (!source || !source.path || typeof source.text !== 'string') fail(context, `${pointer}/source`, 'code', 'code-source',
289
+ 'Code blocks require a Git code excerpt.', 'Reference a repository source id with a valid line range.');
290
+ }
291
+ }
292
+ let assetBytes = 0;
293
+ for (const { block, pointer } of blocks) {
294
+ if (block.type !== 'image') continue;
295
+ const asset = readImage(context, baseDir, block.path, pointer);
296
+ assetBytes += asset.bytes;
297
+ if (assetBytes > MAX_ASSET_BYTES) fail(context, pointer, 'image', 'asset-budget', 'Embedded images exceed the 32 MiB document limit.',
298
+ 'Reduce the total embedded image bytes below 32 MiB.');
299
+ assets[block.id] = Object.freeze({ id: block.id, alt: block.alt, ...asset });
300
+ dependencies.push(Object.freeze({ kind: 'image', id: block.id, path: asset.path, sha256: asset.sha256, bytes: asset.bytes, contentBase64: asset.contentBase64 }));
301
+ }
302
+ // A data chart reads its CSV or JSON once, freezes the bytes and keeps the chart text built from them.
303
+ const charts = Object.create(null);
304
+ for (const { block, pointer } of blocks) {
305
+ if (block.type !== 'chart' || !block.data) continue;
306
+ if (!baseDir) fail(context, pointer, 'chart', 'asset-root', 'A data chart requires an input directory.', 'Run finalize on the draft file next to its data.');
307
+ const relative = safePath(context, block.data.path, `${pointer}/data/path`, 'chart');
308
+ if (!/\.(?:csv|json)$/iu.test(relative)) fail(context, `${pointer}/data/path`, 'chart', 'chart-data-format', 'Chart data is a .csv or .json file.', 'data=latency.csv');
309
+ const bytes = readContained(context, baseDir, relative, pointer, 'chart', MAX_DATA_BYTES, '2 MiB');
310
+ let built;
311
+ try { built = chartFromData(bytes, relative, block.data); } catch { built = { error: 'The data file is not UTF-8 text.' }; }
312
+ const chart = built.error ? built : compileExplainerChart(built.text);
313
+ if (chart.error) fail(context, pointer, 'chart', 'chart-data', chart.error, '@chart type=bar data=latency.csv x=day y=p50,p99');
314
+ charts[block.id] = built.text;
315
+ dependencies.push(Object.freeze({ kind: 'data', id: block.id, path: relative, ...digest(bytes) }));
316
+ }
317
+ return { sources: Object.freeze(sources), assets: Object.freeze(assets), charts: Object.freeze(charts), dependencies: Object.freeze(dependencies), repository };
318
+ }