@stacksjs/defaults 0.75.360 → 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 (209) hide show
  1. package/ai/skills/stacks-ai/SKILL.md +22 -0
  2. package/ai/skills/stacks-native/CATALOG.md +4 -4
  3. package/ai/skills/stacks-payments/SKILL.md +28 -0
  4. package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
  5. package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
  6. package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
  7. package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
  8. package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
  9. package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
  10. package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
  11. package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
  12. package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
  13. package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
  14. package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
  15. package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
  16. package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
  17. package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
  18. package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
  19. package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
  20. package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
  21. package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
  22. package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
  23. package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
  24. package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
  25. package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
  26. package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
  27. package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
  28. package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
  29. package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
  30. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
  31. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
  32. package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
  33. package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
  34. package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
  35. package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
  36. package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
  37. package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
  38. package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
  39. package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
  40. package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
  41. package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
  42. package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
  43. package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
  44. package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
  45. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
  46. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
  47. package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
  48. package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
  49. package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
  50. package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
  51. package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
  52. package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
  53. package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
  54. package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
  55. package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
  56. package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
  57. package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
  58. package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
  59. package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
  60. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
  61. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
  62. package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
  63. package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
  64. package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
  65. package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
  66. package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
  67. package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
  68. package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
  69. package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
  70. package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
  71. package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
  72. package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
  73. package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
  74. package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
  75. package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
  76. package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
  77. package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
  78. package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
  79. package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
  80. package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
  81. package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
  82. package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
  83. package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
  84. package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
  85. package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
  86. package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
  87. package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
  88. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
  89. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
  90. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
  91. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
  92. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
  93. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
  94. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
  95. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
  96. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
  97. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
  98. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
  99. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
  100. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
  101. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
  102. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
  103. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
  104. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
  105. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
  106. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
  107. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
  108. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
  109. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
  110. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
  111. package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
  112. package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
  113. package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
  114. package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
  115. package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
  116. package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
  117. package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
  118. package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
  119. package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
  120. package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
  121. package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
  122. package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
  123. package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
  124. package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
  125. package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
  126. package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
  127. package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
  128. package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
  129. package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
  130. package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
  131. package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
  132. package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
  133. package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
  134. package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
  135. package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
  136. package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
  137. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
  138. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
  139. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
  140. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
  141. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
  142. package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
  143. package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
  144. package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
  145. package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
  146. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
  147. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
  148. package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
  149. package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
  150. package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
  151. package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
  152. package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
  153. package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
  154. package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
  155. package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
  156. package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
  157. package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
  158. package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
  159. package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
  160. package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
  161. package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
  162. package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
  163. package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
  164. package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
  165. package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
  166. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
  167. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
  168. package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
  169. package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
  170. package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
  171. package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
  172. package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
  173. package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
  174. package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
  175. package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
  176. package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
  177. package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
  178. package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
  179. package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
  180. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
  181. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
  182. package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
  183. package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
  184. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
  185. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
  186. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
  187. package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
  188. package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
  189. package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
  190. package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
  191. package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
  192. package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
  193. package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
  194. package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
  195. package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
  196. package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
  197. package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
  198. package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
  199. package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
  200. package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
  201. package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
  202. package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
  203. package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
  204. package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
  205. package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
  206. package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
  207. package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
  208. package/ide/vscode/package.json +1 -1
  209. package/package.json +2 -2
