@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.
- package/ai/skills/stacks-ai/SKILL.md +22 -0
- package/ai/skills/stacks-native/CATALOG.md +2 -2
- package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
- package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
- package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
- package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
- package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
- package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
- package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
- package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
- package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
- package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
- package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
- package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
- package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
- package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
- package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
- package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
- package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
- package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
- package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
- package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
- package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
- package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
- package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
- package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
- package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
- package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
- package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
- package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
- package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
- package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
- package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
- package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
- package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
- package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
- package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
- package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
- package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
- package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
- package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
- package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
- package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
- package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
- package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
- package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
- package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
- package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
- package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
- package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
- package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
- package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
- package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
- package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
- package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
- package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
- package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
- package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
- package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
- package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
- package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
- package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
- package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
- package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
- package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
- package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
- package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
- package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
- package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
- package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
- package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
- package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
- package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
- package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
- package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
- package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
- package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
- package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
- package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
- package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
- package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
- package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
- package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
- package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
- package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
- package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
- package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
- package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
- package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
- package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
- package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
- package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
- package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
- package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
- package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
- package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
- package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
- package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
- package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
- package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
- package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
- package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
- package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
- package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
- package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
- package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
- package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
- package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
- package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
- package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
- package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
- package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
- package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
- package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
- package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
- package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
- package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
- package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
- package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
- package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
- package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
- package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
- package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
- package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
- package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
- package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
- package/ide/vscode/package.json +1 -1
- 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 = '
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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(
|
|
407
|
+
function iconCandidates(links, pageUrl) {
|
|
278
408
|
const candidates = [];
|
|
279
|
-
for (const
|
|
280
|
-
const
|
|
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 =
|
|
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 =
|
|
289
|
-
const 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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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 \`
|
|
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 `
|
|
502
|
-
: ['choose an ID from `
|
|
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
|
|
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) {
|