@@ -0,0 +1,290 @@
1
+ import { normalizeRoutePoints, rectsOverlap, segmentRectClearanceWithin } from './geometry.mjs';
2
+ import { createSpatialGrid } from './spatial-grid.mjs';
3
+
4
+ // A bounded fallback for an unpinned label whose usual position collides.
5
+ // It never routes an edge, moves a node, expands the canvas, or rewrites input.
6
+ export function placeAutomaticLabels({
7
+ labels, routes, components, titles, viewBox, placementBottom = viewBox[1], fallbackRing = true, keepFallbackNearRoute = false,
8
+ gridSweep = false, maxOwnRouteDistance = Infinity,
9
+ }) {
10
+ const placed = [...labels];
11
+ const obstacles = [...components, ...titles];
12
+ const segments = routes.flatMap(({ relationIndex, points }) => {
13
+ const normalized = normalizeRoutePoints(points);
14
+ return normalized.slice(1).map((end, index) => ({ relationIndex, start: normalized[index], end }));
15
+ });
16
+ const inside = rect => (
17
+ rect.x >= 0 && rect.y >= 0
18
+ && rect.x + rect.width <= viewBox[0] && rect.y + rect.height <= viewBox[1]
19
+ );
20
+ // The mask test asked every segment about every candidate position. Segments
21
+ // go into a uniform grid once, and a candidate only asks the cells it covers.
22
+ const SEGMENT_CELL = 120;
23
+ const segmentGrid = createSpatialGrid(SEGMENT_CELL);
24
+ for (const segment of segments) {
25
+ const [sx, sy] = segment.start;
26
+ const [ex, ey] = segment.end;
27
+ segmentGrid.insert({
28
+ minX: Math.min(sx, ex), maxX: Math.max(sx, ex),
29
+ minY: Math.min(sy, ey), maxY: Math.max(sy, ey),
30
+ }, segment);
31
+ }
32
+ const segmentsNear = (rect, margin) => segmentGrid.query({
33
+ minX: rect.x - margin, maxX: rect.x + rect.width + margin,
34
+ minY: rect.y - margin, maxY: rect.y + rect.height + margin,
35
+ });
36
+ const masksRoute = rect => {
37
+ for (const segment of segmentsNear(rect, 4)) {
38
+ if (segment.relationIndex === rect.relationIndex) continue;
39
+ if (segmentRectClearanceWithin(segment, rect, 4) + 0.0001 < 4) return true;
40
+ }
41
+ return false;
42
+ };
43
+ const overlapsLabel = (rect, index, gap = 0) => placed.some((other, otherIndex) => (
44
+ otherIndex !== index && rectsOverlap(rect, other, gap)
45
+ ));
46
+ const clear = (rect, index) => (
47
+ inside(rect) && rect.y + rect.height <= placementBottom
48
+ && !obstacles.some(obstacle => rectsOverlap(rect, obstacle, 2))
49
+ && !overlapsLabel(rect, index, 2) && !masksRoute(rect)
50
+ && (!Number.isFinite(maxOwnRouteDistance) || segments.some(segment =>
51
+ segment.relationIndex === rect.relationIndex
52
+ && segmentRectClearanceWithin(segment, rect, maxOwnRouteDistance) <= maxOwnRouteDistance))
53
+ );
54
+ const rectAt = (label, lx, ly) => ({
55
+ ...label, lx, ly, x: lx - label.width / 2, y: ly - 10,
56
+ });
57
+ // Fixed fractions can miss a narrow free slot. Try the ends of the free
58
+ // intervals beside an orthogonal segment, using obstacle edges rather
59
+ // than a finer sampling grid. The ordinary clearance test still decides
60
+ // whether a candidate is usable; this search never changes geometry.
61
+ const slidingCandidates = (label, index, { start: a, end: b }) => {
62
+ const horizontal = Math.abs(a[1] - b[1]) < 0.0001;
63
+ if (!horizontal && Math.abs(a[0] - b[0]) >= 0.0001) return [];
64
+ const axis = horizontal ? 'x' : 'y';
65
+ const size = horizontal ? 'width' : 'height';
66
+ const across = horizontal ? 'y' : 'x';
67
+ const crossSize = horizontal ? 'height' : 'width';
68
+ const low = Math.min(a[horizontal ? 0 : 1], b[horizontal ? 0 : 1]) + 8;
69
+ const high = Math.max(a[horizontal ? 0 : 1], b[horizontal ? 0 : 1]) - label[size] - 8;
70
+ if (high < low) return [];
71
+ const offsets = horizontal
72
+ ? [a[1] - 20, a[1] + 10, a[1] - 28, a[1] + 18]
73
+ : [a[0] - label.width - 6, a[0] + 6, a[0] - label.width - 14, a[0] + 14];
74
+ return offsets.flatMap(offset => {
75
+ const band = { ...label, [axis]: low, [size]: high - low + label[size], [across]: offset };
76
+ const blockers = [
77
+ ...obstacles.map(rect => ({ rect, gap: 2 })),
78
+ ...placed.filter((_, otherIndex) => otherIndex !== index).map(rect => ({ rect, gap: 2 })),
79
+ ...segmentsNear(band, 4).filter(segment => segment.relationIndex !== label.relationIndex).map(segment => ({
80
+ rect: {
81
+ x: Math.min(segment.start[0], segment.end[0]), y: Math.min(segment.start[1], segment.end[1]),
82
+ width: Math.abs(segment.end[0] - segment.start[0]), height: Math.abs(segment.end[1] - segment.start[1]),
83
+ }, gap: 4,
84
+ })),
85
+ ];
86
+ const positions = [Math.max(low, Math.min(high, label[axis])), low, high];
87
+ for (const { rect, gap } of blockers) {
88
+ if (offset + label[crossSize] + gap <= rect[across]
89
+ || rect[across] + rect[crossSize] + gap <= offset) continue;
90
+ positions.push(rect[axis] - gap - label[size], rect[axis] + rect[size] + gap);
91
+ }
92
+ return [...new Set(positions)].filter(position => position >= low && position <= high)
93
+ .sort((left, right) => Math.abs(left - label[axis]) - Math.abs(right - label[axis]))
94
+ .map(position => {
95
+ const rect = { ...label, [axis]: position, [across]: offset };
96
+ return rectAt(label, rect.x + label.width / 2, rect.y + 10);
97
+ });
98
+ });
99
+ };
100
+ // An opted-in grid layout runs parallel lines through shared gaps. Rank
101
+ // every position along all of the label's own segments: beside the line
102
+ // first, then centred on its own line (the plate interrupts only that
103
+ // line), then stepping outward past neighbouring parallels.
104
+ const gridCandidates = (label) => {
105
+ const fractions = [0.5, 0.25, 0.75, 0.375, 0.625, 0.125, 0.875];
106
+ const ranked = [];
107
+ // A close parallel of another relationship on one side (a reciprocal
108
+ // pair) makes a label on that side read as the neighbour's: prefer the
109
+ // far side.
110
+ const parallelOnLowSide = (a, b, axis) => {
111
+ const across = 1 - axis;
112
+ const [low, high] = [Math.min(a[axis], b[axis]), Math.max(a[axis], b[axis])];
113
+ let nearest = null;
114
+ for (const other of segments) {
115
+ if (other.relationIndex === label.relationIndex) continue;
116
+ if (Math.abs(other.start[across] - other.end[across]) > 0.0001) continue;
117
+ const distance = other.start[across] - a[across];
118
+ if (Math.abs(distance) < 0.0001 || Math.abs(distance) > 36) continue;
119
+ const overlap = Math.min(high, Math.max(other.start[axis], other.end[axis]))
120
+ - Math.max(low, Math.min(other.start[axis], other.end[axis]));
121
+ if (overlap <= 0) continue;
122
+ if (nearest === null || Math.abs(distance) < Math.abs(nearest)) nearest = distance;
123
+ }
124
+ return nearest !== null && nearest < 0;
125
+ };
126
+ for (const { start: a, end: b } of segments.filter(segment => segment.relationIndex === label.relationIndex)) {
127
+ if (Math.abs(a[1] - b[1]) < 0.0001 && Math.abs(a[0] - b[0]) >= label.width + 16) {
128
+ const [above, below] = parallelOnLowSide(a, b, 0) ? [1, 0] : [0, 1];
129
+ for (const fraction of fractions) {
130
+ const x = a[0] + (b[0] - a[0]) * fraction;
131
+ if (Math.min(Math.abs(x - a[0]), Math.abs(x - b[0])) < label.width / 2 + 6) continue;
132
+ ranked.push([above, x, a[1] - 10], [below, x, a[1] + 20], [2, x, a[1] + 3], [3, x, a[1] - 18], [3, x, a[1] + 28]);
133
+ }
134
+ } else if (Math.abs(a[0] - b[0]) < 0.0001 && Math.abs(a[1] - b[1]) >= label.height + 16) {
135
+ const leftFirst = !parallelOnLowSide(a, b, 1);
136
+ for (const fraction of fractions) {
137
+ const y = a[1] + (b[1] - a[1]) * fraction;
138
+ if (Math.min(Math.abs(y - a[1]), Math.abs(y - b[1])) < label.height / 2 + 6) continue;
139
+ ranked.push([2, a[0], y + 3]);
140
+ [6, 14, 22, 30, 38, 46, 54].forEach((offset, step) => {
141
+ const tier = step === 0 ? 0 : step === 1 ? 1 : 2 + step;
142
+ const left = [tier + (leftFirst ? 0 : 0.5), a[0] - label.width / 2 - offset, y + 3];
143
+ const right = [tier + (leftFirst ? 0.5 : 0), a[0] + label.width / 2 + offset, y + 3];
144
+ ranked.push(left, right);
145
+ });
146
+ }
147
+ }
148
+ }
149
+ return ranked.map((entry, order) => [...entry, order])
150
+ .sort((left, right) => left[0] - right[0] || left[3] - right[3])
151
+ .map(([, lx, ly]) => [lx, ly]);
152
+ };
153
+
154
+ const pinned = label => ['labelAt', 'labelDx', 'labelDy', 'labelSegment']
155
+ .some(key => label.relation[key] !== undefined);
156
+ const needsPlacement = (label, index) => !inside(label)
157
+ || components.some(component => rectsOverlap(label, component, -2))
158
+ || titles.some(title => rectsOverlap(label, title))
159
+ || overlapsLabel(label, index) || masksRoute(label);
160
+ const placeLabel = (index, rerank) => {
161
+ const label = placed[index];
162
+ // Match actual defect thresholds before searching; a valid placement is
163
+ // not a reason to restyle the diagram. New placements leave extra space.
164
+ // The grid ranking already starts from the preferred position, so it
165
+ // also re-ranks labels whose default spot is merely valid.
166
+ if (!rerank && !needsPlacement(label, index)) return;
167
+ if (rerank) {
168
+ // Callers measure the plate one pixel higher than rectAt; test with a
169
+ // pixel of slack so the chosen position also passes their clearance.
170
+ const replacement = gridCandidates(label).map(([lx, ly]) => rectAt(label, lx, ly))
171
+ .find(rect => clear({ ...rect, x: rect.x - 1, y: rect.y - 1, width: rect.width + 2, height: rect.height + 2 }, index));
172
+ if (replacement) {
173
+ placed[index] = replacement;
174
+ return;
175
+ }
176
+ }
177
+ for (const segment of segments.filter(segment => segment.relationIndex === label.relationIndex)) {
178
+ const [a, b] = [segment.start, segment.end];
179
+ let candidates = [];
180
+ // These candidates sit beside the line. They need the same 2px node
181
+ // clearance checked by clear(), not 8px reserved at each endpoint.
182
+ // Extra endpoint padding can hide a valid outer slot for a reciprocal
183
+ // pair and make the planner send its other arrow around both nodes.
184
+ if (Math.abs(a[1] - b[1]) < 0.0001 && Math.abs(a[0] - b[0]) >= label.width + 4) {
185
+ candidates = [0.5, 0.25, 0.75, 0.125, 0.875].flatMap(fraction => {
186
+ const x = a[0] + (b[0] - a[0]) * fraction;
187
+ if (Math.min(Math.abs(x - a[0]), Math.abs(x - b[0])) < 8) return [];
188
+ return [
189
+ [x, a[1] - 10],
190
+ [x, a[1] + 20],
191
+ [x, a[1] - 18],
192
+ [x, a[1] + 28],
193
+ ];
194
+ });
195
+ } else if (Math.abs(a[0] - b[0]) < 0.0001 && Math.abs(a[1] - b[1]) >= label.height + 16) {
196
+ candidates = [0.5, 0.25, 0.75, 0.125, 0.875].flatMap(fraction => {
197
+ const y = a[1] + (b[1] - a[1]) * fraction;
198
+ if (Math.min(Math.abs(y - a[1]), Math.abs(y - b[1])) < 8) return [];
199
+ return [
200
+ [a[0] - label.width / 2 - 6, y + 3],
201
+ [a[0] + label.width / 2 + 6, y + 3],
202
+ [a[0] - label.width / 2 - 14, y + 3],
203
+ [a[0] + label.width / 2 + 14, y + 3],
204
+ ];
205
+ });
206
+ }
207
+ const replacement = candidates.map(([lx, ly]) => rectAt(label, lx, ly))
208
+ .find(rect => clear(rect, index));
209
+ if (replacement) {
210
+ placed[index] = replacement;
211
+ break;
212
+ }
213
+ }
214
+ if (placed[index] !== label) return;
215
+ const sliding = segments.filter(segment => segment.relationIndex === label.relationIndex)
216
+ .flatMap(segment => slidingCandidates(label, index, segment)).find(rect => clear(rect, index));
217
+ if (sliding) {
218
+ placed[index] = sliding;
219
+ return;
220
+ }
221
+ if (!fallbackRing) return;
222
+
223
+ // Dense but valid topologies can leave every point directly beside the
224
+ // relationship occupied by another route. Search a small deterministic
225
+ // ring around the current anchor and the relationship's segment centres.
226
+ // A collision-free island above a node is not a readable edge label.
227
+ // Architecture opts into keeping the mask within two label heights of
228
+ // its own route; shared callers retain their existing policy. If no nearby
229
+ // slot fits, retain the collision so validation can request more space.
230
+ const ownSegments = segments.filter(segment => segment.relationIndex === label.relationIndex);
231
+ const baseAnchors = [
232
+ [label.lx, label.ly],
233
+ ...ownSegments.map(segment => [
234
+ (segment.start[0] + segment.end[0]) / 2,
235
+ (segment.start[1] + segment.end[1]) / 2,
236
+ ]),
237
+ ];
238
+ const horizontalStep = label.width / 2 + 12;
239
+ const ringOffsets = [
240
+ [0, -28], [0, 38],
241
+ [-horizontalStep, -28], [horizontalStep, -28],
242
+ [-horizontalStep, 38], [horizontalStep, 38],
243
+ [-(label.width + 20), -52], [label.width + 20, -52],
244
+ [-(label.width + 20), 62], [label.width + 20, 62],
245
+ [-(label.width + 20), -76], [label.width + 20, -76],
246
+ [-(label.width + 20), 86], [label.width + 20, 86],
247
+ ];
248
+ const fallback = baseAnchors.flatMap(([baseX, baseY]) => (
249
+ ringOffsets.map(([dx, dy]) => rectAt(label, baseX + dx, baseY + dy))
250
+ )).find(rect => clear(rect, index) && (!keepFallbackNearRoute || ownSegments.some(segment => (
251
+ segmentRectClearanceWithin(segment, rect, label.height * 2) <= label.height * 2
252
+ ))));
253
+ if (fallback) placed[index] = fallback;
254
+ };
255
+ let pending = placed.map((_, index) => index).filter(index => !pinned(placed[index]));
256
+ // A later label can vacate the only slot available to an earlier one.
257
+ // Retry only unresolved labels after a move; accepted placements clear all
258
+ // current labels and fixed geometry, so each success retires one label.
259
+ // A stalled pass ends the search, with at most one pass per input label.
260
+ for (let pass = 0; pass < labels.length && pending.length; pass++) {
261
+ let moved = false;
262
+ for (const index of pending) {
263
+ const before = placed[index];
264
+ placeLabel(index, gridSweep && pass === 0);
265
+ moved ||= placed[index] !== before;
266
+ }
267
+ if (!moved) break;
268
+ pending = pending.filter(index => needsPlacement(placed[index], index));
269
+ }
270
+ return placed;
271
+ }
272
+
273
+ // The rect a single unpinned label would occupy given only its own route and
274
+ // the nodes: what the planner reserves before the remaining routes are laid.
275
+ // Only a placement beside the route itself is worth reserving; a label that
276
+ // would already need the fallback ring is left to the final placement pass.
277
+ export function reservedLabelRect({
278
+ label, points, routes = [], labels = [], components, viewBox = [Infinity, Infinity], placementBottom = Infinity,
279
+ }) {
280
+ const [rect] = placeAutomaticLabels({
281
+ labels: [{ ...label, relationIndex: -1 }, ...labels.map(other => ({ ...other, relationIndex: -2 }))],
282
+ routes: [{ relationIndex: -1, points }, ...routes],
283
+ components,
284
+ titles: [],
285
+ viewBox,
286
+ placementBottom,
287
+ fallbackRing: false,
288
+ });
289
+ return components.some(component => rectsOverlap(rect, component, -2)) ? null : rect;
290
+ }
@@ -22,7 +22,7 @@ const MAX_HTML_BYTES = 256 * 1024;
22
22
  const MAX_IMAGE_BYTES = 1024 * 1024;
23
23
  const MAX_CAPTURE_CONCURRENCY = 3;
24
24
  const DEFAULT_CAPTURE_TIMEOUT_MS = 8000;
25
- const USER_AGENT = 'stacks-technical-diagrams brand-preview';
25
+ const USER_AGENT = 'Archify/2.15 brand-preview';
26
26
 
27
27
  function lookupForms(value) {
28
28
  const raw = String(value ?? '').trim().toLocaleLowerCase('en-US');
@@ -50,6 +50,17 @@ function asUrl(value) {
50
50
  }
51
51
  }
52
52
 
53
+ // Public provenance is separate from the URL used to reproduce a pinned asset.
54
+ export function normalizeBrandSourceUrl(value) {
55
+ const url = asUrl(value);
56
+ if (!url) return null;
57
+ url.username = '';
58
+ url.password = '';
59
+ url.search = '';
60
+ url.hash = '';
61
+ return url.href;
62
+ }
63
+
53
64
  function domainMark(hostname) {
54
65
  const host = hostname.toLocaleLowerCase('en-US').replace(/\.$/, '');
55
66
  const candidates = [...MARK_BY_DOMAIN.entries()]
@@ -181,7 +192,7 @@ function requestPinned(url, accept, target, deadline) {
181
192
  const request = transport.request(url, {
182
193
  method: 'GET',
183
194
  signal: timeoutSignal(Math.max(1, Math.min(4500, deadline - Date.now()))),
184
- headers: { accept, 'user-agent': USER_AGENT },
195
+ headers: { accept, 'accept-encoding': 'identity', 'user-agent': USER_AGENT },
185
196
  // Reuse the exact public address that passed validation. This closes the
186
197
  // DNS-rebinding gap between checking a hostname and opening its socket.
187
198
  lookup(_hostname, options, callback) {
@@ -224,6 +235,13 @@ async function checkedFetch(input, accept, deadline) {
224
235
  response.body.resume();
225
236
  throw new Error(`brand link returned HTTP ${response.status}`);
226
237
  }
238
+ // Raw HTTP responses are not decompressed. Check successful bodies before
239
+ // HTML discovery or image validation so byte limits and digests stay valid.
240
+ const contentEncoding = (response.headers.get('content-encoding') || '').trim().toLowerCase();
241
+ if (contentEncoding && contentEncoding !== 'identity') {
242
+ response.body.destroy();
243
+ throw new Error(`unsupported brand content encoding ${contentEncoding}`);
244
+ }
227
245
  return { response, finalUrl: current };
228
246
  }
229
247
  throw new Error('brand link redirected too many times');
@@ -269,24 +287,135 @@ async function readLimited(response, maximum) {
269
287
  return Buffer.concat(chunks, total);
270
288
  }
271
289
 
272
- function attribute(tag, name) {
273
- const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
274
- return match ? (match[1] ?? match[2] ?? match[3] ?? '') : '';
290
+ // Read only the bounded head, independent of network chunk boundaries. Scan
291
+ // bytes once so many tiny chunks cannot cause repeated concatenation/rescanning.
292
+ // Collect link tags in the same pass: markup in comments, raw text, attributes
293
+ // or templates must not become an icon candidate or an early head ending.
294
+ async function readHtmlHeadLinks(response, maximum, mediaType) {
295
+ const chunks = response.body && typeof response.body[Symbol.asyncIterator] === 'function'
296
+ ? response.body : [await readLimited(response, maximum)];
297
+ const buffer = Buffer.alloc(maximum);
298
+ const links = [];
299
+ let total = 0;
300
+ let tagStart = -1;
301
+ let quote = 0;
302
+ let comment = false;
303
+ let rawClosing = '';
304
+ let matched = 0;
305
+ let templateDepth = 0;
306
+ for await (const value of chunks) {
307
+ const chunk = Buffer.from(value);
308
+ const length = Math.min(chunk.length, maximum - total);
309
+ chunk.copy(buffer, total, 0, length);
310
+ for (let offset = 0; offset < length; offset++) {
311
+ const byte = chunk[offset];
312
+ const position = total + offset;
313
+ if (comment) {
314
+ if (byte === 0x3e && buffer[position - 1] === 0x2d && buffer[position - 2] === 0x2d) comment = false;
315
+ continue;
316
+ }
317
+ if (rawClosing) {
318
+ const lower = byte >= 65 && byte <= 90 ? byte + 32 : byte;
319
+ if (matched === rawClosing.length && [9, 10, 12, 13, 32, 47, 62].includes(byte)) {
320
+ tagStart = position - matched;
321
+ rawClosing = '';
322
+ matched = 0;
323
+ } else {
324
+ matched = lower === rawClosing.charCodeAt(matched) ? matched + 1 : (byte === 0x3c ? 1 : 0);
325
+ continue;
326
+ }
327
+ }
328
+ if (tagStart < 0) {
329
+ if (byte === 0x3c) tagStart = position;
330
+ continue;
331
+ }
332
+ if (position === tagStart + 1 && !((byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || [33, 47, 63].includes(byte))) {
333
+ tagStart = byte === 0x3c ? position : -1;
334
+ continue;
335
+ }
336
+ if (position === tagStart + 3 && buffer[tagStart + 1] === 0x21 && buffer[tagStart + 2] === 0x2d && byte === 0x2d) {
337
+ comment = true;
338
+ tagStart = -1;
339
+ continue;
340
+ }
341
+ if (quote) {
342
+ if (byte === quote) quote = 0;
343
+ continue;
344
+ }
345
+ if (byte === 0x22 || byte === 0x27) {
346
+ quote = byte;
347
+ continue;
348
+ }
349
+ if (byte === 0x3e) {
350
+ const tag = buffer.toString('utf8', tagStart, position + 1);
351
+ if (!templateDepth && /^<\/head[\t\n\f\r ]*>$/i.test(tag)) {
352
+ response.body?.destroy?.();
353
+ return links;
354
+ }
355
+ const emptyXhtmlElement = mediaType === 'application/xhtml+xml' && tag.endsWith('/>');
356
+ if (!emptyXhtmlElement && /^<template(?=[\t\n\f\r />])/i.test(tag)) templateDepth += 1;
357
+ else if (/^<\/template(?=[\t\n\f\r />])/i.test(tag)) templateDepth = Math.max(0, templateDepth - 1);
358
+ else if (!templateDepth && /^<link(?=[\t\n\f\r />])/i.test(tag)) links.push(tag);
359
+ const raw = /^<(script|style|title|textarea|xmp|iframe|noembed|noframes)(?=[\t\n\f\r />])/i.exec(tag)
360
+ || (mediaType === 'text/html' && /^<(noscript)(?=[\t\n\f\r />])/i.exec(tag));
361
+ if (raw && !emptyXhtmlElement) rawClosing = `</${raw[1].toLowerCase()}`;
362
+ tagStart = -1;
363
+ }
364
+ }
365
+ total += length;
366
+ if (chunk.length > length) {
367
+ response.body?.destroy?.();
368
+ throw new Error('brand asset is too large');
369
+ }
370
+ }
371
+ return links;
372
+ }
373
+
374
+ function linkAttribute(tag, name) {
375
+ // Consume entire attributes so data-href and quoted examples cannot supply
376
+ // a real href. As in HTML, the first occurrence wins, even when valueless.
377
+ const attributes = tag.slice(5, -1).matchAll(/([^\t\n\f\r />=]+)(?:[\t\n\f\r ]*=[\t\n\f\r ]*(?:"([^"]*)"|'([^']*)'|([^\t\n\f\r >]+)))?/g);
378
+ for (const match of attributes) {
379
+ if (match[1].toLowerCase() === name) return match[2] ?? match[3] ?? match[4] ?? '';
380
+ }
381
+ return '';
382
+ }
383
+
384
+ // HTML numeric references in the C1 range use the legacy Windows-1252 mapping.
385
+ // https://html.spec.whatwg.org/multipage/parsing.html#numeric-character-reference-end-state
386
+ const HTML_C1_REFERENCES = [
387
+ 0x20ac, 0x81, 0x201a, 0x192, 0x201e, 0x2026, 0x2020, 0x2021,
388
+ 0x2c6, 0x2030, 0x160, 0x2039, 0x152, 0x8d, 0x17d, 0x8f,
389
+ 0x90, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022, 0x2013, 0x2014,
390
+ 0x2dc, 0x2122, 0x161, 0x203a, 0x153, 0x9d, 0x17e, 0x178,
391
+ ];
392
+ const BASIC_HTML_REFERENCES = { amp: '&', quot: '"', apos: "'", lt: '<', gt: '>' };
393
+
394
+ function decodeIconHref(value) {
395
+ // Decode only after extracting the attribute, in one pass. Leave percent
396
+ // escapes to URL parsing and do not reinterpret decoded quotes as markup.
397
+ return value.replace(/&#(?:[xX]([0-9a-fA-F]+)|([0-9]+));?|&(amp|AMP|quot|QUOT|lt|LT|gt|GT)(?:;|(?![A-Za-z0-9=]))|&(apos);/g,
398
+ (_match, hex, decimal, named, apostrophe) => {
399
+ if (named || apostrophe) return BASIC_HTML_REFERENCES[(named || apostrophe).toLowerCase()];
400
+ let point = Number.parseInt(hex || decimal, hex ? 16 : 10);
401
+ if (point === 0 || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff)) return '\uFFFD';
402
+ if (point >= 0x80 && point <= 0x9f) point = HTML_C1_REFERENCES[point - 0x80];
403
+ return String.fromCodePoint(point);
404
+ });
275
405
  }
276
406
 
277
- function iconCandidates(html, pageUrl) {
407
+ function iconCandidates(links, pageUrl) {
278
408
  const candidates = [];
279
- for (const match of html.matchAll(/<link\b[^>]*>/gi)) {
280
- const tag = match[0];
281
- const rel = attribute(tag, 'rel').toLocaleLowerCase('en-US').split(/\s+/);
409
+ for (const tag of links) {
410
+ const rel = linkAttribute(tag, 'rel').toLocaleLowerCase('en-US').split(/\s+/);
282
411
  if (!rel.some((value) => value === 'icon' || value === 'apple-touch-icon' || value === 'mask-icon')) continue;
283
- const href = attribute(tag, 'href');
412
+ const href = decodeIconHref(linkAttribute(tag, 'href'));
284
413
  if (!href) continue;
285
414
  try {
286
415
  const url = new URL(href, pageUrl);
287
416
  if (!['https:', 'http:'].includes(url.protocol)) continue;
288
- const type = attribute(tag, 'type').toLocaleLowerCase('en-US');
289
- const sizes = attribute(tag, 'sizes');
417
+ const type = linkAttribute(tag, 'type').toLocaleLowerCase('en-US');
418
+ const sizes = linkAttribute(tag, 'sizes');
290
419
  const area = [...sizes.matchAll(/(\d+)x(\d+)/gi)]
291
420
  .reduce((best, size) => Math.max(best, Number(size[1]) * Number(size[2])), 0);
292
421
  const score = (type.includes('svg') || /\.svg(?:$|[?#])/i.test(url.href) ? 1000000 : 0)
@@ -304,6 +433,20 @@ function iconCandidates(html, pageUrl) {
304
433
  return [...unique.values()].slice(0, 5).concat({ url: fallback, score: -1 });
305
434
  }
306
435
 
436
+ function validIcoImageRanges(buffer) {
437
+ if (buffer.length < 6 || buffer.readUInt16LE(0) !== 0 || buffer.readUInt16LE(2) !== 1) return false;
438
+ const count = buffer.readUInt16LE(4);
439
+ const directoryEnd = 6 + count * 16;
440
+ if (count === 0 || directoryEnd > buffer.length) return false;
441
+ for (let index = 0; index < count; index += 1) {
442
+ const entry = 6 + index * 16;
443
+ const size = buffer.readUInt32LE(entry + 8);
444
+ const offset = buffer.readUInt32LE(entry + 12);
445
+ if (size === 0 || offset < directoryEnd || offset + size > buffer.length) return false;
446
+ }
447
+ return true;
448
+ }
449
+
307
450
  async function imageData(response) {
308
451
  const contentType = (response.headers.get('content-type') || '').split(';')[0].trim().toLocaleLowerCase('en-US');
309
452
  const allowed = new Set([
@@ -335,10 +478,7 @@ async function imageData(response) {
335
478
  && buffer.toString('ascii', 0, 4) === 'RIFF'
336
479
  && buffer.toString('ascii', 8, 12) === 'WEBP'
337
480
  && buffer.readUInt32LE(4) + 8 <= buffer.length
338
- : buffer.length >= 22
339
- && buffer[0] === 0 && buffer[1] === 0 && buffer[2] === 1 && buffer[3] === 0
340
- && buffer.readUInt16LE(4) > 0
341
- && 6 + buffer.readUInt16LE(4) * 16 <= buffer.length));
481
+ : validIcoImageRanges(buffer)));
342
482
  if (!signatureMatches) throw new Error(`brand asset bytes do not match ${contentType}`);
343
483
  return {
344
484
  dataUrl: `data:${contentType};base64,${buffer.toString('base64')}`,
@@ -349,13 +489,14 @@ async function imageData(response) {
349
489
 
350
490
  async function captureRemoteBrand(value, deadline = Date.now() + captureTimeoutMilliseconds()) {
351
491
  const sourceUrl = new URL(value);
492
+ const publicSourceUrl = normalizeBrandSourceUrl(sourceUrl);
352
493
  const fallback = (reason) => ({
353
494
  id: sourceUrl.hostname,
354
495
  title: sourceUrl.hostname,
355
496
  category: 'link',
356
497
  kind: 'fallback',
357
498
  status: 'unavailable',
358
- sourceUrl: sourceUrl.href,
499
+ sourceUrl: publicSourceUrl,
359
500
  reason,
360
501
  });
361
502
  try {
@@ -369,7 +510,7 @@ async function captureRemoteBrand(value, deadline = Date.now() + captureTimeoutM
369
510
  category: 'link',
370
511
  kind: 'remote',
371
512
  status: 'captured',
372
- sourceUrl: sourceUrl.href,
513
+ sourceUrl: publicSourceUrl,
373
514
  resolvedUrl: page.finalUrl.href,
374
515
  ...image,
375
516
  };
@@ -378,9 +519,11 @@ async function captureRemoteBrand(value, deadline = Date.now() + captureTimeoutM
378
519
  page.response.body?.destroy?.();
379
520
  return fallback('linked page is not HTML');
380
521
  }
381
- const html = (await readLimited(page.response, MAX_HTML_BYTES)).toString('utf8');
522
+ // Distinguish HTML noscript and XHTML empty elements without executing scripts.
523
+ const mediaType = pageType.split(';')[0].trim();
524
+ const links = await readHtmlHeadLinks(page.response, MAX_HTML_BYTES, mediaType);
382
525
  const iconErrors = [];
383
- for (const candidate of iconCandidates(html, page.finalUrl)) {
526
+ for (const candidate of iconCandidates(links, page.finalUrl)) {
384
527
  try {
385
528
  const fetched = await checkedFetch(candidate.url, 'image/*', deadline);
386
529
  const image = await imageData(fetched.response);
@@ -390,7 +533,7 @@ async function captureRemoteBrand(value, deadline = Date.now() + captureTimeoutM
390
533
  category: 'link',
391
534
  kind: 'remote',
392
535
  status: 'captured',
393
- sourceUrl: sourceUrl.href,
536
+ sourceUrl: publicSourceUrl,
394
537
  resolvedUrl: fetched.finalUrl.href,
395
538
  ...image,
396
539
  };
@@ -399,7 +542,7 @@ async function captureRemoteBrand(value, deadline = Date.now() + captureTimeoutM
399
542
  // Try the next declared favicon before using the generic link mark.
400
543
  }
401
544
  }
402
- const usefulError = iconErrors.find((error) => /unsupported brand image type/i.test(error?.message))
545
+ const usefulError = iconErrors.find((error) => /unsupported brand (?:image type|content encoding)/i.test(error?.message))
403
546
  || iconErrors.at(-1);
404
547
  return fallback(usefulError?.message || 'no usable site icon was found');
405
548
  } catch (error) {
@@ -483,7 +626,7 @@ export async function prepareDiagramBrandMarks(diagramType, diagram) {
483
626
  }
484
627
  const url = asUrl(node.brand);
485
628
  if (url) {
486
- unknown.push(`/${collection}/${index}/brand ${JSON.stringify(node.brand)} is an unpinned URL; capture it first with \`diagrams brands capture ${url.href} --json\``);
629
+ unknown.push(`/${collection}/${index}/brand ${JSON.stringify(node.brand)} is an unpinned URL; capture it first with \`archify brands capture ${url.href} --json\``);
487
630
  return;
488
631
  }
489
632
  unknown.push(`/${collection}/${index}/brand ${JSON.stringify(node.brand)} is not a built-in brand; closest IDs: ${suggestions(node.brand).join(', ')}`);
@@ -498,8 +641,8 @@ export async function prepareDiagramBrandMarks(diagramType, diagram) {
498
641
  subject: { diagramType, collection },
499
642
  evidence: {},
500
643
  supportedFixes: message.includes('is an unpinned URL')
501
- ? ['run `diagrams brands capture <url> --json` and author the returned digest-pinned brand object']
502
- : ['choose an ID from `diagrams brands`', 'run `diagrams brands capture <url> --json` for an unknown official site'],
644
+ ? ['run `archify brands capture <url> --json` and author the returned digest-pinned brand object']
645
+ : ['choose an ID from `archify brands`', 'run `archify brands capture <url> --json` for an unknown official site'],
503
646
  })));
504
647
  }
505
648
  }
@@ -528,7 +671,7 @@ export function brandTopRailProblem(node, width, minimumFontSize, subject = 'Nod
528
671
  const required = textUnits(node.label) * minimumFontSize * 0.6;
529
672
  if (available >= required) return null;
530
673
  return `${subject} "${node.id}" brand top rail leaves ${Math.max(0, available)}px for its label, but `
531
- + `"${node.label}" needs ~${Math.ceil(required)}px at the ${minimumFontSize}px legible minimum - widen the node or shorten the label.`;
674
+ + `"${node.label}" needs ~${Math.ceil(required)}px at the ${minimumFontSize}px legible minimum — widen the node or shorten the label.`;
532
675
  }
533
676
 
534
677
  function markAttrs(mark) {