Archify Diagram Viewer 0.1.0
VS Code extension that previews Archify diagrams from their JSON sources (live, as you type) and opens rendered Archify HTML in a viewer tab. Bundles the Archify 3.0.1 renderer and runs it on VS Code's Node runtime. Adds validation diagnostics, JSON schema help, source-link navigation, export saving, render-to-file and open-in-browser commands. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+62
@@ -0,0 +1,62 @@
|
||||
/** Grid placement for architecture IR (#8). Not auto-layout — fixed cell math only. */
|
||||
|
||||
export const DEFAULT_GRID = {
|
||||
mode: 'grid',
|
||||
origin: [40, 80],
|
||||
cols: 4,
|
||||
gapX: 30,
|
||||
gapY: 40,
|
||||
cellW: 130,
|
||||
cellH: 64,
|
||||
};
|
||||
|
||||
export function gridLayout(arch) {
|
||||
const raw = arch.layout;
|
||||
if (!raw || raw.mode !== 'grid') return null;
|
||||
return { ...DEFAULT_GRID, ...raw };
|
||||
}
|
||||
|
||||
export function resolveComponentPos(component, grid) {
|
||||
if (Array.isArray(component.pos) && component.pos.length === 2) {
|
||||
return component.pos;
|
||||
}
|
||||
if (!grid) return [NaN, NaN];
|
||||
if (!Number.isInteger(component.row) || !Number.isInteger(component.col)) {
|
||||
return [NaN, NaN];
|
||||
}
|
||||
const [ox, oy] = grid.origin;
|
||||
const stepX = grid.cellW + grid.gapX;
|
||||
const stepY = grid.cellH + grid.gapY;
|
||||
return [ox + component.col * stepX, oy + component.row * stepY];
|
||||
}
|
||||
|
||||
export function validateGridPlacement(arch, grid, problems) {
|
||||
if (!grid) return;
|
||||
if (arch.layout !== undefined && arch.layout.mode !== 'grid') {
|
||||
problems.push('layout.mode must be "grid" when layout is set (free placement omits layout entirely).');
|
||||
return;
|
||||
}
|
||||
const seen = new Map();
|
||||
for (const c of arch.components ?? []) {
|
||||
const hasPos = Array.isArray(c.pos) && c.pos.length === 2;
|
||||
const hasCell = Number.isInteger(c.row) && Number.isInteger(c.col);
|
||||
if (hasPos) continue; // pos wins; row/col are optional hints only
|
||||
if (!hasPos && !hasCell) {
|
||||
problems.push(`Component "${c.id}" needs pos [x,y] or grid row/col when layout.mode is "grid".`);
|
||||
continue;
|
||||
}
|
||||
if (c.row < 0 || c.col < 0) {
|
||||
problems.push(`Component "${c.id}" row/col must be non-negative integers.`);
|
||||
continue;
|
||||
}
|
||||
if (c.col >= grid.cols) {
|
||||
problems.push(`Component "${c.id}" col ${c.col} exceeds layout.cols ${grid.cols} (valid: 0..${grid.cols - 1}).`);
|
||||
}
|
||||
const key = `${c.row},${c.col}`;
|
||||
if (seen.has(key)) {
|
||||
problems.push(`Components "${seen.get(key)}" and "${c.id}" share grid cell row ${c.row} col ${c.col}.`);
|
||||
} else {
|
||||
seen.set(key, c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
import { normalizeRoutePoints, rectsOverlap, segmentRectClearanceWithin } from '../shared/geometry.mjs';
|
||||
import { createSpatialGrid } from '../shared/spatial-grid.mjs';
|
||||
|
||||
// A bounded fallback for an unpinned label whose usual position collides.
|
||||
// It never routes an edge, moves a node, expands the canvas, or rewrites input.
|
||||
export function placeAutomaticLabels({
|
||||
labels, routes, components, titles, viewBox, placementBottom = viewBox[1], fallbackRing = true, keepFallbackNearRoute = false,
|
||||
gridSweep = false,
|
||||
}) {
|
||||
const placed = [...labels];
|
||||
const obstacles = [...components, ...titles];
|
||||
const segments = routes.flatMap(({ relationIndex, points }) => {
|
||||
const normalized = normalizeRoutePoints(points);
|
||||
return normalized.slice(1).map((end, index) => ({ relationIndex, start: normalized[index], end }));
|
||||
});
|
||||
const inside = rect => (
|
||||
rect.x >= 0 && rect.y >= 0
|
||||
&& rect.x + rect.width <= viewBox[0] && rect.y + rect.height <= viewBox[1]
|
||||
);
|
||||
// The mask test asked every segment about every candidate position. Segments
|
||||
// go into a uniform grid once, and a candidate only asks the cells it covers.
|
||||
const SEGMENT_CELL = 120;
|
||||
const segmentGrid = createSpatialGrid(SEGMENT_CELL);
|
||||
for (const segment of segments) {
|
||||
const [sx, sy] = segment.start;
|
||||
const [ex, ey] = segment.end;
|
||||
segmentGrid.insert({
|
||||
minX: Math.min(sx, ex), maxX: Math.max(sx, ex),
|
||||
minY: Math.min(sy, ey), maxY: Math.max(sy, ey),
|
||||
}, segment);
|
||||
}
|
||||
const segmentsNear = (rect, margin) => segmentGrid.query({
|
||||
minX: rect.x - margin, maxX: rect.x + rect.width + margin,
|
||||
minY: rect.y - margin, maxY: rect.y + rect.height + margin,
|
||||
});
|
||||
const masksRoute = rect => {
|
||||
for (const segment of segmentsNear(rect, 4)) {
|
||||
if (segment.relationIndex === rect.relationIndex) continue;
|
||||
if (segmentRectClearanceWithin(segment, rect, 4) + 0.0001 < 4) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const overlapsLabel = (rect, index, gap = 0) => placed.some((other, otherIndex) => (
|
||||
otherIndex !== index && rectsOverlap(rect, other, gap)
|
||||
));
|
||||
const clear = (rect, index) => (
|
||||
inside(rect) && rect.y + rect.height <= placementBottom
|
||||
&& !obstacles.some(obstacle => rectsOverlap(rect, obstacle, 2))
|
||||
&& !overlapsLabel(rect, index, 2) && !masksRoute(rect)
|
||||
);
|
||||
const rectAt = (label, lx, ly) => ({
|
||||
...label, lx, ly, x: lx - label.width / 2, y: ly - 10,
|
||||
});
|
||||
// An opted-in grid layout runs parallel lines through shared gaps. Rank
|
||||
// every position along all of the label's own segments: beside the line
|
||||
// first, then centred on its own line (the plate interrupts only that
|
||||
// line), then stepping outward past neighbouring parallels.
|
||||
const gridCandidates = (label) => {
|
||||
const fractions = [0.5, 0.25, 0.75, 0.375, 0.625, 0.125, 0.875];
|
||||
const ranked = [];
|
||||
// A close parallel of another relationship on one side (a reciprocal
|
||||
// pair) makes a label on that side read as the neighbour's: prefer the
|
||||
// far side.
|
||||
const parallelOnLowSide = (a, b, axis) => {
|
||||
const across = 1 - axis;
|
||||
const [low, high] = [Math.min(a[axis], b[axis]), Math.max(a[axis], b[axis])];
|
||||
let nearest = null;
|
||||
for (const other of segments) {
|
||||
if (other.relationIndex === label.relationIndex) continue;
|
||||
if (Math.abs(other.start[across] - other.end[across]) > 0.0001) continue;
|
||||
const distance = other.start[across] - a[across];
|
||||
if (Math.abs(distance) < 0.0001 || Math.abs(distance) > 36) continue;
|
||||
const overlap = Math.min(high, Math.max(other.start[axis], other.end[axis]))
|
||||
- Math.max(low, Math.min(other.start[axis], other.end[axis]));
|
||||
if (overlap <= 0) continue;
|
||||
if (nearest === null || Math.abs(distance) < Math.abs(nearest)) nearest = distance;
|
||||
}
|
||||
return nearest !== null && nearest < 0;
|
||||
};
|
||||
for (const { start: a, end: b } of segments.filter(segment => segment.relationIndex === label.relationIndex)) {
|
||||
if (Math.abs(a[1] - b[1]) < 0.0001 && Math.abs(a[0] - b[0]) >= label.width + 16) {
|
||||
const [above, below] = parallelOnLowSide(a, b, 0) ? [1, 0] : [0, 1];
|
||||
for (const fraction of fractions) {
|
||||
const x = a[0] + (b[0] - a[0]) * fraction;
|
||||
if (Math.min(Math.abs(x - a[0]), Math.abs(x - b[0])) < label.width / 2 + 6) continue;
|
||||
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]);
|
||||
}
|
||||
} else if (Math.abs(a[0] - b[0]) < 0.0001 && Math.abs(a[1] - b[1]) >= label.height + 16) {
|
||||
const leftFirst = !parallelOnLowSide(a, b, 1);
|
||||
for (const fraction of fractions) {
|
||||
const y = a[1] + (b[1] - a[1]) * fraction;
|
||||
if (Math.min(Math.abs(y - a[1]), Math.abs(y - b[1])) < label.height / 2 + 6) continue;
|
||||
ranked.push([2, a[0], y + 3]);
|
||||
[6, 14, 22, 30, 38, 46, 54].forEach((offset, step) => {
|
||||
const tier = step === 0 ? 0 : step === 1 ? 1 : 2 + step;
|
||||
const left = [tier + (leftFirst ? 0 : 0.5), a[0] - label.width / 2 - offset, y + 3];
|
||||
const right = [tier + (leftFirst ? 0.5 : 0), a[0] + label.width / 2 + offset, y + 3];
|
||||
ranked.push(left, right);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return ranked.map((entry, order) => [...entry, order])
|
||||
.sort((left, right) => left[0] - right[0] || left[3] - right[3])
|
||||
.map(([, lx, ly]) => [lx, ly]);
|
||||
};
|
||||
|
||||
for (const [index, label] of placed.entries()) {
|
||||
const relation = label.relation;
|
||||
if (['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => relation[key] !== undefined)) continue;
|
||||
// Match actual defect thresholds before searching; a valid placement is
|
||||
// not a reason to restyle the diagram. New placements leave extra space.
|
||||
// The grid ranking already starts from the preferred position, so it
|
||||
// also re-ranks labels whose default spot is merely valid.
|
||||
if (!gridSweep && inside(label) && !components.some(component => rectsOverlap(label, component, -2))
|
||||
&& !titles.some(title => rectsOverlap(label, title))
|
||||
&& !overlapsLabel(label, index) && !masksRoute(label)) continue;
|
||||
if (gridSweep) {
|
||||
// Callers measure the plate one pixel higher than rectAt; test with a
|
||||
// pixel of slack so the chosen position also passes their clearance.
|
||||
const replacement = gridCandidates(label).map(([lx, ly]) => rectAt(label, lx, ly))
|
||||
.find(rect => clear({ ...rect, x: rect.x - 1, y: rect.y - 1, width: rect.width + 2, height: rect.height + 2 }, index));
|
||||
if (replacement) {
|
||||
placed[index] = replacement;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (const segment of segments.filter(segment => segment.relationIndex === label.relationIndex)) {
|
||||
const [a, b] = [segment.start, segment.end];
|
||||
let candidates = [];
|
||||
if (Math.abs(a[1] - b[1]) < 0.0001 && Math.abs(a[0] - b[0]) >= label.width + 16) {
|
||||
candidates = [0.5, 0.25, 0.75, 0.125, 0.875].flatMap(fraction => {
|
||||
const x = a[0] + (b[0] - a[0]) * fraction;
|
||||
if (Math.min(Math.abs(x - a[0]), Math.abs(x - b[0])) < 8) return [];
|
||||
return [
|
||||
[x, a[1] - 10],
|
||||
[x, a[1] + 20],
|
||||
[x, a[1] - 18],
|
||||
[x, a[1] + 28],
|
||||
];
|
||||
});
|
||||
} else if (Math.abs(a[0] - b[0]) < 0.0001 && Math.abs(a[1] - b[1]) >= label.height + 16) {
|
||||
candidates = [0.5, 0.25, 0.75, 0.125, 0.875].flatMap(fraction => {
|
||||
const y = a[1] + (b[1] - a[1]) * fraction;
|
||||
if (Math.min(Math.abs(y - a[1]), Math.abs(y - b[1])) < 8) return [];
|
||||
return [
|
||||
[a[0] - label.width / 2 - 6, y + 3],
|
||||
[a[0] + label.width / 2 + 6, y + 3],
|
||||
[a[0] - label.width / 2 - 14, y + 3],
|
||||
[a[0] + label.width / 2 + 14, y + 3],
|
||||
];
|
||||
});
|
||||
}
|
||||
const replacement = candidates.map(([lx, ly]) => rectAt(label, lx, ly))
|
||||
.find(rect => clear(rect, index));
|
||||
if (replacement) {
|
||||
placed[index] = replacement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (placed[index] !== label || !fallbackRing) continue;
|
||||
|
||||
// Dense but valid topologies can leave every point directly beside the
|
||||
// relationship occupied by another route. Search a small deterministic
|
||||
// ring around the current anchor and the relationship's segment centres.
|
||||
// A collision-free island above a node is not a readable edge label.
|
||||
// Architecture opts into keeping the mask within two label heights of
|
||||
// its own route; shared callers retain their existing policy. If no nearby
|
||||
// slot fits, retain the collision so validation can request more space.
|
||||
const ownSegments = segments.filter(segment => segment.relationIndex === label.relationIndex);
|
||||
const baseAnchors = [
|
||||
[label.lx, label.ly],
|
||||
...ownSegments.map(segment => [
|
||||
(segment.start[0] + segment.end[0]) / 2,
|
||||
(segment.start[1] + segment.end[1]) / 2,
|
||||
]),
|
||||
];
|
||||
const horizontalStep = label.width / 2 + 12;
|
||||
const ringOffsets = [
|
||||
[0, -28], [0, 38],
|
||||
[-horizontalStep, -28], [horizontalStep, -28],
|
||||
[-horizontalStep, 38], [horizontalStep, 38],
|
||||
[-(label.width + 20), -52], [label.width + 20, -52],
|
||||
[-(label.width + 20), 62], [label.width + 20, 62],
|
||||
[-(label.width + 20), -76], [label.width + 20, -76],
|
||||
[-(label.width + 20), 86], [label.width + 20, 86],
|
||||
];
|
||||
const fallback = baseAnchors.flatMap(([baseX, baseY]) => (
|
||||
ringOffsets.map(([dx, dy]) => rectAt(label, baseX + dx, baseY + dy))
|
||||
)).find(rect => clear(rect, index) && (!keepFallbackNearRoute || ownSegments.some(segment => (
|
||||
segmentRectClearanceWithin(segment, rect, label.height * 2) <= label.height * 2
|
||||
))));
|
||||
if (fallback) placed[index] = fallback;
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
|
||||
// The rect a single unpinned label would occupy given only its own route and
|
||||
// the nodes: what the planner reserves before the remaining routes are laid.
|
||||
// Only a placement beside the route itself is worth reserving; a label that
|
||||
// would already need the fallback ring is left to the final placement pass.
|
||||
export function reservedLabelRect({
|
||||
label, points, routes = [], labels = [], components, viewBox = [Infinity, Infinity], placementBottom = Infinity,
|
||||
}) {
|
||||
const [rect] = placeAutomaticLabels({
|
||||
labels: [{ ...label, relationIndex: -1 }, ...labels.map(other => ({ ...other, relationIndex: -2 }))],
|
||||
routes: [{ relationIndex: -1, points }, ...routes],
|
||||
components,
|
||||
titles: [],
|
||||
viewBox,
|
||||
placementBottom,
|
||||
fallbackRing: false,
|
||||
});
|
||||
return components.some(component => rectsOverlap(rect, component, -2)) ? null : rect;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1157
File diff suppressed because it is too large
Load Diff
+105
@@ -0,0 +1,105 @@
|
||||
# Data Flow Renderer
|
||||
|
||||
Render `diagram_type: "dataflow"` JSON files into the standard Archify HTML
|
||||
template.
|
||||
|
||||
```bash
|
||||
node archify/renderers/dataflow/render-dataflow.mjs input.dataflow.json output.html
|
||||
```
|
||||
|
||||
The renderer validates input against `archify/schemas/dataflow.schema.json`
|
||||
with the bundled standalone validator. No dependency installation is required.
|
||||
|
||||
If `output.html` is omitted, the renderer uses the required `meta.output` value
|
||||
from the JSON file.
|
||||
|
||||
## Input
|
||||
|
||||
Data-flow JSON files must set:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "dataflow",
|
||||
"meta": {
|
||||
"title": "Product Analytics Data Flow",
|
||||
"output": "product-analytics-dataflow.html",
|
||||
"viewBox": [940, 720]
|
||||
},
|
||||
"stages": [],
|
||||
"nodes": [],
|
||||
"flows": [],
|
||||
"cards": []
|
||||
}
|
||||
```
|
||||
|
||||
A complete worked example lives at
|
||||
`archify/examples/product-analytics.dataflow.json`.
|
||||
|
||||
The schema lives at:
|
||||
|
||||
```text
|
||||
archify/schemas/dataflow.schema.json
|
||||
```
|
||||
|
||||
## Legend
|
||||
|
||||
The default visual legend derives kinds from `flows[].variant` (omitting
|
||||
`variant` means `default`) and adds `database` only when a database node exists.
|
||||
Supported `meta.legend.entries` keys, in stable order, are `emphasis`,
|
||||
`security`, `dashed`, `database`, and `default`. Flow variants remain
|
||||
visual-only because Archify has no compiled edge-kind facts in this slice. A
|
||||
present `database` entry is different: it comes from exact
|
||||
`nodes[].type: "database"` facts, so it publishes the normal Semantic Legend
|
||||
count, accessible name, and keyboard interaction. Forcing `database` visible
|
||||
without a database node keeps it visual-only.
|
||||
|
||||
## Layout budget
|
||||
|
||||
| Constant | Value |
|
||||
|----------|-------|
|
||||
| viewBox | default `[940, 720]`; schema minimum `[360, 360]` |
|
||||
| Stages (2–5) | centers at x = 100 + stage×215; stage band 168 wide, header at y 46 |
|
||||
| Row tops (`row` 0–4) | y = 128, 242, 356, 470, 584 (plus `yOffset`) |
|
||||
| Default node | 112×58 |
|
||||
| Node area | x within `[24, width − 24]`; y within `[104, height − 74]` |
|
||||
| Node spacing | ≥10px between any two nodes (checked across stages and rows) |
|
||||
| Flow length | ≥34px between endpoints |
|
||||
| Legend row | y = height − 36 |
|
||||
|
||||
Route presets for flows: `straight`, `vertical-channel`, `bottom-channel`,
|
||||
`top-channel`, explicit `via` points, or the default `auto` (midpoint elbow).
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Use stages for data lifecycle boundaries: source, ingest, process, store,
|
||||
consume.
|
||||
- Place nodes by stage index and row index; do not hand-place raw SVG for the
|
||||
common case.
|
||||
- Use flow labels to name the data asset, not the transport primitive:
|
||||
`clickstream`, `identity map`, `normalized facts`, `feature vectors`.
|
||||
- Use `classification` for short sensitivity or governance context:
|
||||
`PII touch`, `non-PII`, `approved only`, `batch`, `read-only`.
|
||||
- Use `security` for PII, policy, consent, access-control, or restricted joins.
|
||||
- Use `emphasis` for the primary data path and `dashed` for async or batch
|
||||
derivations.
|
||||
- Keep labels short enough to fit in narrow previews.
|
||||
|
||||
Schema violations exit non-zero with path-prefixed messages annotated with the
|
||||
element's id or label. The renderer additionally fails when it can detect
|
||||
layout problems, including missing stages, duplicate node IDs, nodes outside
|
||||
the readable diagram area, node overlap, labels colliding with nodes or other
|
||||
labels, labels wider than their node, unknown flow endpoints, missing flow
|
||||
labels, unreadably short flows, flows crossing unrelated nodes (2px Clean Flow
|
||||
clearance), or stages that exceed the viewBox. Stage frames remain intentional
|
||||
pass-through containers. Text width
|
||||
is estimated CJK-aware: fullwidth glyphs count as two units.
|
||||
|
||||
Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
|
||||
X crossings then fail with `composition/proper-crossing`; default `standard`
|
||||
keeps them as artifact-receipt warnings. Collinear stage corridors are outside
|
||||
the proper-X rule, but a separate gate warns in `standard` and fails in
|
||||
`showcase` when unrelated flows overlap for at least 8px. Shared semantic
|
||||
endpoints, point touches, and shorter overlaps remain valid. Showcase also
|
||||
rejects any route segment below 8px and any interior turn segment below 16px;
|
||||
ordinary 8–15px endpoint stubs remain valid.
|
||||
@@ -0,0 +1,544 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
|
||||
import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
|
||||
import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
|
||||
import { resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
|
||||
import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth, nodeLabelLayout } from '../shared/text-fit.mjs';
|
||||
import { brandLabelFitWidth, brandMarkFor, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
|
||||
import { translateMessage as i18nText } from '../shared/i18n.mjs';
|
||||
import {
|
||||
asArray,
|
||||
isFinitePoint,
|
||||
rectsOverlap,
|
||||
cleanEndpointSideProblems,
|
||||
cleanFlowProblems,
|
||||
cleanCrossingProblems,
|
||||
cleanAmbiguousCorridorProblems,
|
||||
cleanBorderRunProblems,
|
||||
cleanRouteRhythmProblems,
|
||||
cleanLabelRouteClearanceProblems,
|
||||
cleanLabelCanvasContainmentProblems,
|
||||
suggestLabelObstacleFix,
|
||||
suggestLabelPairFix,
|
||||
anchor,
|
||||
automaticPortSpread,
|
||||
legacyDefaultFromSide as defaultFromSide,
|
||||
legacyDefaultToSide as defaultToSide,
|
||||
chosenSide,
|
||||
polylinePath,
|
||||
routePointsValue,
|
||||
authoredStraightRouteAttrs,
|
||||
labelPoint,
|
||||
componentFill,
|
||||
componentText,
|
||||
arrowClassMap,
|
||||
edgeLabelAccent
|
||||
} from '../shared/geometry.mjs';
|
||||
|
||||
const nodeTextFit = {
|
||||
sublabelPreferred: 7,
|
||||
sublabelMinimum: 6,
|
||||
tagPreferred: 7,
|
||||
tagMinimum: 6,
|
||||
};
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const { diagram: dataflow, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
|
||||
rendererDir: __dirname,
|
||||
diagramType: 'dataflow',
|
||||
defaultExample: 'product-analytics.dataflow.json'
|
||||
});
|
||||
|
||||
const viewBox = dataflow.meta?.viewBox || [940, 720];
|
||||
const layout = {
|
||||
stageY: 46,
|
||||
stageH: 36,
|
||||
stageBottomPad: 74,
|
||||
leftX: 100,
|
||||
colGap: 215,
|
||||
stageW: 168,
|
||||
nodeW: 112,
|
||||
nodeH: 58,
|
||||
rowYs: [128, 242, 356, 470, 584],
|
||||
labelH: 16
|
||||
};
|
||||
|
||||
function flowLabelSize(flow) {
|
||||
const longestLine = Math.max(textUnits(flow.label), textUnits(flow.classification || ''));
|
||||
return {
|
||||
width: Math.round(Math.max(34, longestLine * 4.9 + 12) * 10) / 10,
|
||||
height: flow.classification ? 27 : layout.labelH,
|
||||
};
|
||||
}
|
||||
|
||||
function stageX(index) {
|
||||
return layout.leftX + index * layout.colGap;
|
||||
}
|
||||
|
||||
function stageFrame(stage, index) {
|
||||
return {
|
||||
id: index,
|
||||
label: stage.label,
|
||||
kind: 'stage',
|
||||
x: stageX(index) - layout.stageW / 2,
|
||||
y: layout.stageY,
|
||||
width: layout.stageW,
|
||||
height: viewBox[1] - layout.stageY - layout.stageBottomPad,
|
||||
radius: 10,
|
||||
};
|
||||
}
|
||||
|
||||
const compositionFrames = asArray(dataflow.stages).map(stageFrame);
|
||||
|
||||
function measureNode(node) {
|
||||
const width = node.width || layout.nodeW;
|
||||
const height = node.height || layout.nodeH;
|
||||
const cx = stageX(node.stage);
|
||||
const y = layout.rowYs[node.row] + (node.yOffset || 0);
|
||||
return {
|
||||
...node,
|
||||
width,
|
||||
height,
|
||||
cx,
|
||||
cy: y + height / 2,
|
||||
x: cx - width / 2,
|
||||
y
|
||||
};
|
||||
}
|
||||
|
||||
const nodes = new Map(asArray(dataflow.nodes).map((node) => [node.id, measureNode(node)]));
|
||||
const nodeSteps = new Map();
|
||||
for (const [index, flow] of asArray(dataflow.flows).entries()) {
|
||||
if (!nodeSteps.has(flow.from)) nodeSteps.set(flow.from, index);
|
||||
if (!nodeSteps.has(flow.to)) nodeSteps.set(flow.to, index + 1);
|
||||
}
|
||||
for (const [index, node] of asArray(dataflow.nodes).entries()) {
|
||||
if (!nodeSteps.has(node.id)) nodeSteps.set(node.id, index);
|
||||
}
|
||||
|
||||
function validateDataflow() {
|
||||
const problems = [];
|
||||
if (nodes.size !== asArray(dataflow.nodes).length) problems.push('Node ids must be unique.');
|
||||
|
||||
const stageCount = asArray(dataflow.stages).length;
|
||||
for (const node of nodes.values()) {
|
||||
if (typeof node.stage !== 'number' || node.stage < 0 || node.stage >= stageCount) {
|
||||
problems.push(`Node "${node.id}" uses invalid stage ${node.stage} — valid stages are 0..${stageCount - 1}.`);
|
||||
}
|
||||
if (typeof node.row !== 'number' || node.row < 0 || node.row >= layout.rowYs.length) {
|
||||
problems.push(`Node "${node.id}" uses invalid row ${node.row} — valid rows are 0..${layout.rowYs.length - 1}.`);
|
||||
}
|
||||
if (!isFinitePoint(node.x, node.y, node.cx, node.cy)) {
|
||||
problems.push(`Node "${node.id}" produced non-finite coordinates — check stage, row, width, height, and yOffset are numbers.`);
|
||||
continue;
|
||||
}
|
||||
if (node.x < 24 || node.x + node.width > viewBox[0] - 24) {
|
||||
problems.push(`Node "${node.id}" exceeds the horizontal bounds of the viewBox — reduce node.width or increase meta.viewBox[0].`);
|
||||
}
|
||||
if (node.y < layout.stageY + layout.stageH + 22 || node.y + node.height > viewBox[1] - layout.stageBottomPad) {
|
||||
problems.push(`Node "${node.id}" exceeds the readable diagram area — keep y between ${layout.stageY + layout.stageH + 22} and ${viewBox[1] - layout.stageBottomPad} (adjust row/yOffset or increase meta.viewBox[1]).`);
|
||||
}
|
||||
const estLabelW = textUnits(node.label) * 6.2;
|
||||
if (estLabelW > node.width + 6) {
|
||||
problems.push(`Label "${node.label}" (~${Math.round(estLabelW)}px) is wider than node "${node.id}" (${node.width}px) — shorten the label or increase node.width.`);
|
||||
}
|
||||
const brandRailProblem = brandTopRailProblem(node, node.width, 8);
|
||||
if (brandRailProblem) problems.push(brandRailProblem);
|
||||
// sublabel and tag render as single unwrapped <text> elements; shrink-to-fit
|
||||
// handles the ordinary case, this rejects what it cannot rescue.
|
||||
const availableTextW = availableNodeTextWidth(node.width);
|
||||
for (const [field, value, minimum] of [
|
||||
['Sublabel', node.sublabel, nodeTextFit.sublabelMinimum],
|
||||
['Tag', node.tag, nodeTextFit.tagMinimum],
|
||||
]) {
|
||||
if (!value) continue;
|
||||
const minimumW = minimumNodeTextWidth(value, minimum);
|
||||
if (minimumW > availableTextW) {
|
||||
problems.push(`${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but node "${node.id}" provides ${availableTextW}px — shorten the ${field.toLowerCase()} or increase node.width.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nodeList = asArray(dataflow.nodes);
|
||||
for (let i = 0; i < nodeList.length; i += 1) {
|
||||
for (let j = i + 1; j < nodeList.length; j += 1) {
|
||||
const a = nodes.get(nodeList[i].id);
|
||||
const b = nodes.get(nodeList[j].id);
|
||||
if (rectsOverlap(a, b, 10)) {
|
||||
problems.push(`Nodes "${a.id}" and "${b.id}" are less than 10px apart — move one to another stage/row or adjust yOffset.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const flow of asArray(dataflow.flows)) {
|
||||
if (!nodes.has(flow.from)) problems.push(`Flow "${flow.label || flow.from}" references unknown source "${flow.from}".`);
|
||||
if (!nodes.has(flow.to)) problems.push(`Flow "${flow.label || flow.to}" references unknown target "${flow.to}".`);
|
||||
if (!flow.label) problems.push(`Flow "${flow.from}" -> "${flow.to}" must include a short data label.`);
|
||||
if (nodes.has(flow.from) && nodes.has(flow.to)) {
|
||||
const routed = pathFor(flow);
|
||||
const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
|
||||
const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
|
||||
if (distance < 34) problems.push(`Flow "${flow.label}" is too short (${Math.round(distance)}px; minimum 34px) — route it through a channel or spread its nodes.`);
|
||||
if (Array.isArray(flow.via)) {
|
||||
for (let segmentIndex = 0; segmentIndex < routed.points.length - 1; segmentIndex += 1) {
|
||||
const segmentStart = routed.points[segmentIndex];
|
||||
const segmentEnd = routed.points[segmentIndex + 1];
|
||||
const isDiagonal = Math.abs(segmentStart[0] - segmentEnd[0]) > 0.01
|
||||
&& Math.abs(segmentStart[1] - segmentEnd[1]) > 0.01;
|
||||
if (!isDiagonal) continue;
|
||||
const viaIndex = Math.min(segmentIndex, flow.via.length - 1);
|
||||
problems.push(`Flow "${flow.label}" has a diagonal segment from (${segmentStart.join(', ')}) to (${segmentEnd.join(', ')}) — align via[${viaIndex}] with its adjacent point by sharing the same x or y coordinate.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
problems.push(...cleanEndpointSideProblems({
|
||||
relations: dataflow.flows,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
fromSideFor: (flow) => flowSides(flow).fromSide,
|
||||
toSideFor: (flow) => flowSides(flow).toSide,
|
||||
routeHint: 'keep automatic routing, or choose fromSide/toSide and via points whose first and final segments cross node borders perpendicularly',
|
||||
}));
|
||||
problems.push(...cleanFlowProblems({
|
||||
relations: dataflow.flows,
|
||||
obstacles: nodes.values(),
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
obstacleKind: 'node',
|
||||
routeHint: 'adjust fromSide/toSide, set route/via or channelX/channelY, or move the node to another stage/row'
|
||||
}));
|
||||
problems.push(...cleanCrossingProblems({
|
||||
relations: dataflow.flows,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
profile: dataflow.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via or channelX/channelY so the flows use separate stage corridors'
|
||||
}));
|
||||
problems.push(...cleanAmbiguousCorridorProblems({
|
||||
relations: dataflow.flows,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
profile: dataflow.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via or channelX/channelY so unrelated flows do not visually merge'
|
||||
}));
|
||||
problems.push(...cleanBorderRunProblems({
|
||||
relations: dataflow.flows,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
frames: compositionFrames,
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
profile: dataflow.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via or channelX/channelY so the flow crosses the stage perpendicularly instead of following its border'
|
||||
}));
|
||||
problems.push(...cleanRouteRhythmProblems({
|
||||
relations: dataflow.flows,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
profile: dataflow.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via or channelX/channelY so each turn uses a clear inter-stage corridor'
|
||||
}));
|
||||
|
||||
const labelRects = [];
|
||||
for (const [flowIndex, flow] of asArray(dataflow.flows).entries()) {
|
||||
if (!flow.label || !nodes.has(flow.from) || !nodes.has(flow.to)) continue;
|
||||
const [lx, ly] = labelPoint(flow, pathFor(flow).points);
|
||||
const { width, height } = flowLabelSize(flow);
|
||||
labelRects.push({ relation: flow, relationIndex: flowIndex, label: flow.label, x: lx - width / 2, y: ly - 11, width, height, lx, ly });
|
||||
}
|
||||
for (const rect of labelRects) {
|
||||
for (const node of nodes.values()) {
|
||||
if (rectsOverlap(rect, node, -2)) {
|
||||
problems.push(`Label "${rect.label}" overlaps node "${node.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, node, 'node', viewBox, nodes.values())}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < labelRects.length; i += 1) {
|
||||
for (let j = i + 1; j < labelRects.length; j += 1) {
|
||||
if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
|
||||
problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — adjust labelDx/labelDy.\n${suggestLabelPairFix(labelRects[i], labelRects[j])}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
problems.push(...cleanLabelRouteClearanceProblems({
|
||||
relations: dataflow.flows,
|
||||
labels: labelRects,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
profile: dataflow.meta?.quality_profile,
|
||||
routeHint: 'adjust labelAt, labelDx, labelDy, or labelSegment; otherwise adjust the other flow route/via/channelX/channelY'
|
||||
}));
|
||||
problems.push(...cleanLabelCanvasContainmentProblems({
|
||||
labels: labelRects,
|
||||
viewBox,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
profile: dataflow.meta?.quality_profile,
|
||||
}));
|
||||
|
||||
const lastStageX = stageX(asArray(dataflow.stages).length - 1);
|
||||
if (lastStageX + layout.stageW / 2 > viewBox[0] - 24) {
|
||||
problems.push(`Stages exceed viewBox width — set meta.viewBox[0] to at least ${Math.ceil(lastStageX + layout.stageW / 2 + 24)}.`);
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
throwDiagnosticProblems('Data-flow layout validation failed', problems, {
|
||||
subject: { diagramType: 'dataflow' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function routeVia(flow, from, to, start, end) {
|
||||
if (flow.via) return flow.via;
|
||||
switch (flow.route || 'auto') {
|
||||
case 'straight':
|
||||
return [];
|
||||
case 'vertical-channel': {
|
||||
const x = flow.channelX ?? start[0] + (end[0] > start[0] ? 44 : -44);
|
||||
return [[x, start[1]], [x, end[1]]];
|
||||
}
|
||||
case 'bottom-channel': {
|
||||
const y = flow.channelY ?? Math.max(from.y + from.height, to.y + to.height) + 26;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
case 'top-channel': {
|
||||
const y = flow.channelY ?? Math.min(from.y, to.y) - 24;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
case 'auto':
|
||||
default: {
|
||||
if (Math.abs(start[1] - end[1]) < 4) return [];
|
||||
const midX = start[0] + (end[0] - start[0]) / 2;
|
||||
return [[midX, start[1]], [midX, end[1]]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pathCache = new Map();
|
||||
|
||||
function flowSides(flow) {
|
||||
const from = nodes.get(flow.from);
|
||||
const to = nodes.get(flow.to);
|
||||
return {
|
||||
fromSide: chosenSide(flow.fromSide, defaultFromSide(from, to)),
|
||||
toSide: chosenSide(flow.toSide, defaultToSide(from, to)),
|
||||
};
|
||||
}
|
||||
|
||||
const automaticPorts = automaticPortSpread(dataflow.flows, nodes, {
|
||||
sideFor: (flow, endpoint) => flowSides(flow)[endpoint === 'source' ? 'fromSide' : 'toSide'],
|
||||
});
|
||||
|
||||
function pathFor(flow) {
|
||||
if (pathCache.has(flow)) return pathCache.get(flow);
|
||||
const from = nodes.get(flow.from);
|
||||
const to = nodes.get(flow.to);
|
||||
const ports = automaticPorts.get(flow);
|
||||
const { fromSide, toSide } = flowSides(flow);
|
||||
const start = ports?.from || anchor(from, fromSide);
|
||||
const end = ports?.to || anchor(to, toSide);
|
||||
// Drop consecutive duplicate points so a purely vertical (or horizontal)
|
||||
// auto-route never emits a zero-length final segment — SVG derives
|
||||
// marker-end orientation from the last segment, and a degenerate segment
|
||||
// leaves the arrowhead angle undefined (see #169).
|
||||
const rawPoints = [start, ...routeVia(flow, from, to, start, end), end];
|
||||
const points = [];
|
||||
for (const p of rawPoints) {
|
||||
const prev = points.at(-1);
|
||||
if (!prev || Math.abs(p[0] - prev[0]) > 0.0001 || Math.abs(p[1] - prev[1]) > 0.0001) {
|
||||
points.push(p);
|
||||
}
|
||||
}
|
||||
// Guard against an all-degenerate route (e.g. start === end): keep both
|
||||
// endpoints so the path is still well-formed even if the marker is hidden.
|
||||
if (points.length < 2) points.push(end);
|
||||
const routed = { d: polylinePath(points), points };
|
||||
pathCache.set(flow, routed);
|
||||
return routed;
|
||||
}
|
||||
|
||||
// Header measurement follows 276970789's #257, including the ordinal.
|
||||
// Long titles wrap at the same legible floor instead of becoming invalid input.
|
||||
function stageHeaderText(stage, index) {
|
||||
return `${String(index + 1).padStart(2, '0')} / ${stage.label}`;
|
||||
}
|
||||
|
||||
function renderStageHeader(stage, index, cx) {
|
||||
const text = stageHeaderText(stage, index);
|
||||
const font = fittedNodeFontSize(text, layout.stageW, 9, 7);
|
||||
const available = availableNodeTextWidth(layout.stageW);
|
||||
const open = `<text x="${cx}" y="${layout.stageY + 22}" class="t-dim" font-size="${font}" font-weight="600" text-anchor="middle">`;
|
||||
if (minimumNodeTextWidth(text, font) <= available) return `${open}${esc(text)}</text>`;
|
||||
const lines = [];
|
||||
let line = '';
|
||||
// Prefer word boundaries; split oversized words and CJK by grapheme, without
|
||||
// dropping whitespace or splitting a combining character/emoji sequence.
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
||||
for (const word of text.match(/\s+|\S+/gu) || []) {
|
||||
if (line && minimumNodeTextWidth(line + word, font) > available) {
|
||||
lines.push(line); line = '';
|
||||
}
|
||||
for (const { segment } of segmenter.segment(word)) {
|
||||
if (line && minimumNodeTextWidth(line + segment, font) > available) {
|
||||
lines.push(line); line = '';
|
||||
}
|
||||
line += segment;
|
||||
}
|
||||
}
|
||||
if (line) lines.push(line);
|
||||
// Explicit node geometry is authoritative. Do not turn an old horizontal
|
||||
// overflow into a new collision with nodes when the header area is packed.
|
||||
const firstNodeY = Math.min(...[...nodes.values()].filter(node => node.stage === index).map(node => node.y),
|
||||
viewBox[1] - layout.stageBottomPad);
|
||||
const lastLineBottom = layout.stageY + 22 + (lines.length - 1) * (font + 4) + font * 0.3;
|
||||
if (lastLineBottom > firstNodeY - 4) return `${open}${esc(text)}</text>`;
|
||||
const content = lines.length === 1 ? esc(text) : lines.map((line, i) =>
|
||||
`<tspan x="${cx}" dy="${i ? font + 4 : 0}">${esc(line)}</tspan>`).join('');
|
||||
return `${open}${content}</text>`;
|
||||
}
|
||||
|
||||
function renderStage(stage, index) {
|
||||
const frame = compositionFrames[index];
|
||||
const cx = stageX(index);
|
||||
return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="${index}" x="${frame.x}" y="${frame.y}" width="${frame.width}" height="${frame.height}" rx="${frame.radius}" class="c-lane" stroke-width="1"/>
|
||||
${renderStageHeader(stage, index, cx)}`;
|
||||
}
|
||||
|
||||
function renderNode(node) {
|
||||
const fill = componentFill[node.type] || 'c-external';
|
||||
const accent = componentText[node.type] || 't-muted';
|
||||
const hasSub = node.sublabel != null && node.sublabel !== '';
|
||||
const labelFontSize = fittedNodeFontSize(node.label, brandLabelFitWidth(node, node.width), 10, 8);
|
||||
const sublabelFontSize = fittedNodeFontSize(node.sublabel, node.width, nodeTextFit.sublabelPreferred, nodeTextFit.sublabelMinimum);
|
||||
const tagFontSize = fittedNodeFontSize(node.tag, node.width, nodeTextFit.tagPreferred, nodeTextFit.tagMinimum);
|
||||
const textRows = [{ text: node.label, font: labelFontSize, y: 21 }];
|
||||
if (hasSub) textRows.push({ text: node.sublabel, font: sublabelFontSize, y: 37 });
|
||||
if (node.tag) textRows.push({ text: node.tag, font: tagFontSize, y: node.height - 11 });
|
||||
const labelLayout = nodeLabelLayout({ width: node.width, height: node.height, rows: textRows,
|
||||
brand: Boolean(brandMarkFor(node)), source: Boolean(sourceEvidence?.nodes?.[node.id]?.length) });
|
||||
const sub = hasSub
|
||||
? `\n <text data-detail="context" x="${node.cx}" y="${node.y + labelLayout.ys[1]}" class="t-muted" font-size="${sublabelFontSize}" text-anchor="middle">${esc(node.sublabel)}</text>`
|
||||
: '';
|
||||
const tag = node.tag
|
||||
? `\n <text data-detail="fine" x="${node.cx}" y="${node.y + labelLayout.ys[hasSub ? 2 : 1]}" class="${accent}" font-size="${tagFontSize}" text-anchor="middle">${esc(node.tag)}</text>`
|
||||
: '';
|
||||
const stage = asArray(dataflow.stages)[node.stage];
|
||||
const context = stage
|
||||
? `${String(node.stage + 1).padStart(2, '0')} / ${stage.label}`
|
||||
: i18nText(dataflow.meta.locale, 'node.context.dataflow');
|
||||
const brand = renderBrandMark(node, { x: node.x + node.width - 22, y: node.y + 6 });
|
||||
const passport = { kind: node.type, sublabel: node.sublabel, tag: node.tag, context, ...brandMetadataFor(node) };
|
||||
return ` <g ${focusNodeAttrs(node.id, node.label, passport, dataflow.meta.locale)}>
|
||||
${focusNodeTitle(node.label, passport)}
|
||||
<rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="c-mask"/>
|
||||
<rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="${fill}"${animateAttr(dataflow.meta, 'node', nodeSteps.get(node.id))} stroke-width="1.5"/>
|
||||
${renderSemanticSigil(node.type, { icon: node.icon, x: node.x + 6, y: node.y + labelLayout.sigilY, size: labelLayout.sigilSize })}${brand ? `\n ${brand}` : ''}
|
||||
<text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${node.x + labelLayout.x}" y="${node.y + labelLayout.ys[0]}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(node.label)}</text>${sub}${tag}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function renderFlowPath(flow, index) {
|
||||
const [cls, marker] = arrowClassMap[flow.variant || 'default'] || arrowClassMap.default;
|
||||
const routed = pathFor(flow);
|
||||
const strokeWidth = flow.width || (flow.variant === 'emphasis' ? 1.8 : 1.4);
|
||||
return ` <path ${focusEdgeAttrs(flow.from, flow.to, flow.label, index, flow.id)} data-composition-points="${routePointsValue(routed.points)}"${authoredStraightRouteAttrs(flow, routed.points)} d="${routed.d}" class="${cls}"${animateAttr(dataflow.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
|
||||
}
|
||||
|
||||
function renderFlowLabel(flow, index) {
|
||||
const routed = pathFor(flow);
|
||||
const [lx, ly] = labelPoint(flow, routed.points);
|
||||
const { width: labelW, height: labelH } = flowLabelSize(flow);
|
||||
const classification = flow.classification
|
||||
? `\n <text data-detail="fine" x="${lx}" y="${ly + 11}" class="t-dim" font-size="7" text-anchor="middle">${esc(flow.classification)}</text>`
|
||||
: '';
|
||||
return ` <g data-detail="context" ${focusEdgeAttrs(flow.from, flow.to, flow.label, index, flow.id)}>
|
||||
<rect x="${lx - labelW / 2}" y="${ly - 11}" width="${labelW}" height="${labelH}" rx="4" class="c-mask"/>
|
||||
<text x="${lx}" y="${ly}" class="${edgeLabelAccent(flow.variant)}" font-size="8" text-anchor="middle">${esc(flow.label)}</text>${classification}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
const LEGEND_CATALOG = [
|
||||
{ kind: 'emphasis', className: 'a-emphasis', marker: 'arrowhead-emphasis', strokeWidth: 1.8, swatchWidth: 34, swatchGap: 9, interactive: false },
|
||||
{ kind: 'security', className: 'a-security', marker: 'arrowhead-security', swatchWidth: 34, swatchGap: 9, interactive: false },
|
||||
{ kind: 'dashed', className: 'a-dashed', marker: 'arrowhead-dashed', swatchWidth: 34, swatchGap: 9, interactive: false },
|
||||
{ kind: 'database' },
|
||||
{ kind: 'default', className: 'a-default', marker: 'arrowhead', swatchWidth: 34, swatchGap: 9, interactive: false },
|
||||
].map((entry) => ({
|
||||
...entry,
|
||||
label: i18nText(dataflow.meta.locale, `legend.dataflow.${entry.kind}`),
|
||||
}));
|
||||
|
||||
function renderLegend() {
|
||||
const presentKinds = new Set(asArray(dataflow.flows).map((flow) => flow.variant || 'default'));
|
||||
if ([...nodes.values()].some((node) => node.type === 'database')) presentKinds.add('database');
|
||||
const entries = resolveLegend(dataflow.meta?.legend, LEGEND_CATALOG, presentKinds);
|
||||
return renderResolvedLegend({
|
||||
entries,
|
||||
locale: dataflow.meta.locale,
|
||||
layout: {
|
||||
x: 40,
|
||||
baselineY: viewBox[1] - 36,
|
||||
width: viewBox[0] - 80,
|
||||
minTitleY: viewBox[1] - 66,
|
||||
unfit: dataflow.meta?.legend === undefined ? 'hide' : 'error',
|
||||
diagramType: 'dataflow',
|
||||
},
|
||||
renderSwatch: (entry) => entry.kind === 'database'
|
||||
? `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="c-database" stroke-width="1"/>`
|
||||
: `<path d="M ${entry.x} ${entry.baseline - 3} L ${entry.x + 34} ${entry.baseline - 3}" class="${entry.className}" stroke-width="${entry.strokeWidth || 1.4}" marker-end="url(#${entry.marker})"/>`,
|
||||
});
|
||||
}
|
||||
|
||||
function renderSvg() {
|
||||
// Same default-canvas contract as lifecycle: 940x720 is below the 1.55 wide
|
||||
// ratio, so without intrinsic-height the desktop Reader can neither narrow
|
||||
// nor scroll it and every default dataflow fails the browser gate.
|
||||
const readerFit = dataflow.meta?.viewBox ? '' : ' data-reader-fit="intrinsic-height"';
|
||||
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}"${readerFit} ${svgRootAttrs(dataflow.meta)}>
|
||||
${svgAccessibleText(dataflow.meta, 'dataflow')}
|
||||
${renderDefinitions()}
|
||||
|
||||
<!-- Background Grid -->
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
<!-- Data Stages -->
|
||||
${dataflow.stages.map(renderStage).join('\n\n')}
|
||||
|
||||
<!-- Flow paths -->
|
||||
${asArray(dataflow.flows).map(renderFlowPath).join('\n')}
|
||||
|
||||
<!-- Nodes -->
|
||||
${[...nodes.values()].map(renderNode).join('\n\n')}
|
||||
|
||||
<!-- Flow labels -->
|
||||
${asArray(dataflow.flows).map(renderFlowLabel).join('\n')}
|
||||
|
||||
<!-- Legend -->
|
||||
${renderLegend()}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
validateDataflow();
|
||||
writeDiagram({
|
||||
outPath,
|
||||
template,
|
||||
diagramType: 'dataflow',
|
||||
meta: dataflow.meta,
|
||||
svg: renderSvg(),
|
||||
cards: dataflow.cards,
|
||||
sourceEvidence,
|
||||
});
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
# Lifecycle Renderer
|
||||
|
||||
Render `diagram_type: "lifecycle"` JSON files into the standard Archify HTML
|
||||
template.
|
||||
|
||||
```bash
|
||||
node archify/renderers/lifecycle/render-lifecycle.mjs input.lifecycle.json output.html
|
||||
```
|
||||
|
||||
The renderer validates input against `archify/schemas/lifecycle.schema.json`
|
||||
with the bundled standalone validator. No dependency installation is required.
|
||||
|
||||
If `output.html` is omitted, the renderer uses the required `meta.output` value
|
||||
from the JSON file.
|
||||
|
||||
## Input
|
||||
|
||||
Lifecycle JSON files must set:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 2,
|
||||
"diagram_type": "lifecycle",
|
||||
"meta": {
|
||||
"title": "Deployment Release Lifecycle",
|
||||
"output": "deployment-release-lifecycle.html"
|
||||
},
|
||||
"lanes": [],
|
||||
"states": [],
|
||||
"transitions": [],
|
||||
"cards": []
|
||||
}
|
||||
```
|
||||
|
||||
`schema_version` is `1` or `2`; author new diagrams as `2`. Lane ids `main`
|
||||
(required) and `terminal` are reserved in both versions.
|
||||
|
||||
- **v2** renders one row per populated lane: `main` first, `terminal` last,
|
||||
other lanes in `lanes[]` order, each titled in the left gutter. A complete
|
||||
example lives at `archify/examples/deployment-release.lifecycle.json`.
|
||||
- **v1** keeps the fixed three bands: `main` is the top phase band,
|
||||
`terminal` the bottom outcome band, and every other lane shares the middle
|
||||
event band, whose header joins their labels with ` + `. A complete example
|
||||
lives at `archify/examples/agent-run.lifecycle.json`.
|
||||
|
||||
The schema lives at:
|
||||
|
||||
```text
|
||||
archify/schemas/lifecycle.schema.json
|
||||
```
|
||||
|
||||
## Legend and state marks
|
||||
|
||||
State color follows `states[].type`: active and start are cyan, waiting amber,
|
||||
decision purple, success green, failure rose, neutral and external slate.
|
||||
Structure is drawn, not colored: every `start` state gets a UML initial marker
|
||||
(a dot and arrow into its left side), and a state with no outgoing transition
|
||||
gets a double border as a final state (in v1, a main state followed by
|
||||
another main column is not final, because the implied rail continues).
|
||||
|
||||
The default legend derives kinds from `states[].type`; the `start` entry shows
|
||||
the initial marker, and a non-interactive `final` entry appears when a final
|
||||
state exists. Supported `meta.legend.entries` keys, in stable order, are
|
||||
`start`, `active`, `waiting`, `decision`, `success`, `failure`, `neutral`, and
|
||||
`external`. Labels and visibility may be overridden through the shared legend
|
||||
contract; only kinds backed by rendered states receive Semantic Legend
|
||||
controls.
|
||||
|
||||
State decorations share one top rail: the type sigil and `step` on the left,
|
||||
the brand mark at the right corner, and the Viewer's runtime source badge just
|
||||
left of the brand. Label layout reserves the badge's width whenever the state
|
||||
has verified repository sources.
|
||||
|
||||
## Layout budget (v2)
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Columns | `col` 0–4, one x grid shared by every row |
|
||||
| Default state | 140×64 (text 11px label, 8px sublabel and tag) |
|
||||
| Column gap | 64px, widened until a labelled same-row neighbour transition fits beside its line; all gaps shrink toward 44px when the canvas would exceed the desktop readability budget of the smallest state text |
|
||||
| Row gap | at least 120px, opened further for the horizontal tracks its routes need |
|
||||
| Canvas | sized from the rows, columns, and measured legend when `meta.viewBox` is omitted; an authored `viewBox` is honored and validated |
|
||||
|
||||
Transitions without `via`, a channel, or a non-`auto` route use the v2 grid
|
||||
router: neighbours in one row connect horizontally (a reciprocal pair runs as
|
||||
two parallel lines), rows connect through the facing top/bottom sides with one
|
||||
turn in a row gap, a state blocking a straight descent sends the route through
|
||||
the empty corridor between columns, and an unlabelled route blocked in an
|
||||
outer column loops around the outside of the grid. Each gap assigns tracks in
|
||||
the order that minimizes crossings. There is no implied rail; a forward
|
||||
transition between two `main` states without a `variant` renders as the
|
||||
emphasized primary path. Showcase labels are ranked beside their line, then on
|
||||
it, then outward past neighbouring parallels.
|
||||
|
||||
Explicit `fromSide` / `toSide` values remain authoritative. Pins that match the
|
||||
grid router's chosen sides keep its routes and adaptive row gaps. If an automatic
|
||||
transition pins a different side, the scene uses the shared side-aware obstacle
|
||||
planner, retaining the v2 state grid and shared port spreading.
|
||||
|
||||
## Layout budget (v1)
|
||||
|
||||
| Band | Lane id | Top y | Column centers | Default state |
|
||||
|------|---------|-------|----------------|---------------|
|
||||
| Phase | `main` (required) | 126 | `col` 0–4 → x = 94, 248, 402, 556, 710 | 118×62 |
|
||||
| Event | any other id | 278 | `col` 0–2 → x = 402, 556, 710 | 126×58 |
|
||||
| Outcome | `terminal` | 450 | `col` 0–2 → x = 402, 556, 710 | 118×58 |
|
||||
|
||||
Event and terminal columns are intentionally offset from the main rail:
|
||||
event/terminal `col: N` uses the same x coordinate as main `col: N + 2`.
|
||||
For example, lower-band columns 0, 1, and 2 align beneath main columns 2, 3,
|
||||
and 4 respectively.
|
||||
|
||||
| Constant | Value |
|
||||
|----------|-------|
|
||||
| viewBox | default `[980, 660]`; schema minimum `[420, 566]` |
|
||||
| State area | x within `[32, width − 32]`; state bottom at or above `height − 122` |
|
||||
| State spacing | ≥10px between any two states — checked across lanes, because all event lanes share one band; separate same-band states with `col` or `yOffset` |
|
||||
| Transition length | ≥32px between endpoints |
|
||||
| Legend row | final baseline y = height − 36; extra measured rows wrap upward |
|
||||
|
||||
The primary lifecycle rail runs along the phase band and extends to the
|
||||
furthest occupied phase column. Route presets for transitions: `straight`,
|
||||
`drop` (bend at `channelY`, defaulting to the vertical midpoint),
|
||||
`bottom-channel`, `top-channel`, `right-channel`, `left-channel`, explicit
|
||||
`via` points, or the default `auto`. Multi-segment transitions get rounded
|
||||
corners; tune them with `cornerRadius` (default 10, `0` for sharp bends).
|
||||
|
||||
Transition `label` and `note` are independently optional. A non-empty `note`
|
||||
renders even when `label` is omitted or empty, using its existing secondary
|
||||
text style on a single row and retaining the note's fine-detail visibility.
|
||||
With both fields present, the note stays below the label. Notes participate in
|
||||
automatic label placement, route-space reservation, and label collision checks;
|
||||
the existing `labelAt`, `labelDx`, `labelDy`, and
|
||||
`labelSegment` controls also position a note-only text block.
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Treat lifecycle diagrams as a phase map, not a dense state-transition graph.
|
||||
- Put the primary lifecycle on one horizontal row using the `main` lane; in v2,
|
||||
author each step of it as a transition.
|
||||
- In v2, place an interruption, recovery, or exit in the column of the state it
|
||||
leaves so its transition drops straight down.
|
||||
- Use `step` labels for ordered phases, such as `01`, `02`, and `03`.
|
||||
- Use lower lanes only for interruptions, recovery, and terminal exits.
|
||||
- Keep transition labels out of the main SVG unless the label is essential;
|
||||
prefer node labels, tags, legend entries, and summary cards.
|
||||
- Prefer axis-aligned lines and avoid crossings. Terminal exits should drop
|
||||
vertically from their source event whenever possible. Explicit `straight`
|
||||
routes remain supported; see the [authored routing contract](../../references/authoring-contract.md#executable-geometry-rules).
|
||||
- Use `success` for completion, `failure` for failure/terminal exits,
|
||||
`waiting` for pauses, and `decision` for quality gates.
|
||||
|
||||
Schema violations exit non-zero with path-prefixed messages annotated with the
|
||||
element's id or label. The renderer additionally fails when it can detect
|
||||
layout problems, including a missing `main` lane, duplicate state IDs, unknown
|
||||
lanes, unknown transition endpoints, states outside the lifecycle area,
|
||||
overlapping states (including across lanes), labels colliding with states or
|
||||
other labels, labels wider than their state, unreadably short transitions, or
|
||||
transitions crossing unrelated states (2px Clean Flow clearance). Lifecycle
|
||||
bands remain intentional pass-through containers.
|
||||
Text width is estimated CJK-aware: fullwidth glyphs count as two units.
|
||||
|
||||
Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
|
||||
X crossings then fail with `composition/proper-crossing`; default `standard`
|
||||
keeps them as artifact-receipt warnings. The final artifact check samples
|
||||
rounded `Q` corners. Collinear corridors remain outside the proper-X rule, but
|
||||
a separate gate warns in `standard` and fails in `showcase` when unrelated
|
||||
transitions overlap for at least 8px. Shared semantic endpoints, point touches,
|
||||
and shorter overlaps remain valid. Showcase also rejects any route segment
|
||||
below 8px and any interior turn segment below 16px; ordinary 8–15px endpoint
|
||||
stubs remain valid.
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
// Orthogonal router for schema_version 2 lifecycle diagrams.
|
||||
//
|
||||
// v2 states sit on a fixed grid: one row per lane and a shared column pitch,
|
||||
// with empty gaps between rows and between columns. That structure lets
|
||||
// every automatic transition use a small, predictable set of shapes instead
|
||||
// of a general obstacle search:
|
||||
// - neighbours in one row connect with a horizontal line;
|
||||
// - other states in one row connect through the gap above (below for the
|
||||
// first row);
|
||||
// - states in different rows leave through the facing top/bottom side,
|
||||
// turn once in a row gap, and enter the target's facing side; when a
|
||||
// state blocks the straight descent, the route steps sideways through the
|
||||
// empty corridor between two columns.
|
||||
// Ports on each side are spread in the order of where their routes head, and
|
||||
// the horizontal runs in each gap get their own tracks, ordered to minimize
|
||||
// crossings. Reciprocal pairs therefore render as two parallel lines.
|
||||
|
||||
const PORT_GUTTER = 16;
|
||||
const PORT_SPACING = 30;
|
||||
const SNAP_LIMIT = 16;
|
||||
const TRACK_TOP_CLEARANCE = 18;
|
||||
const TRACK_BOTTOM_CLEARANCE = 18;
|
||||
const PREFERRED_TRACK_SPACING = 22;
|
||||
const MAX_TRACK_SPACING = 24;
|
||||
const CORRIDOR_SPACING = 10;
|
||||
const EXHAUSTIVE_TRACK_LIMIT = 7;
|
||||
|
||||
const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
|
||||
|
||||
function permutations(items) {
|
||||
if (items.length <= 1) return [items];
|
||||
return items.flatMap((item, index) => permutations([...items.slice(0, index), ...items.slice(index + 1)])
|
||||
.map((rest) => [item, ...rest]));
|
||||
}
|
||||
|
||||
export function createLifecycleGridRouter(states, transitions, { rowOf, columnXs }) {
|
||||
const byRow = new Map();
|
||||
for (const state of states.values()) {
|
||||
const row = rowOf(state);
|
||||
if (!Number.isInteger(row)) continue;
|
||||
if (!byRow.has(row)) byRow.set(row, []);
|
||||
byRow.get(row).push(state);
|
||||
}
|
||||
const rows = [...byRow.keys()].sort((a, b) => a - b);
|
||||
const rowTop = new Map(rows.map((row) => [row, Math.min(...byRow.get(row).map((s) => s.y))]));
|
||||
const rowBottom = new Map(rows.map((row) => [row, Math.max(...byRow.get(row).map((s) => s.y + s.height))]));
|
||||
const nextRow = (row) => rows.find((candidate) => candidate > row);
|
||||
const previousRow = (row) => [...rows].reverse().find((candidate) => candidate < row);
|
||||
|
||||
// Gap g sits below row g. The gap under the last row borders the legend,
|
||||
// so it only receives tracks when nothing else is possible.
|
||||
function gapBand(row) {
|
||||
const below = nextRow(row);
|
||||
const top = rowBottom.get(row) + TRACK_TOP_CLEARANCE;
|
||||
const bottom = below === undefined ? rowBottom.get(row) + 40 : rowTop.get(below) - TRACK_BOTTOM_CLEARANCE;
|
||||
return [top, Math.max(top, bottom)];
|
||||
}
|
||||
|
||||
function blocksVertical(x, fromRow, toRow, exclude) {
|
||||
const [low, high] = fromRow < toRow ? [fromRow, toRow] : [toRow, fromRow];
|
||||
return [...states.values()].some((state) => {
|
||||
if (exclude.has(state.id)) return false;
|
||||
const row = rowOf(state);
|
||||
return row > low && row < high && x >= state.x - 6 && x <= state.x + state.width + 6;
|
||||
});
|
||||
}
|
||||
|
||||
function blocksHorizontal(from, to) {
|
||||
const row = rowOf(from);
|
||||
const [left, right] = from.cx < to.cx ? [from, to] : [to, from];
|
||||
return byRow.get(row).some((state) => state !== left && state !== right
|
||||
&& state.x < right.x && state.x + state.width > left.x + left.width
|
||||
&& state.y < Math.max(left.y + left.height, right.y + right.height)
|
||||
&& state.y + state.height > Math.min(left.y, right.y));
|
||||
}
|
||||
|
||||
// The empty corridors between neighbouring columns.
|
||||
const corridorXs = columnXs.slice(1).map((x, index) => (x + columnXs[index]) / 2);
|
||||
const allStates = [...states.values()];
|
||||
const leftmostX = Math.min(...allStates.map((state) => state.cx));
|
||||
const rightmostX = Math.max(...allStates.map((state) => state.cx));
|
||||
const gridLeft = Math.min(...allStates.map((state) => state.x));
|
||||
const gridRight = Math.max(...allStates.map((state) => state.x + state.width));
|
||||
// The initial-state marker occupies a start state's left side.
|
||||
function loopBlocked(from, to, side) {
|
||||
if (side !== 'left') return false;
|
||||
const [low, high] = [Math.min(rowOf(from), rowOf(to)), Math.max(rowOf(from), rowOf(to))];
|
||||
return allStates.some((state) => state.type === 'start' && Math.abs(state.cx - from.cx) < 1
|
||||
&& rowOf(state) >= low && rowOf(state) <= high);
|
||||
}
|
||||
|
||||
// Plan: sides, the gap each horizontal run uses, and where each end heads.
|
||||
const plans = new Map();
|
||||
for (const transition of transitions) {
|
||||
const from = states.get(transition.from);
|
||||
const to = states.get(transition.to);
|
||||
if (!from || !to || from === to) continue;
|
||||
const fromRow = rowOf(from);
|
||||
const toRow = rowOf(to);
|
||||
if (!Number.isInteger(fromRow) || !Number.isInteger(toRow)) continue;
|
||||
const exclude = new Set([from.id, to.id]);
|
||||
if (fromRow === toRow) {
|
||||
if (!blocksHorizontal(from, to)) {
|
||||
const fromSide = to.cx > from.cx ? 'right' : 'left';
|
||||
plans.set(transition, { kind: 'horizontal', from, to, fromSide, toSide: opposite[fromSide] });
|
||||
} else {
|
||||
const gapRow = previousRow(fromRow);
|
||||
const useAbove = gapRow !== undefined;
|
||||
const side = useAbove ? 'top' : 'bottom';
|
||||
plans.set(transition, {
|
||||
kind: 'channel', from, to, fromSide: side, toSide: side,
|
||||
runs: [{ gap: useAbove ? gapRow : fromRow, legs: useAbove ? ['down', 'down'] : ['up', 'up'] }],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const down = toRow > fromRow;
|
||||
const fromSide = down ? 'bottom' : 'top';
|
||||
const toSide = down ? 'top' : 'bottom';
|
||||
const gapNearTarget = down ? previousRow(toRow) : toRow;
|
||||
const gapNearSource = down ? fromRow : previousRow(fromRow);
|
||||
const legs = down ? ['up', 'down'] : ['down', 'up'];
|
||||
if (!blocksVertical(from.cx, fromRow, toRow, exclude) && Math.abs(from.cx - to.cx) < 1) {
|
||||
plans.set(transition, { kind: 'vertical', from, to, fromSide, toSide, runs: [{ gap: gapNearTarget, legs }] });
|
||||
} else if (!blocksVertical(from.cx, fromRow, toRow, exclude)) {
|
||||
plans.set(transition, { kind: 'channel', from, to, fromSide, toSide, runs: [{ gap: gapNearTarget, legs }] });
|
||||
} else if (!blocksVertical(to.cx, fromRow, toRow, exclude)) {
|
||||
plans.set(transition, { kind: 'channel', from, to, fromSide, toSide, runs: [{ gap: gapNearSource, legs }] });
|
||||
} else if (!transition.label && !transition.note
|
||||
&& Math.abs(from.cx - to.cx) < 1 && (from.cx <= leftmostX || from.cx >= rightmostX)
|
||||
&& !loopBlocked(from, to, from.cx <= leftmostX ? 'left' : 'right')) {
|
||||
// A blocked edge column loops around the outside of the grid, like a
|
||||
// bracket, instead of weaving through the rows' interior corridors.
|
||||
// The margin has no room for a label, so only unlabeled edges loop.
|
||||
const side = from.cx <= leftmostX ? 'left' : 'right';
|
||||
plans.set(transition, { kind: 'loop', from, to, fromSide: side, toSide: side, side });
|
||||
} else {
|
||||
const middle = (from.cx + to.cx) / 2;
|
||||
const corridor = corridorXs
|
||||
.filter((x) => !blocksVertical(x, fromRow, toRow, new Set()))
|
||||
.sort((a, b) => Math.abs(a - middle) - Math.abs(b - middle))[0];
|
||||
plans.set(transition, corridor === undefined
|
||||
? { kind: 'channel', from, to, fromSide, toSide, runs: [{ gap: gapNearTarget, legs }] }
|
||||
: {
|
||||
kind: 'corridor', from, to, fromSide, toSide, corridor,
|
||||
runs: [{ gap: gapNearSource, legs }, { gap: gapNearTarget, legs }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Corridor offsets: routes sharing one corridor run side by side.
|
||||
const corridorUse = new Map();
|
||||
for (const plan of plans.values()) {
|
||||
if (plan.kind !== 'corridor') continue;
|
||||
const list = corridorUse.get(plan.corridor) || [];
|
||||
list.push(plan);
|
||||
corridorUse.set(plan.corridor, list);
|
||||
}
|
||||
for (const [x, list] of corridorUse) {
|
||||
list.sort((a, b) => a.from.cx - b.from.cx || a.to.cx - b.to.cx);
|
||||
list.forEach((plan, index) => { plan.corridorX = x + (index - (list.length - 1) / 2) * CORRIDOR_SPACING; });
|
||||
}
|
||||
// Outer loops nest: a longer span sits further out so loops never cross.
|
||||
for (const side of ['left', 'right']) {
|
||||
const loops = [...plans.values()].filter((plan) => plan.kind === 'loop' && plan.side === side)
|
||||
.sort((a, b) => Math.abs(rowOf(a.from) - rowOf(a.to)) - Math.abs(rowOf(b.from) - rowOf(b.to)));
|
||||
loops.forEach((plan, index) => {
|
||||
plan.loopX = side === 'left' ? gridLeft - 18 - index * CORRIDOR_SPACING : gridRight + 18 + index * CORRIDOR_SPACING;
|
||||
});
|
||||
}
|
||||
|
||||
// Where each end heads after leaving its side, used to order the ports.
|
||||
function headingFor(plan, end) {
|
||||
const self = end === 'source' ? plan.from : plan.to;
|
||||
const other = end === 'source' ? plan.to : plan.from;
|
||||
if (plan.kind === 'horizontal' || plan.kind === 'loop') return other.cy;
|
||||
if (plan.kind === 'corridor') return plan.corridorX;
|
||||
return other === self ? self.cx : other.cx;
|
||||
}
|
||||
|
||||
const sideEnds = new Map();
|
||||
for (const [transition, plan] of plans) {
|
||||
for (const end of ['source', 'target']) {
|
||||
const state = end === 'source' ? plan.from : plan.to;
|
||||
const side = end === 'source' ? plan.fromSide : plan.toSide;
|
||||
const key = `${state.id}:${side}`;
|
||||
if (!sideEnds.has(key)) sideEnds.set(key, { state, side, ends: [] });
|
||||
// Movers in the positive direction (right/down) take the first slot so
|
||||
// both ends of a reciprocal pair line up.
|
||||
const positive = plan.kind === 'horizontal'
|
||||
? plan.to.cx > plan.from.cx
|
||||
: rowOf(plan.to) > rowOf(plan.from) || (rowOf(plan.to) === rowOf(plan.from) && plan.to.cx > plan.from.cx);
|
||||
sideEnds.get(key).ends.push({ transition, end, heading: headingFor(plan, end), positive });
|
||||
}
|
||||
}
|
||||
|
||||
const ports = new Map();
|
||||
for (const { state, side, ends } of sideEnds.values()) {
|
||||
ends.sort((a, b) => a.heading - b.heading || Number(b.positive) - Number(a.positive));
|
||||
const horizontalSide = side === 'top' || side === 'bottom';
|
||||
const length = horizontalSide ? state.width : state.height;
|
||||
const gutter = Math.min(PORT_GUTTER, length / 4);
|
||||
const spacing = ends.length > 1 ? Math.min(PORT_SPACING, (length - gutter * 2) / (ends.length - 1)) : 0;
|
||||
ends.forEach((entry, index) => {
|
||||
const offset = (index - (ends.length - 1) / 2) * spacing;
|
||||
const point = horizontalSide
|
||||
? [state.cx + offset, side === 'top' ? state.y : state.y + state.height]
|
||||
: [side === 'left' ? state.x : state.x + state.width, state.cy + offset];
|
||||
const record = ports.get(entry.transition) || {};
|
||||
record[entry.end] = point;
|
||||
ports.set(entry.transition, record);
|
||||
});
|
||||
}
|
||||
|
||||
// Straight connections whose spread ports landed a few px apart would
|
||||
// otherwise need a jog shorter than a readable turn: move one end onto the
|
||||
// other's line when that side still has room there.
|
||||
function sideRange(state, side) {
|
||||
return side === 'top' || side === 'bottom'
|
||||
? [state.x + PORT_GUTTER / 2, state.x + state.width - PORT_GUTTER / 2]
|
||||
: [state.y + PORT_GUTTER / 2, state.y + state.height - PORT_GUTTER / 2];
|
||||
}
|
||||
function portsOnSide(state, side, except) {
|
||||
return (sideEnds.get(`${state.id}:${side}`)?.ends || [])
|
||||
.filter((entry) => entry.transition !== except)
|
||||
.map((entry) => ports.get(entry.transition)[entry.end]);
|
||||
}
|
||||
for (const [transition, plan] of plans) {
|
||||
const record = ports.get(transition);
|
||||
const axis = plan.kind === 'horizontal' ? 1 : 0;
|
||||
const delta = Math.abs(record.source[axis] - record.target[axis]);
|
||||
if (delta < 0.5 || delta >= SNAP_LIMIT || !(plan.kind === 'horizontal' || plan.kind === 'vertical' || plan.kind === 'channel')) continue;
|
||||
for (const [end, fixed] of [['target', 'source'], ['source', 'target']]) {
|
||||
const state = end === 'source' ? plan.from : plan.to;
|
||||
const side = end === 'source' ? plan.fromSide : plan.toSide;
|
||||
const value = record[fixed][axis];
|
||||
const [low, high] = sideRange(state, side);
|
||||
const crowded = portsOnSide(state, side, transition).some((point) => Math.abs(point[axis] - value) < 10);
|
||||
if (value >= low && value <= high && !crowded) {
|
||||
record[end] = axis ? [record[end][0], value] : [value, record[end][1]];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal runs per gap, then tracks ordered to minimize crossings.
|
||||
const runsByGap = new Map();
|
||||
for (const [transition, plan] of plans) {
|
||||
if (plan.kind === 'horizontal' || plan.kind === 'loop') continue;
|
||||
const { source, target } = ports.get(transition);
|
||||
plan.runs.forEach((run, index) => {
|
||||
const x1 = index === 0 ? source[0] : plan.corridorX;
|
||||
const x2 = plan.kind === 'corridor' && index === 0 ? plan.corridorX : target[0];
|
||||
if (plan.kind !== 'corridor' && Math.abs(x1 - x2) < 0.5) return;
|
||||
const entry = { transition, index, x1, x2, legs: run.legs };
|
||||
if (!runsByGap.has(run.gap)) runsByGap.set(run.gap, []);
|
||||
runsByGap.get(run.gap).push(entry);
|
||||
});
|
||||
}
|
||||
|
||||
const trackY = new Map();
|
||||
const trackCounts = new Map();
|
||||
for (const [gap, runs] of runsByGap) {
|
||||
const [top, bottom] = gapBand(gap);
|
||||
// Greedy interval colouring keeps unrelated runs on shared tracks only
|
||||
// when they do not overlap.
|
||||
const sorted = [...runs].sort((a, b) => Math.min(a.x1, a.x2) - Math.min(b.x1, b.x2));
|
||||
const classes = [];
|
||||
for (const run of sorted) {
|
||||
const low = Math.min(run.x1, run.x2) - 8;
|
||||
const high = Math.max(run.x1, run.x2) + 8;
|
||||
let target = classes.find((members) => members.every((other) => (
|
||||
high < Math.min(other.x1, other.x2) - 8 || low > Math.max(other.x1, other.x2) + 8
|
||||
)));
|
||||
if (!target || runs.length <= EXHAUSTIVE_TRACK_LIMIT) {
|
||||
target = [];
|
||||
classes.push(target);
|
||||
}
|
||||
target.push(run);
|
||||
}
|
||||
const count = classes.length;
|
||||
trackCounts.set(gap, count);
|
||||
// The renderer sizes each gap for its track count; a fixed canvas that
|
||||
// cannot grow compresses the tracks rather than leaving the gap.
|
||||
const spacing = count > 1 ? Math.min(MAX_TRACK_SPACING, (bottom - top) / (count - 1)) : 0;
|
||||
const center = (top + bottom) / 2;
|
||||
const ys = classes.map((_, index) => center + (index - (count - 1) / 2) * spacing);
|
||||
const crossings = (order) => {
|
||||
const y = new Map();
|
||||
order.forEach((members, index) => members.forEach((run) => y.set(run, ys[index])));
|
||||
let total = 0;
|
||||
for (const a of runs) {
|
||||
for (const b of runs) {
|
||||
if (a === b || a.transition === b.transition) continue;
|
||||
const [low, high] = [Math.min(a.x1, a.x2), Math.max(a.x1, a.x2)];
|
||||
for (const [x, leg] of [[b.x1, b.legs[0]], [b.x2, b.legs[1]]]) {
|
||||
// An up leg and a down leg on one x overlap when the down leg
|
||||
// starts above where the up leg ends: that merges two routes.
|
||||
for (const [ax, aLeg] of [[a.x1, a.legs[0]], [a.x2, a.legs[1]]]) {
|
||||
if (Math.abs(ax - x) < 1 && aLeg === 'up' && leg === 'down' && y.get(b) < y.get(a)) total += 100;
|
||||
}
|
||||
if (x <= low + 0.5 || x >= high - 0.5) continue;
|
||||
if ((leg === 'up' && y.get(a) < y.get(b)) || (leg === 'down' && y.get(a) > y.get(b))) total += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
};
|
||||
let best = classes;
|
||||
let bestScore = crossings(best);
|
||||
if (count <= EXHAUSTIVE_TRACK_LIMIT) {
|
||||
for (const order of permutations(classes).slice(1)) {
|
||||
const score = crossings(order);
|
||||
if (score < bestScore) {
|
||||
best = order;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Too many tracks to enumerate: swap pairs while that still helps.
|
||||
for (let improved = true; improved;) {
|
||||
improved = false;
|
||||
for (let i = 0; i < count && !improved; i += 1) {
|
||||
for (let j = i + 1; j < count && !improved; j += 1) {
|
||||
const order = [...best];
|
||||
[order[i], order[j]] = [order[j], order[i]];
|
||||
const score = crossings(order);
|
||||
if (score < bestScore) {
|
||||
best = order;
|
||||
bestScore = score;
|
||||
improved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best.forEach((members, index) => members.forEach((run) => trackY.set(`${gap}:${run.index}:${transitions.indexOf(run.transition)}`, ys[index])));
|
||||
}
|
||||
|
||||
const pathCache = new Map();
|
||||
function pointsFor(transition) {
|
||||
const plan = plans.get(transition);
|
||||
if (!plan) {
|
||||
// Self transitions and unknown endpoints are rejected by validation;
|
||||
// a degenerate stub keeps that diagnostic reachable.
|
||||
const state = states.get(transition.from) || states.get(transition.to);
|
||||
const point = state ? [state.x + state.width, state.cy] : [0, 0];
|
||||
return [point, point];
|
||||
}
|
||||
const { source, target } = ports.get(transition);
|
||||
if (plan.kind === 'horizontal') {
|
||||
if (Math.abs(source[1] - target[1]) < 0.5) return [source, target];
|
||||
const x = (source[0] + target[0]) / 2;
|
||||
return [source, [x, source[1]], [x, target[1]], target];
|
||||
}
|
||||
if (plan.kind === 'loop') return [source, [plan.loopX, source[1]], [plan.loopX, target[1]], target];
|
||||
const trackFor = (index) => trackY.get(`${plan.runs[index].gap}:${index}:${transitions.indexOf(transition)}`);
|
||||
if (plan.kind === 'corridor') {
|
||||
const y1 = trackFor(0);
|
||||
const y2 = trackFor(1);
|
||||
return [source, [source[0], y1], [plan.corridorX, y1], [plan.corridorX, y2], [target[0], y2], target];
|
||||
}
|
||||
if (Math.abs(source[0] - target[0]) < 0.5) return [source, target];
|
||||
const y = trackFor(0);
|
||||
return [source, [source[0], y], [target[0], y], target];
|
||||
}
|
||||
|
||||
// Ports of two routes can still line up across a gap so their vertical
|
||||
// runs share one line. Nudge a turning route's end sideways on its side.
|
||||
for (const transition of plans.keys()) pathCache.set(transition, pointsFor(transition));
|
||||
const verticals = (points) => points.slice(1).flatMap((end, index) => {
|
||||
const start = points[index];
|
||||
if (Math.abs(start[0] - end[0]) >= 0.5 || Math.abs(start[1] - end[1]) < 0.5) return [];
|
||||
return [{ x: start[0], low: Math.min(start[1], end[1]), high: Math.max(start[1], end[1]), index, last: index === points.length - 2 }];
|
||||
});
|
||||
for (let round = 0; round < 12; round += 1) {
|
||||
const entries = [...pathCache.entries()];
|
||||
let conflict = null;
|
||||
for (let i = 0; i < entries.length && !conflict; i += 1) {
|
||||
for (let j = i + 1; j < entries.length && !conflict; j += 1) {
|
||||
for (const left of verticals(entries[i][1])) {
|
||||
const right = verticals(entries[j][1]).find((other) => Math.abs(other.x - left.x) < 1
|
||||
&& Math.min(other.high, left.high) - Math.max(other.low, left.low) > 0.5);
|
||||
if (right) {
|
||||
conflict = [[entries[i][0], left], [entries[j][0], right]];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!conflict) break;
|
||||
let moved = false;
|
||||
for (const [transition, segment] of conflict) {
|
||||
const plan = plans.get(transition);
|
||||
const end = segment.index === 0 ? 'source' : segment.last ? 'target' : null;
|
||||
if (!end || pathCache.get(transition).length < 4 || !['channel', 'corridor'].includes(plan.kind)) continue;
|
||||
const state = end === 'source' ? plan.from : plan.to;
|
||||
const side = end === 'source' ? plan.fromSide : plan.toSide;
|
||||
const record = ports.get(transition);
|
||||
const [low, high] = sideRange(state, side);
|
||||
const x = [12, -12, 20, -20].map((delta) => record[end][0] + delta).find((candidate) => (
|
||||
candidate >= low && candidate <= high
|
||||
&& !portsOnSide(state, side, transition).some((point) => Math.abs(point[0] - candidate) < 10)
|
||||
));
|
||||
if (x === undefined) continue;
|
||||
record[end] = [x, record[end][1]];
|
||||
pathCache.set(transition, pointsFor(transition));
|
||||
moved = true;
|
||||
break;
|
||||
}
|
||||
if (!moved) break;
|
||||
}
|
||||
|
||||
return {
|
||||
// Height a gap below `row` needs for its tracks at a readable spacing.
|
||||
gapHeight(row) {
|
||||
const count = trackCounts.get(row) || 0;
|
||||
return TRACK_TOP_CLEARANCE + TRACK_BOTTOM_CLEARANCE + Math.max(0, count - 1) * PREFERRED_TRACK_SPACING;
|
||||
},
|
||||
connectionSides(transition) {
|
||||
const plan = plans.get(transition);
|
||||
return plan ? { fromSide: plan.fromSide, toSide: plan.toSide } : { fromSide: 'right', toSide: 'right' };
|
||||
},
|
||||
pathFor(transition) {
|
||||
if (!pathCache.has(transition)) pathCache.set(transition, pointsFor(transition));
|
||||
return pathCache.get(transition);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,938 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
|
||||
import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
|
||||
import { recordDiagnostic, throwDiagnosticProblems } from '../shared/diagnostics.mjs';
|
||||
import { createRouter } from '../architecture/routing.mjs';
|
||||
import { createLifecycleGridRouter } from './grid-routing.mjs';
|
||||
import { placeAutomaticLabels, reservedLabelRect } from '../architecture/labels.mjs';
|
||||
import { legendFootprint, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
|
||||
import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth, nodeLabelLayout } from '../shared/text-fit.mjs';
|
||||
import { brandLabelFitWidth, brandMarkFor, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
|
||||
import { translateMessage as i18nText } from '../shared/i18n.mjs';
|
||||
import { DESKTOP_READER_DIAGRAM_WIDTH, MIN_PROJECTED_NODE_TEXT_PX } from '../shared/desktop-readability.mjs';
|
||||
import {
|
||||
asArray,
|
||||
isFinitePoint,
|
||||
rectsOverlap,
|
||||
cleanEndpointSideProblems,
|
||||
cleanFlowProblems,
|
||||
cleanCrossingProblems,
|
||||
cleanAmbiguousCorridorProblems,
|
||||
cleanBorderRunProblems,
|
||||
cleanRouteRhythmProblems,
|
||||
cleanLabelRouteClearanceProblems,
|
||||
cleanLabelCanvasContainmentProblems,
|
||||
suggestLabelObstacleFix,
|
||||
suggestLabelPairFix,
|
||||
anchor,
|
||||
automaticPortSpread,
|
||||
legacyDefaultFromSide as defaultFromSide,
|
||||
legacyDefaultToSide as defaultToSide,
|
||||
chosenSide,
|
||||
roundedPath,
|
||||
routePointsValue,
|
||||
authoredStraightRouteAttrs,
|
||||
labelPoint,
|
||||
arrowClassMap,
|
||||
edgeLabelAccent
|
||||
} from '../shared/geometry.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const { diagram: lifecycle, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
|
||||
rendererDir: __dirname,
|
||||
diagramType: 'lifecycle',
|
||||
defaultExample: 'agent-run.lifecycle.json'
|
||||
});
|
||||
|
||||
// v2 sets state text one step larger; its canvas width is then budgeted
|
||||
// from the smallest fitted text so the desktop Reader keeps it legible.
|
||||
const stateTextFit = lifecycle.schema_version === 2 ? {
|
||||
labelPreferred: 11,
|
||||
labelMinimum: 9,
|
||||
sublabelPreferred: 8,
|
||||
sublabelMinimum: 7,
|
||||
tagPreferred: 8,
|
||||
tagMinimum: 7,
|
||||
step: 8,
|
||||
} : {
|
||||
labelPreferred: 10,
|
||||
labelMinimum: 8,
|
||||
sublabelPreferred: 7,
|
||||
sublabelMinimum: 6,
|
||||
tagPreferred: 7,
|
||||
tagMinimum: 6,
|
||||
step: 7,
|
||||
};
|
||||
|
||||
// schema_version 2 replaces the three fixed bands (main/event/outcome, with
|
||||
// non-main lanes sharing one band and lower columns offset by +2) with one
|
||||
// row per populated lane on a shared 0..4 column grid. v1 keeps its exact
|
||||
// state geometry; only presentation (colors, markers, legend, sigil side)
|
||||
// is shared between versions.
|
||||
const isV2 = lifecycle.schema_version === 2;
|
||||
const authoredViewBox = lifecycle.meta?.viewBox;
|
||||
|
||||
const layout = {
|
||||
phaseY: 126,
|
||||
eventY: 278,
|
||||
outcomeY: 450,
|
||||
phaseW: 118,
|
||||
phaseH: 62,
|
||||
eventW: 126,
|
||||
eventH: 58,
|
||||
outcomeW: 118,
|
||||
outcomeH: 58,
|
||||
phaseXs: [94, 248, 402, 556, 710],
|
||||
eventXs: [402, 556, 710],
|
||||
outcomeXs: [402, 556, 710]
|
||||
};
|
||||
|
||||
const layoutV2 = {
|
||||
stateW: 140,
|
||||
stateH: 64,
|
||||
marginX: 60,
|
||||
minGap: 64,
|
||||
floorGap: 44,
|
||||
firstRowTop: 56,
|
||||
rowPitch: 184,
|
||||
};
|
||||
|
||||
function transitionLabelWidth(transition) {
|
||||
const longestLine = Math.max(textUnits(transition.label), textUnits(transition.note || ''));
|
||||
return Math.max(32, longestLine * 4.9 + 12);
|
||||
}
|
||||
|
||||
function stateFontSizes(state, width) {
|
||||
return {
|
||||
label: fittedNodeFontSize(state.label, brandLabelFitWidth(state, width), stateTextFit.labelPreferred, stateTextFit.labelMinimum),
|
||||
sublabel: fittedNodeFontSize(state.sublabel, width, stateTextFit.sublabelPreferred, stateTextFit.sublabelMinimum),
|
||||
tag: fittedNodeFontSize(state.tag, width, stateTextFit.tagPreferred, stateTextFit.tagMinimum),
|
||||
};
|
||||
}
|
||||
|
||||
// v2 column centers. A gap between two columns widens until the label of a
|
||||
// same-row neighbour transition fits beside its line; the whole canvas then
|
||||
// stays inside the desktop readability budget of its smallest state text.
|
||||
const v2ColumnCenters = (() => {
|
||||
if (!isV2) return [];
|
||||
const authored = asArray(lifecycle.states);
|
||||
const widths = [0, 1, 2, 3, 4].map((col) => Math.max(
|
||||
layoutV2.stateW, ...authored.filter((state) => state.col === col).map((state) => state.width || 0),
|
||||
));
|
||||
const cols = authored.map((state) => state.col).filter((col) => Number.isInteger(col) && col >= 0 && col <= 4);
|
||||
const lastCol = cols.length ? Math.max(...cols) : 0;
|
||||
const gaps = [0, 1, 2, 3].map(() => layoutV2.minGap);
|
||||
const byId = new Map(authored.map((state) => [state.id, state]));
|
||||
for (const transition of asArray(lifecycle.transitions)) {
|
||||
const [from, to] = [byId.get(transition.from), byId.get(transition.to)];
|
||||
if (!(transition.label || transition.note) || !from || !to || from.lane !== to.lane
|
||||
|| !Number.isInteger(from.col) || !Number.isInteger(to.col) || Math.abs(from.col - to.col) !== 1) continue;
|
||||
const gap = Math.min(from.col, to.col);
|
||||
if (gap >= 0 && gap < 4) gaps[gap] = Math.max(gaps[gap], Math.ceil(transitionLabelWidth(transition) + 24));
|
||||
}
|
||||
const smallestText = Math.min(stateTextFit.step, ...authored.flatMap((state) => (
|
||||
Object.values(stateFontSizes(state, state.width || layoutV2.stateW))
|
||||
)));
|
||||
const budget = Math.floor(DESKTOP_READER_DIAGRAM_WIDTH * smallestText / MIN_PROJECTED_NODE_TEXT_PX);
|
||||
const fixed = layoutV2.marginX * 2 + widths.slice(0, lastCol + 1).reduce((sum, width) => sum + width, 0);
|
||||
const used = gaps.slice(0, lastCol);
|
||||
const excess = fixed + used.reduce((sum, gap) => sum + gap, 0) - budget;
|
||||
const slack = used.reduce((sum, gap) => sum + gap - layoutV2.floorGap, 0);
|
||||
if (excess > 0 && slack > 0) {
|
||||
const ratio = Math.min(1, excess / slack);
|
||||
for (let index = 0; index < used.length; index += 1) {
|
||||
gaps[index] = Math.floor(gaps[index] - (gaps[index] - layoutV2.floorGap) * ratio);
|
||||
}
|
||||
}
|
||||
const centers = [];
|
||||
let x = layoutV2.marginX;
|
||||
for (let col = 0; col <= 4; col += 1) {
|
||||
centers.push(x + widths[col] / 2);
|
||||
x += widths[col] + (gaps[col] ?? 0);
|
||||
}
|
||||
return centers;
|
||||
})();
|
||||
|
||||
// Rows render in authored lane order with `main` first and `terminal` last;
|
||||
// lanes without states get no row and no title.
|
||||
function laneRowOrder() {
|
||||
const lanes = asArray(lifecycle.lanes);
|
||||
const populated = new Set(asArray(lifecycle.states).map((state) => state.lane));
|
||||
const ordered = [];
|
||||
if (populated.has('main')) ordered.push('main');
|
||||
for (const lane of lanes) {
|
||||
if (lane.id !== 'main' && lane.id !== 'terminal' && populated.has(lane.id)) ordered.push(lane.id);
|
||||
}
|
||||
if (populated.has('terminal')) ordered.push('terminal');
|
||||
return ordered;
|
||||
}
|
||||
|
||||
const v2RowTop = new Map(isV2
|
||||
? laneRowOrder().map((laneId, index) => [laneId, layoutV2.firstRowTop + index * layoutV2.rowPitch])
|
||||
: []);
|
||||
|
||||
const typeClass = {
|
||||
start: 'c-frontend',
|
||||
active: 'c-frontend',
|
||||
waiting: 'c-cloud',
|
||||
decision: 'c-database',
|
||||
success: 'c-backend',
|
||||
failure: 'c-security',
|
||||
neutral: 'c-external',
|
||||
external: 'c-external'
|
||||
};
|
||||
|
||||
const textClass = {
|
||||
start: 't-frontend',
|
||||
active: 't-frontend',
|
||||
waiting: 't-cloud',
|
||||
decision: 't-database',
|
||||
success: 't-backend',
|
||||
failure: 't-security',
|
||||
neutral: 't-muted',
|
||||
external: 't-muted'
|
||||
};
|
||||
|
||||
// Lane semantics are fixed: lane id "main" maps to the top phase band, lane id
|
||||
// "terminal" maps to the bottom outcome band, and every other lane shares the
|
||||
// middle event band (separated visually via yOffset). v1 only.
|
||||
function bandFor(lane) {
|
||||
if (lane === 'main') return 'phase';
|
||||
if (lane === 'terminal') return 'outcome';
|
||||
return 'event';
|
||||
}
|
||||
|
||||
function measureState(state) {
|
||||
let width;
|
||||
let height;
|
||||
let cx;
|
||||
let y;
|
||||
if (isV2) {
|
||||
width = state.width || layoutV2.stateW;
|
||||
height = state.height || layoutV2.stateH;
|
||||
cx = v2ColumnCenters[state.col] ?? NaN;
|
||||
y = (v2RowTop.get(state.lane) ?? NaN) + (state.yOffset || 0);
|
||||
} else {
|
||||
const isPhase = bandFor(state.lane) === 'phase';
|
||||
const isOutcome = bandFor(state.lane) === 'outcome';
|
||||
width = state.width || (isPhase ? layout.phaseW : isOutcome ? layout.outcomeW : layout.eventW);
|
||||
height = state.height || (isPhase ? layout.phaseH : isOutcome ? layout.outcomeH : layout.eventH);
|
||||
const xs = isPhase ? layout.phaseXs : isOutcome ? layout.outcomeXs : layout.eventXs;
|
||||
cx = xs[state.col] ?? xs[xs.length - 1];
|
||||
y = (
|
||||
isPhase ? layout.phaseY :
|
||||
isOutcome ? layout.outcomeY :
|
||||
layout.eventY
|
||||
) + (state.yOffset || 0);
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
width,
|
||||
height,
|
||||
x: cx - width / 2,
|
||||
y,
|
||||
cx,
|
||||
cy: y + height / 2
|
||||
};
|
||||
}
|
||||
|
||||
const states = new Map(asArray(lifecycle.states).map((state) => [state.id, measureState(state)]));
|
||||
const plannedTransitions = asArray(lifecycle.transitions).filter(plannerRouted);
|
||||
const v2Rows = laneRowOrder();
|
||||
const v2RowOf = (state) => (v2Rows.includes(state.lane) ? v2Rows.indexOf(state.lane) : undefined);
|
||||
let useGridRouter = isV2;
|
||||
if (isV2) {
|
||||
// Route once to learn how many horizontal tracks each row gap carries,
|
||||
// then open every gap to fit them before the final routing pass.
|
||||
const probe = createLifecycleGridRouter(states, plannedTransitions, {
|
||||
rowOf: v2RowOf, columnXs: v2ColumnCenters,
|
||||
});
|
||||
// Compatible pins keep the grid layout. A conflicting pin sends the whole
|
||||
// scene to the side-aware planner so all edges still share port spreading
|
||||
// and obstacle reservations.
|
||||
useGridRouter = plannedTransitions.every(transition => {
|
||||
const sides = probe.connectionSides(transition);
|
||||
return ['fromSide', 'toSide'].every(key => !transition[key] || transition[key] === 'auto' || transition[key] === sides[key]);
|
||||
});
|
||||
let top = layoutV2.firstRowTop;
|
||||
v2Rows.forEach((laneId, index) => {
|
||||
const rowHeight = Math.max(layoutV2.stateH, ...[...states.values()]
|
||||
.filter((state) => state.lane === laneId)
|
||||
.map((state) => state.y + state.height - v2RowTop.get(laneId)));
|
||||
v2RowTop.set(laneId, top);
|
||||
top += rowHeight + Math.max(layoutV2.rowPitch - layoutV2.stateH, useGridRouter ? probe.gapHeight(index) : 0);
|
||||
});
|
||||
for (const state of asArray(lifecycle.states)) states.set(state.id, measureState(state));
|
||||
}
|
||||
const laneLabels = new Map(asArray(lifecycle.lanes).map((lane) => [lane.id, lane.label]));
|
||||
const authoredOutgoing = new Set(asArray(lifecycle.transitions).map((transition) => transition.from));
|
||||
|
||||
// A state with no authored outgoing transition is terminal in the UML sense.
|
||||
// v1 main states rely on the implied phase rail, so a main state is only final
|
||||
// at the furthest occupied main column; every other v1 lane is explicit.
|
||||
function isFinal(state) {
|
||||
if (authoredOutgoing.has(state.id)) return false;
|
||||
if (isV2) return true;
|
||||
if (bandFor(state.lane) !== 'phase') return true;
|
||||
const mainCols = [...states.values()].filter((s) => bandFor(s.lane) === 'phase').map((s) => s.col);
|
||||
return state.col === Math.max(...mainCols);
|
||||
}
|
||||
|
||||
const LEGEND_CATALOG = [
|
||||
'start',
|
||||
'active',
|
||||
'waiting',
|
||||
'decision',
|
||||
'success',
|
||||
'failure',
|
||||
'neutral',
|
||||
'external',
|
||||
].map((kind) => ({
|
||||
kind,
|
||||
label: i18nText(lifecycle.meta.locale, `legend.lifecycle.${kind}`),
|
||||
swatchWidth: kind === 'start' ? 26 : undefined,
|
||||
}));
|
||||
|
||||
// Entries exist before the canvas so an auto-sized v2 viewBox can reserve the
|
||||
// measured legend rows below the last row instead of painting over them.
|
||||
// The structural `final` entry explains the double border; it follows the
|
||||
// kind entries and disappears with them, so a hidden legend stays empty.
|
||||
function legendCatalog() {
|
||||
const presentKinds = new Set([...states.values()].map((state) => state.type));
|
||||
const entries = resolveLegend(lifecycle.meta?.legend, LEGEND_CATALOG, presentKinds);
|
||||
if (!entries.length || ![...states.values()].some(isFinal)) return entries;
|
||||
return [...entries, {
|
||||
kind: 'final',
|
||||
label: i18nText(lifecycle.meta.locale, 'legend.lifecycle.final'),
|
||||
interactive: false,
|
||||
present: true,
|
||||
swatchWidth: 16,
|
||||
}];
|
||||
}
|
||||
|
||||
const resolvedLegendEntries = legendCatalog();
|
||||
|
||||
let viewBox;
|
||||
if (isV2 && !authoredViewBox) {
|
||||
const finite = [...states.values()];
|
||||
const maxRight = Math.max(0, ...finite.map((state) => state.cx + state.width / 2).filter(Number.isFinite));
|
||||
const width = Math.max(640, Math.ceil(maxRight + layoutV2.marginX));
|
||||
const footprint = legendFootprint(resolvedLegendEntries, { width: width - 80 });
|
||||
const statesBottom = Math.max(0, ...finite.map((state) => state.y + state.height).filter(Number.isFinite));
|
||||
viewBox = [width, Math.ceil(statesBottom + footprint.extraHeight + 96)];
|
||||
} else {
|
||||
viewBox = authoredViewBox || [980, 660];
|
||||
}
|
||||
|
||||
const legendExtraHeight = legendFootprint(resolvedLegendEntries, { width: viewBox[0] - 80 }).extraHeight;
|
||||
|
||||
function legendY() {
|
||||
return viewBox[1] - 36;
|
||||
}
|
||||
|
||||
// Keep the authored state-placement contract independent from the measured
|
||||
// legend's lower baseline. Moving legend chrome must not admit new state
|
||||
// geometry into the reserved outcome/legend band.
|
||||
function lifecycleAreaBottom() {
|
||||
return isV2 ? viewBox[1] - legendExtraHeight - 96 : viewBox[1] - 122;
|
||||
}
|
||||
const stateSteps = new Map();
|
||||
for (const [index, transition] of asArray(lifecycle.transitions).entries()) {
|
||||
if (!stateSteps.has(transition.from)) stateSteps.set(transition.from, index);
|
||||
if (!stateSteps.has(transition.to)) stateSteps.set(transition.to, index + 1);
|
||||
}
|
||||
for (const [index, state] of asArray(lifecycle.states).entries()) {
|
||||
if (!stateSteps.has(state.id)) stateSteps.set(state.id, index);
|
||||
}
|
||||
|
||||
function validateLifecycle() {
|
||||
const problems = [];
|
||||
if (states.size !== asArray(lifecycle.states).length) problems.push('State ids must be unique.');
|
||||
|
||||
// The three bands are fixed at y=112/264/436. Preserve the original
|
||||
// outcome/legend reserve even though measured legend rows now sit lower.
|
||||
// v2 derives its canvas from rendered rows, so the floor does not apply.
|
||||
if (!isV2 && lifecycleAreaBottom() + 4 < 448) {
|
||||
problems.push(`viewBox height ${viewBox[1]} is too short for the fixed band layout — set meta.viewBox[1] to at least 566.`);
|
||||
}
|
||||
|
||||
const laneIds = new Set(asArray(lifecycle.lanes).map((lane) => lane.id));
|
||||
if (laneIds.size !== asArray(lifecycle.lanes).length) problems.push('Lane ids must be unique.');
|
||||
if (!laneIds.has('main')) {
|
||||
problems.push(isV2
|
||||
? 'Lifecycle diagrams need a lane with id "main" (the first row). Lane ids "main" and "terminal" are reserved: "main" renders as the first row, "terminal" as the last, and every other lane in authored order.'
|
||||
: 'Lifecycle diagrams need a lane with id "main" (the phase rail). Lane ids "main" and "terminal" are reserved: "main" maps to the top phase band, "terminal" to the bottom outcome band, and all other lanes share the middle event band.');
|
||||
}
|
||||
|
||||
for (const state of states.values()) {
|
||||
if (!laneIds.has(state.lane)) {
|
||||
problems.push(`State "${state.id}" uses unknown lane "${state.lane}".`);
|
||||
continue;
|
||||
}
|
||||
if (isV2) {
|
||||
if (!Number.isInteger(state.col) || state.col < 0 || state.col > 4) {
|
||||
problems.push(`State "${state.id}" uses invalid column ${state.col} — every lifecycle row has integer columns 0..4.`);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
const band = bandFor(state.lane);
|
||||
const maxCol = band === 'phase'
|
||||
? layout.phaseXs.length
|
||||
: band === 'outcome'
|
||||
? layout.outcomeXs.length
|
||||
: layout.eventXs.length;
|
||||
if (!Number.isInteger(state.col) || state.col < 0 || state.col >= maxCol) {
|
||||
problems.push(`State "${state.id}" uses invalid column ${state.col} — the ${band} band has integer columns 0..${maxCol - 1}.`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!isFinitePoint(state.x, state.y, state.cx, state.cy)) {
|
||||
problems.push(`State "${state.id}" produced non-finite coordinates — check col, width, height, and yOffset are numbers.`);
|
||||
continue;
|
||||
}
|
||||
if (state.x < (isV2 ? 28 : 32) || state.x + state.width > viewBox[0] - (isV2 ? 28 : 32)) {
|
||||
problems.push(`State "${state.id}" exceeds the horizontal bounds of the diagram — reduce state.width${isV2 ? ', lower its col,' : ''} or increase meta.viewBox[0].`);
|
||||
}
|
||||
if (state.y < (isV2 ? 44 : 64) || state.y + state.height > lifecycleAreaBottom()) {
|
||||
problems.push(`State "${state.id}" exceeds the vertical lifecycle area — keep y between ${isV2 ? 44 : 64} and ${lifecycleAreaBottom()} (adjust yOffset or increase meta.viewBox[1]).`);
|
||||
}
|
||||
const estLabelW = textUnits(state.label) * 6.2;
|
||||
if (estLabelW > state.width + 6) {
|
||||
problems.push(`Label "${state.label}" (~${Math.round(estLabelW)}px) is wider than state "${state.id}" (${state.width}px) — shorten the label or increase state.width.`);
|
||||
}
|
||||
const brandRailProblem = brandTopRailProblem(state, state.width, 8, 'State');
|
||||
if (brandRailProblem) problems.push(brandRailProblem);
|
||||
// sublabel and tag render as single unwrapped <text> elements; shrink-to-fit
|
||||
// handles the ordinary case, this rejects what it cannot rescue.
|
||||
const availableTextW = availableNodeTextWidth(state.width);
|
||||
for (const [field, value, minimum] of [
|
||||
['Sublabel', state.sublabel, stateTextFit.sublabelMinimum],
|
||||
['Tag', state.tag, stateTextFit.tagMinimum],
|
||||
]) {
|
||||
if (!value) continue;
|
||||
const minimumW = minimumNodeTextWidth(value, minimum);
|
||||
if (minimumW > availableTextW) {
|
||||
problems.push(`${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but state "${state.id}" provides ${availableTextW}px — shorten the ${field.toLowerCase()} or increase state.width.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v1: all non-main/non-terminal lanes share the same y band, so the overlap
|
||||
// check must run across lanes — not per-lane. v2 gives each lane its own
|
||||
// row, so only same-row neighbours can collide, but the shared check still
|
||||
// covers custom widths and yOffset nudges.
|
||||
const allStates = [...states.values()];
|
||||
for (let i = 0; i < allStates.length; i += 1) {
|
||||
for (let j = i + 1; j < allStates.length; j += 1) {
|
||||
if (rectsOverlap(allStates[i], allStates[j], 10)) {
|
||||
problems.push(`States "${allStates[i].id}" and "${allStates[j].id}" are less than 10px apart — move one to another col or separate them with yOffset${isV2 ? '.' : ' (lanes other than "main"/"terminal" share one band).'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const transition of asArray(lifecycle.transitions)) {
|
||||
if (!states.has(transition.from)) problems.push(`Transition "${transition.label || transition.from}" references unknown source "${transition.from}".`);
|
||||
if (!states.has(transition.to)) problems.push(`Transition "${transition.label || transition.to}" references unknown target "${transition.to}".`);
|
||||
if (states.has(transition.from) && states.has(transition.to)) {
|
||||
const routed = pathFor(transition);
|
||||
const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
|
||||
const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
|
||||
if (distance < 32) problems.push(`Transition "${transition.label || `${transition.from}->${transition.to}`}" is too short (${Math.round(distance)}px; minimum 32px) — route it through a channel or drop its label.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Authored via points are authoritative in schema v1, including under a
|
||||
// quality profile. Preserve and render them exactly: applying the endpoint
|
||||
// gate would either reject an existing typed input or require silently
|
||||
// falsifying its geometry. Automatic routes still receive the side gate.
|
||||
problems.push(...cleanEndpointSideProblems({
|
||||
relations: lifecycle.transitions,
|
||||
endpointIds: new Set(states.keys()),
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
fromSideFor: (transition) => transitionSides(transition).fromSide,
|
||||
toSideFor: (transition) => transitionSides(transition).toSide,
|
||||
shouldCheckRelation: (transition) => !Array.isArray(transition.via),
|
||||
routeHint: 'keep automatic routing, or choose fromSide/toSide and via points whose first and final segments cross state borders perpendicularly',
|
||||
}));
|
||||
problems.push(...cleanFlowProblems({
|
||||
relations: lifecycle.transitions,
|
||||
obstacles: states.values(),
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
obstacleKind: 'state',
|
||||
routeHint: 'adjust fromSide/toSide, set route/via or channelX/channelY, or move the state with col/yOffset'
|
||||
}));
|
||||
problems.push(...cleanCrossingProblems({
|
||||
relations: lifecycle.transitions,
|
||||
endpointIds: new Set(states.keys()),
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
profile: lifecycle.meta?.quality_profile,
|
||||
// Planner routes render with the opaque crossover halo, like architecture.
|
||||
crossingResolved: (left, right) => plannerRouted(left) && plannerRouted(right),
|
||||
routeHint: 'adjust route/via or channelX/channelY so the transitions use separate lifecycle corridors'
|
||||
}));
|
||||
problems.push(...cleanAmbiguousCorridorProblems({
|
||||
relations: lifecycle.transitions,
|
||||
endpointIds: new Set(states.keys()),
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
profile: lifecycle.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via or channelX/channelY so unrelated transitions do not visually merge'
|
||||
}));
|
||||
// Lifecycle bands are dashed reading guides, not closed containers. Keep the
|
||||
// shared contract wired with an explicit empty frame set so future typed
|
||||
// lifecycle containers cannot accidentally inherit presentation geometry.
|
||||
problems.push(...cleanBorderRunProblems({
|
||||
relations: lifecycle.transitions,
|
||||
endpointIds: new Set(states.keys()),
|
||||
frames: [],
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
profile: lifecycle.meta?.quality_profile
|
||||
}));
|
||||
problems.push(...cleanRouteRhythmProblems({
|
||||
relations: lifecycle.transitions,
|
||||
endpointIds: new Set(states.keys()),
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
profile: lifecycle.meta?.quality_profile,
|
||||
routeHint: 'move route/via or channel coordinates so each lifecycle turn has a readable run-up'
|
||||
}));
|
||||
|
||||
const labelRects = transitionLabelRects();
|
||||
if (lifecycle.meta?.quality_profile === 'showcase') {
|
||||
for (const rect of labelRects) {
|
||||
for (const title of bandGeometry()) {
|
||||
if (!rectsOverlap(rect, title)) continue;
|
||||
const message = `Transition ${rect.relationIndex} label "${rect.label}" overlaps lifecycle band title "${title.label}" — move the label with labelAt/labelDx/labelDy/labelSegment or provide more space.`;
|
||||
recordDiagnostic({
|
||||
code: 'composition/label-band-title-overlap', severity: 'error', message,
|
||||
subject: { diagramType: 'lifecycle', collection: 'transitions', index: rect.relationIndex, from: rect.relation.from, to: rect.relation.to },
|
||||
evidence: { labelRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, bandTitle: title },
|
||||
supportedFixes: ['move the transition label with labelAt/labelDx/labelDy/labelSegment while preserving its text'],
|
||||
});
|
||||
problems.push(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const rect of labelRects) {
|
||||
for (const state of states.values()) {
|
||||
if (rectsOverlap(rect, state, -2)) {
|
||||
problems.push(`Label "${rect.label}" overlaps state "${state.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, state, 'state', viewBox, states.values())}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < labelRects.length; i += 1) {
|
||||
for (let j = i + 1; j < labelRects.length; j += 1) {
|
||||
if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
|
||||
problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — adjust labelDx/labelDy.\n${suggestLabelPairFix(labelRects[i], labelRects[j])}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
problems.push(...cleanLabelRouteClearanceProblems({
|
||||
relations: lifecycle.transitions,
|
||||
labels: labelRects,
|
||||
endpointIds: new Set(states.keys()),
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
profile: lifecycle.meta?.quality_profile,
|
||||
}));
|
||||
problems.push(...cleanLabelCanvasContainmentProblems({
|
||||
labels: labelRects,
|
||||
viewBox,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
profile: lifecycle.meta?.quality_profile,
|
||||
}));
|
||||
|
||||
if (problems.length) {
|
||||
throwDiagnosticProblems('Lifecycle layout validation failed', problems, {
|
||||
subject: { diagramType: 'lifecycle' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function routeVia(transition, from, to, start, end, fromSide, toSide) {
|
||||
if (transition.via) return transition.via;
|
||||
switch (transition.route || 'auto') {
|
||||
case 'straight':
|
||||
return [];
|
||||
case 'drop': {
|
||||
const y = transition.channelY ?? (start[1] + end[1]) / 2;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
case 'bottom-channel': {
|
||||
const y = transition.channelY ?? Math.max(from.y + from.height, to.y + to.height) + 34;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
case 'top-channel': {
|
||||
const y = transition.channelY ?? Math.min(from.y, to.y) - 28;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
case 'right-channel': {
|
||||
const x = transition.channelX ?? Math.max(from.x + from.width, to.x + to.width) + 36;
|
||||
return [[x, start[1]], [x, end[1]]];
|
||||
}
|
||||
case 'left-channel': {
|
||||
const x = transition.channelX ?? Math.min(from.x, to.x) - 36;
|
||||
return [[x, start[1]], [x, end[1]]];
|
||||
}
|
||||
case 'auto':
|
||||
default: {
|
||||
if (start[0] === end[0] || start[1] === end[1]) return [];
|
||||
const fromVertical = fromSide === 'top' || fromSide === 'bottom';
|
||||
const toVertical = toSide === 'top' || toSide === 'bottom';
|
||||
if (fromVertical !== toVertical) {
|
||||
return [fromVertical ? [start[0], end[1]] : [end[0], start[1]]];
|
||||
}
|
||||
if (fromVertical) {
|
||||
const y = transition.channelY ?? (start[1] + end[1]) / 2;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
const x = transition.channelX ?? (start[0] + end[0]) / 2;
|
||||
return [[x, start[1]], [x, end[1]]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pathCache = new Map();
|
||||
|
||||
// A transition without via, channel, or a lifecycle route preset is routed by
|
||||
// the obstacle-aware planner shared with architecture, so a first draft that
|
||||
// leaves routing to the renderer does not cross unrelated states or produce
|
||||
// micro jogs. Authored via/route/channel geometry keeps the lifecycle presets.
|
||||
function plannerRouted(transition) {
|
||||
return !transition.via
|
||||
&& (!transition.route || transition.route === 'auto')
|
||||
&& transition.channelX === undefined
|
||||
&& transition.channelY === undefined;
|
||||
}
|
||||
|
||||
// v2 states sit on a fixed row/column grid, so automatic transitions use the
|
||||
// dedicated orthogonal grid router; v1 keeps the shared obstacle planner.
|
||||
const planner = useGridRouter ? createLifecycleGridRouter(states, plannedTransitions, {
|
||||
rowOf: v2RowOf,
|
||||
columnXs: v2ColumnCenters,
|
||||
}) : createRouter(states, plannedTransitions, {
|
||||
labelRectFor: (transition, points, { routes, labels }) => ((transition.label || transition.note) ? reservedLabelRect({
|
||||
label: { relation: transition, label: transition.label || transition.note, ...transitionLabelBoxAt(transition, labelPoint(transition, points)) },
|
||||
points,
|
||||
routes: routes.map((route, index) => ({ relationIndex: index, points: route })),
|
||||
labels,
|
||||
components: [...states.values()],
|
||||
viewBox,
|
||||
placementBottom: lifecycleAreaBottom(),
|
||||
}) : null),
|
||||
});
|
||||
|
||||
function transitionSides(transition) {
|
||||
if (plannerRouted(transition)) return planner.connectionSides(transition);
|
||||
const from = states.get(transition.from);
|
||||
const to = states.get(transition.to);
|
||||
return {
|
||||
fromSide: chosenSide(transition.fromSide, defaultFromSide(from, to)),
|
||||
toSide: chosenSide(transition.toSide, defaultToSide(from, to)),
|
||||
};
|
||||
}
|
||||
|
||||
const automaticPorts = automaticPortSpread(
|
||||
asArray(lifecycle.transitions).filter((transition) => !plannerRouted(transition)),
|
||||
states,
|
||||
{ sideFor: (transition, endpoint) => transitionSides(transition)[endpoint === 'source' ? 'fromSide' : 'toSide'] },
|
||||
);
|
||||
|
||||
function pathFor(transition) {
|
||||
if (pathCache.has(transition)) return pathCache.get(transition);
|
||||
if (plannerRouted(transition)) {
|
||||
let routed = planner.pathFor(transition);
|
||||
if (useGridRouter) routed = { d: roundedPath(routed, transition.cornerRadius ?? 10), points: routed };
|
||||
pathCache.set(transition, routed);
|
||||
return routed;
|
||||
}
|
||||
const from = states.get(transition.from);
|
||||
const to = states.get(transition.to);
|
||||
const ports = automaticPorts.get(transition);
|
||||
const { fromSide, toSide } = transitionSides(transition);
|
||||
const start = ports?.from || anchor(from, fromSide);
|
||||
const end = ports?.to || anchor(to, toSide);
|
||||
let via = routeVia(transition, from, to, start, end, fromSide, toSide);
|
||||
if (ports && !via.length && Math.abs(start[0] - end[0]) >= 4 && Math.abs(start[1] - end[1]) >= 4) {
|
||||
const midX = (start[0] + end[0]) / 2;
|
||||
via = [[midX, start[1]], [midX, end[1]]];
|
||||
}
|
||||
const points = [start, ...via, end];
|
||||
const routed = {
|
||||
d: roundedPath(points, transition.cornerRadius ?? 10),
|
||||
points
|
||||
};
|
||||
pathCache.set(transition, routed);
|
||||
return routed;
|
||||
}
|
||||
|
||||
const resolvedLabelPoints = new Map();
|
||||
|
||||
function transitionLabelBox(transition) {
|
||||
return transitionLabelBoxAt(
|
||||
transition,
|
||||
resolvedLabelPoints.get(transition) || labelPoint(transition, pathFor(transition).points),
|
||||
);
|
||||
}
|
||||
|
||||
function transitionLabelBoxAt(transition, [lx, ly]) {
|
||||
const width = transitionLabelWidth(transition);
|
||||
const height = transition.label && transition.note ? 27 : 16;
|
||||
return { x: lx - width / 2, y: ly - 11, width, height, lx, ly };
|
||||
}
|
||||
|
||||
function transitionLabelRects() {
|
||||
const rects = [];
|
||||
for (const [relationIndex, transition] of asArray(lifecycle.transitions).entries()) {
|
||||
if (!(transition.label || transition.note) || !states.has(transition.from) || !states.has(transition.to)) continue;
|
||||
rects.push({ relation: transition, relationIndex, label: transition.label || transition.note, ...transitionLabelBox(transition) });
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
// Showcase drafts leave label positions to the renderer too: move an unpinned
|
||||
// label off other routes and states instead of reporting a clearance defect.
|
||||
if (lifecycle.meta?.quality_profile === 'showcase') {
|
||||
const placed = placeAutomaticLabels({
|
||||
labels: transitionLabelRects(),
|
||||
routes: asArray(lifecycle.transitions).flatMap((transition, relationIndex) => (
|
||||
states.has(transition.from) && states.has(transition.to)
|
||||
? [{ relationIndex, points: pathFor(transition).points }] : []
|
||||
)),
|
||||
components: [...states.values()],
|
||||
titles: bandGeometry(),
|
||||
viewBox,
|
||||
placementBottom: lifecycleAreaBottom(),
|
||||
keepFallbackNearRoute: isV2,
|
||||
gridSweep: isV2,
|
||||
});
|
||||
for (const rect of placed) resolvedLabelPoints.set(rect.relation, [rect.lx, rect.ly]);
|
||||
}
|
||||
|
||||
function bandGeometry() {
|
||||
if (isV2) {
|
||||
// One row header per rendered row, set vertically in the left gutter so
|
||||
// routes entering a row never run through its title.
|
||||
return laneRowOrder().map((laneId, index) => {
|
||||
const cy = v2RowTop.get(laneId) + layoutV2.stateH / 2;
|
||||
const label = laneLabels.get(laneId) || laneId;
|
||||
const length = textUnits(label) * 6.2;
|
||||
return { index, label, vertical: true, cx: 18, cy, x: 11, y: cy - length / 2, width: 14, height: length };
|
||||
});
|
||||
}
|
||||
const lanes = asArray(lifecycle.lanes);
|
||||
const mainLane = lanes.find((lane) => lane.id === 'main');
|
||||
const terminalLane = lanes.find((lane) => lane.id === 'terminal');
|
||||
const eventLanes = lanes.filter((lane) => lane.id !== 'main' && lane.id !== 'terminal');
|
||||
const populated = new Set([...states.values()].map((state) => bandFor(state.lane)));
|
||||
return [
|
||||
mainLane?.label || 'Lifecycle phases',
|
||||
eventLanes.length ? eventLanes.map((lane) => lane.label).join(' + ') : 'Interruptions + recovery',
|
||||
terminalLane?.label || 'Outcomes'
|
||||
].map((title, index) => {
|
||||
const baseline = [100, 252, 424][index];
|
||||
const label = `${String(index + 1).padStart(2, '0')} / ${title}`;
|
||||
return { index, band: ['phase', 'event', 'outcome'][index], label, x: 72, y: baseline - 11, width: textUnits(label) * 6.2, height: 14, baseline };
|
||||
}).filter((band) => populated.has(band.band));
|
||||
}
|
||||
|
||||
function renderBands() {
|
||||
return bandGeometry().map((band) => (band.vertical
|
||||
? ` <text x="${band.cx}" y="${band.cy}" class="t-dim" font-size="10" font-weight="600" writing-mode="vertical-rl" text-anchor="middle">${esc(band.label)}</text>`
|
||||
: ` <path d="M ${band.x} ${band.baseline + 12} L ${viewBox[0] - band.x} ${band.baseline + 12}" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
|
||||
<text x="${band.x}" y="${band.baseline}" class="t-dim" font-size="10" font-weight="600">${esc(band.label)}</text>`)).join('\n');
|
||||
}
|
||||
|
||||
function renderState(state) {
|
||||
const fill = typeClass[state.type] || typeClass.neutral;
|
||||
const accent = textClass[state.type] || 't-muted';
|
||||
const hasSub = state.sublabel != null && state.sublabel !== '';
|
||||
const { label: labelFontSize, sublabel: sublabelFontSize, tag: tagFontSize } = stateFontSizes(state, state.width);
|
||||
const textRows = [{ text: state.label, font: labelFontSize, y: isV2 ? 23 : 21 }];
|
||||
if (hasSub) textRows.push({ text: state.sublabel, font: sublabelFontSize, y: isV2 ? 40 : 37 });
|
||||
if (state.tag) textRows.push({ text: state.tag, font: tagFontSize, y: state.height - (isV2 ? 12 : 11) });
|
||||
const hasBrand = Boolean(brandMarkFor(state));
|
||||
const hasSource = Boolean(sourceEvidence?.nodes?.[state.id]?.length);
|
||||
const labelLayout = nodeLabelLayout({ width: state.width, height: state.height, rows: textRows,
|
||||
brand: hasBrand, source: hasSource, side: 'left', step: state.step });
|
||||
const sub = hasSub
|
||||
? `\n <text data-detail="context" x="${state.cx}" y="${state.y + labelLayout.ys[1]}" class="t-muted" font-size="${sublabelFontSize}" text-anchor="middle">${esc(state.sublabel)}</text>`
|
||||
: '';
|
||||
const tag = state.tag
|
||||
? `\n <text data-detail="fine" x="${state.cx}" y="${state.y + labelLayout.ys[hasSub ? 2 : 1]}" class="${accent}" font-size="${tagFontSize}" text-anchor="middle">${esc(state.tag)}</text>`
|
||||
: '';
|
||||
const step = state.step
|
||||
? `\n <text data-detail="fine" x="${state.x + 23}" y="${state.y + 14}" class="${accent}" font-size="${stateTextFit.step}" font-weight="700">${esc(state.step)}</text>`
|
||||
: '';
|
||||
const brand = renderBrandMark(state, { x: state.x + state.width - 22, y: state.y + 6 });
|
||||
// UML pseudo-state markers: start states get an initial dot + arrow into
|
||||
// the left border; states with no authored outgoing transition get a double
|
||||
// border. Both are decorations, not focus/relationship edges.
|
||||
const initialMarker = state.type === 'start'
|
||||
? `\n <g aria-hidden="true" data-lifecycle-initial-marker="">
|
||||
${initialMarkerShape(state.x - 22, state.x - 1, state.cy)}
|
||||
</g>`
|
||||
: '';
|
||||
const finalBorder = isFinal(state)
|
||||
? `\n <rect x="${state.x + 3}" y="${state.y + 3}" width="${state.width - 6}" height="${state.height - 6}" rx="4" class="${fill}" style="fill: none" stroke-width="1"/>`
|
||||
: '';
|
||||
const passport = {
|
||||
kind: state.type,
|
||||
sublabel: state.sublabel,
|
||||
tag: state.tag,
|
||||
context: laneLabels.get(state.lane) || i18nText(lifecycle.meta.locale, 'node.context.lifecycle'),
|
||||
...brandMetadataFor(state),
|
||||
};
|
||||
return ` <g ${focusNodeAttrs(state.id, state.label, passport, lifecycle.meta.locale)}>
|
||||
${focusNodeTitle(state.label, passport)}
|
||||
<rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="7" class="c-mask"/>
|
||||
<rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="7" class="${fill}"${animateAttr(lifecycle.meta, 'node', stateSteps.get(state.id))} stroke-width="1.5"/>${finalBorder}${initialMarker}
|
||||
${renderSemanticSigil(state.type, { icon: state.icon, x: state.x + 6, y: state.y + labelLayout.sigilY, size: labelLayout.sigilSize })}${brand ? `\n ${brand}` : ''}${step}
|
||||
<text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${state.x + labelLayout.x}" y="${state.y + labelLayout.ys[0]}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(state.label)}</text>${sub}${tag}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
// v2 replaces the implied phase rail with explicit topology: a forward
|
||||
// transition between two main-lane states renders as the emphasized primary
|
||||
// path unless the author chose another variant.
|
||||
function effectiveVariant(transition) {
|
||||
if (transition.variant) return transition.variant;
|
||||
if (isV2) {
|
||||
const from = states.get(transition.from);
|
||||
const to = states.get(transition.to);
|
||||
if (from?.lane === 'main' && to?.lane === 'main' && to.col > from.col) return 'emphasis';
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function renderTransitionPath(transition, index) {
|
||||
const variant = effectiveVariant(transition);
|
||||
const [cls, marker] = arrowClassMap[variant] || arrowClassMap.default;
|
||||
const routed = pathFor(transition);
|
||||
const strokeWidth = transition.width || (variant === 'emphasis' ? (isV2 ? 1.6 : 2) : 1.1);
|
||||
const automaticRoute = plannerRouted(transition);
|
||||
const crossover = automaticRoute ? ' data-composition-crossover="halo"' : '';
|
||||
const edge = ` <path ${focusEdgeAttrs(transition.from, transition.to, transition.label || transition.note, index, transition.id)} data-composition-points="${routePointsValue(routed.points)}"${crossover}${authoredStraightRouteAttrs(transition, routed.points)} d="${routed.d}" class="${cls}"${animateAttr(lifecycle.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
|
||||
if (!automaticRoute) return edge;
|
||||
// Same presentation-only wrapper as architecture: the mask underlay lets two
|
||||
// planner routes cross legibly while the viewer still sees one semantic edge.
|
||||
const underlay = ` <path data-graph-role="automatic-crossover-underlay" d="${routed.d}" fill="none" stroke="var(--mask)" stroke-width="${strokeWidth + 4}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"/>\n`;
|
||||
return ` <g data-graph-role="automatic-crossover" style="--step:${index}">\n${underlay}${edge.replace(/^ /, ' ')}\n </g>`;
|
||||
}
|
||||
|
||||
function renderTransitionLabel(transition, index) {
|
||||
if (!(transition.label || transition.note)) return '';
|
||||
const { lx, ly, width: labelW, height: labelH } = transitionLabelBox(transition);
|
||||
const label = transition.label
|
||||
? `\n <text x="${lx}" y="${ly}" class="${edgeLabelAccent(effectiveVariant(transition))}" font-size="8" text-anchor="middle">${esc(transition.label)}</text>`
|
||||
: '';
|
||||
const note = transition.note
|
||||
? `\n <text data-detail="fine" x="${lx}" y="${ly + (transition.label ? 11 : 0)}" class="t-dim" font-size="7" text-anchor="middle">${esc(transition.note)}</text>`
|
||||
: '';
|
||||
return ` <g data-detail="${transition.label ? 'context' : 'fine'}" ${focusEdgeAttrs(transition.from, transition.to, transition.label || transition.note, index, transition.id)}>
|
||||
<rect x="${lx - labelW / 2}" y="${ly - 11}" width="${labelW}" height="${labelH}" rx="4" class="c-mask"/>${label}${note}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
// UML initial pseudo-state: a filled dot and a short arrow ending at `tipX`.
|
||||
function initialMarkerShape(dotX, tipX, y) {
|
||||
return `<circle cx="${dotX}" cy="${y}" r="4.5" style="fill: var(--arrow-emphasis)"/><path d="M ${dotX + 4.5} ${y} L ${tipX - 6} ${y}" class="a-emphasis" stroke-width="1.4"/><path d="M ${tipX - 7} ${y - 3.5} L ${tipX} ${y} L ${tipX - 7} ${y + 3.5} Z" style="fill: var(--arrow-emphasis)"/>`;
|
||||
}
|
||||
|
||||
function renderSwatch(entry) {
|
||||
// `start` is structural, not a color: its swatch is the same initial
|
||||
// pseudo-state marker drawn on the canvas. `final` is a non-interactive
|
||||
// structural entry (a double-border rect) appended when a final state exists.
|
||||
if (entry.kind === 'start') {
|
||||
return initialMarkerShape(entry.x + 4.5, entry.x + 24, entry.baseline - 3.5);
|
||||
}
|
||||
if (entry.kind === 'final') {
|
||||
return `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="c-external" stroke-width="1"/><rect x="${entry.x + 2.5}" y="${entry.baseline - 5.5}" width="9" height="4" rx="1" class="c-external" style="fill: none" stroke-width="0.8"/>`;
|
||||
}
|
||||
return `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="${typeClass[entry.kind] || 'c-external'}" stroke-width="1"/>`;
|
||||
}
|
||||
|
||||
function renderLegend() {
|
||||
return renderResolvedLegend({
|
||||
entries: resolvedLegendEntries,
|
||||
locale: lifecycle.meta.locale,
|
||||
layout: {
|
||||
x: 40,
|
||||
baselineY: legendY(),
|
||||
width: viewBox[0] - 80,
|
||||
minTitleY: lifecycleAreaBottom() + 8,
|
||||
unfit: lifecycle.meta?.legend === undefined ? 'hide' : 'error',
|
||||
diagramType: 'lifecycle',
|
||||
},
|
||||
renderSwatch,
|
||||
});
|
||||
}
|
||||
|
||||
// v1 only: the implied emphasis line behind main states. v2 topology is fully
|
||||
// explicit, so the rail would double the authored forward transitions.
|
||||
function renderLifecycleRail() {
|
||||
if (isV2) return '';
|
||||
const mainCols = [...states.values()]
|
||||
.filter((state) => bandFor(state.lane) === 'phase')
|
||||
.map((state) => state.col);
|
||||
if (!mainCols.length) return '';
|
||||
const railEnd = layout.phaseXs[mainCols.reduce((max, col) => Math.max(max, col))] + 38;
|
||||
return ` <path data-lifecycle-rail="" d="M 154 ${layout.phaseY + 31} L ${railEnd} ${layout.phaseY + 31}" class="a-emphasis" stroke-width="2.2" marker-end="url(#arrowhead-emphasis)"/>`;
|
||||
}
|
||||
|
||||
function renderSvg() {
|
||||
// A renderer-sized canvas declares the intrinsic-height fit exactly like
|
||||
// architecture: the default 980x660 band layout is below the 1.55 wide
|
||||
// ratio, so without this the desktop Reader could neither narrow it nor
|
||||
// scroll it and every default lifecycle failed the browser gate.
|
||||
const readerFit = lifecycle.meta?.viewBox ? '' : ' data-reader-fit="intrinsic-height"';
|
||||
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}"${readerFit} ${svgRootAttrs(lifecycle.meta)}>
|
||||
${svgAccessibleText(lifecycle.meta, 'lifecycle')}
|
||||
${renderDefinitions()}
|
||||
|
||||
<!-- Background Grid -->
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
<!-- Lifecycle bands -->
|
||||
${renderBands()}
|
||||
|
||||
<!-- Primary lifecycle rail -->
|
||||
${renderLifecycleRail()}
|
||||
|
||||
<!-- Transition paths -->
|
||||
${asArray(lifecycle.transitions).map(renderTransitionPath).join('\n')}
|
||||
|
||||
<!-- States -->
|
||||
${[...states.values()].map(renderState).join('\n\n')}
|
||||
|
||||
<!-- Transition labels -->
|
||||
${asArray(lifecycle.transitions).map(renderTransitionLabel).join('\n')}
|
||||
|
||||
<!-- Legend -->
|
||||
${renderLegend()}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
validateLifecycle();
|
||||
writeDiagram({
|
||||
outPath,
|
||||
template,
|
||||
diagramType: 'lifecycle',
|
||||
meta: lifecycle.meta,
|
||||
svg: renderSvg(),
|
||||
cards: lifecycle.cards,
|
||||
sourceEvidence,
|
||||
});
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# Sequence Renderer
|
||||
|
||||
Render `diagram_type: "sequence"` JSON files into the standard Archify HTML
|
||||
template.
|
||||
|
||||
```bash
|
||||
node archify/renderers/sequence/render-sequence.mjs input.sequence.json output.html
|
||||
```
|
||||
|
||||
The renderer validates input against `archify/schemas/sequence.schema.json`
|
||||
with the bundled standalone validator. No dependency installation is required.
|
||||
|
||||
If `output.html` is omitted, the renderer uses the required `meta.output` value
|
||||
from the JSON file.
|
||||
|
||||
## Input
|
||||
|
||||
Sequence JSON files must set:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "sequence",
|
||||
"meta": {
|
||||
"title": "Cache Miss Request Sequence",
|
||||
"output": "cache-miss-request.html",
|
||||
"viewBox": [920, 760]
|
||||
},
|
||||
"participants": [],
|
||||
"segments": [],
|
||||
"messages": [],
|
||||
"activations": [],
|
||||
"cards": []
|
||||
}
|
||||
```
|
||||
|
||||
The timeline scales with the viewBox height: a taller `meta.viewBox` buys more
|
||||
message room, a shorter one shrinks the readable band instead of clipping. A
|
||||
complete worked example lives at
|
||||
`archify/examples/cache-miss-request.sequence.json`.
|
||||
|
||||
The schema lives at:
|
||||
|
||||
```text
|
||||
archify/schemas/sequence.schema.json
|
||||
```
|
||||
|
||||
## Legend
|
||||
|
||||
The default visual legend derives kinds from `messages[].variant` (omitting
|
||||
`variant` means `default`). Supported `meta.legend.entries` keys, in stable
|
||||
order, are `emphasis`, `return`, `security`, `dashed`, and `default`. These are
|
||||
visual message keys, not Semantic Lens controls; label/visibility overrides do
|
||||
not create edge facts.
|
||||
|
||||
The legend sits below all timeline content: the last message and its note,
|
||||
activation bars, and segment frames, with a 12px gap. Without `meta.viewBox`
|
||||
the canvas grows to keep that gap. With an authored `viewBox` that is too short,
|
||||
`showcase` fails with the exact height to set, and `standard` hides the implicit
|
||||
legend rather than drawing it over content. Lifelines stop above the legend.
|
||||
Message labels use their line's color; gray default and return lines keep the
|
||||
muted text color.
|
||||
|
||||
## Layout budget
|
||||
|
||||
| Constant | Value |
|
||||
|----------|-------|
|
||||
| viewBox | default `[920, 760]`, taller when late content needs legend room; schema minimum `[480, 480]` |
|
||||
| Participant boxes | `fixed` (default): 86×54 at y 72; `spread`: viewBox-relative width from 86px up to 190px |
|
||||
| Participant columns | `fixed`: centers at x = 62 + index×108; `spread`: columns distribute across the available viewBox width |
|
||||
| Participant count | the last box must end at or before width − 40; layouts that cannot fit fail closed |
|
||||
| Lifelines | from y 142 down to height − 65 (drawn to just above the legend); band must be ≥120px tall |
|
||||
| Message `y` range | `[160, height − 83]` |
|
||||
| Message spacing | ≥28px vertical between messages that share horizontal space |
|
||||
| Arrow span | ≥60px horizontal between the two participants |
|
||||
| Segments | y pixel ranges with `to > from`, inside `[72, lifeline bottom + 20]` |
|
||||
| Legend | last row baseline at height − 54; extra rows wrap upward and stay 12px below the timeline content |
|
||||
|
||||
`segments[].from/to` and `activations[].from/to` are y pixel coordinates, not
|
||||
participant ids; activations also require `to > from`.
|
||||
|
||||
### Column fit
|
||||
|
||||
Sequence diagrams use `meta.column_fit: "fixed"` by default so existing
|
||||
documents keep their historical coordinates. Use `"spread"` when a wide
|
||||
viewBox would otherwise leave empty space on the right or when meaningful
|
||||
participant labels do not fit the fixed 86px boxes. Spread derives box width
|
||||
and column distance from the viewBox while preserving participant order,
|
||||
lifelines, and message semantics.
|
||||
|
||||
The artifact checker reports `composition.sequenceColumnSpace` from the rendered
|
||||
participants, routes and text. A large unused right-hand region in a fixed layout
|
||||
can produce an `inspect-sequence-width` recommendation in `finalize`; it is advice,
|
||||
not a new warning or failure. See [Sequence width review](../../references/delivery-contract.md#sequence-width-review)
|
||||
for the bounded authoring repair and explicit-fixed/legacy preservation rules.
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Put participants across the top, ordered by the story the reader should
|
||||
follow.
|
||||
- Time moves downward.
|
||||
- Use `emphasis` for the main request path.
|
||||
- Use `security` for auth, consent, permission, and policy calls.
|
||||
- Use `return` for quiet response messages.
|
||||
- Use `dashed` for async trace, event, logging, and non-blocking work.
|
||||
- Use segments as light background guides; keep segment labels short.
|
||||
- Keep labels concise, but try `meta.column_fit: "spread"` before shortening a
|
||||
meaningful participant label just to fit the fixed boxes.
|
||||
|
||||
Schema violations exit non-zero with path-prefixed messages annotated with the
|
||||
element's id or label. The renderer additionally fails when it can detect
|
||||
layout problems, including missing participants, duplicate participant IDs,
|
||||
participant labels wider than their box, unknown message endpoints, messages
|
||||
outside the readable timeline, overly tight vertical spacing between messages
|
||||
that overlap horizontally, invalid segment or activation ranges, or
|
||||
participants that exceed the viewBox. The shared Clean Flow contract treats
|
||||
participant headers as semantic boxes while explicitly allowing messages to
|
||||
cross intermediate lifelines, activation bars, and segment frames. Text width is estimated CJK-aware:
|
||||
fullwidth glyphs count as two units.
|
||||
|
||||
Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
|
||||
message X crossings then fail with `composition/proper-crossing`; default
|
||||
`standard` keeps them as artifact-receipt warnings. Messages may still cross
|
||||
intermediate lifelines. Collinear corridors remain outside the proper-X rule,
|
||||
but a separate gate warns in `standard` and fails in `showcase` when unrelated
|
||||
messages overlap for at least 8px. Shared semantic endpoints, point touches,
|
||||
and shorter overlaps remain valid. Showcase also rejects any route segment
|
||||
below 8px and any interior turn segment below 16px; ordinary 8–15px endpoint
|
||||
stubs remain valid.
|
||||
@@ -0,0 +1,526 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
|
||||
import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
|
||||
import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
|
||||
import { legendFootprint, measureLegend, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
|
||||
import { componentFill, arrowClassMap, rectsOverlap, cleanFlowProblems, cleanCrossingProblems, cleanAmbiguousCorridorProblems, cleanBorderRunProblems, cleanRouteRhythmProblems, cleanLabelRouteClearanceProblems, cleanLabelCanvasContainmentProblems, routePointsValue, asArray, isFinitePoint, edgeLabelAccent } from '../shared/geometry.mjs';
|
||||
import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
|
||||
import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
|
||||
import { translateMessage as i18nText } from '../shared/i18n.mjs';
|
||||
|
||||
const participantTextFit = {
|
||||
sublabelPreferred: 7,
|
||||
sublabelMinimum: 6,
|
||||
};
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const { diagram: sequence, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
|
||||
rendererDir: __dirname,
|
||||
diagramType: 'sequence',
|
||||
defaultExample: 'cache-miss-request.sequence.json'
|
||||
});
|
||||
|
||||
const LEGEND_CATALOG = [
|
||||
{ kind: 'emphasis', className: 'a-emphasis', marker: 'arrowhead-emphasis', strokeWidth: 1.8 },
|
||||
{ kind: 'return', className: 'a-default', marker: 'arrowhead', dash: '3,5' },
|
||||
{ kind: 'security', className: 'a-security', marker: 'arrowhead-security' },
|
||||
{ kind: 'dashed', className: 'a-dashed', marker: 'arrowhead-dashed' },
|
||||
{ kind: 'default', className: 'a-default', marker: 'arrowhead' },
|
||||
].map((entry) => ({
|
||||
...entry,
|
||||
interactive: false,
|
||||
swatchWidth: 34,
|
||||
swatchGap: 9,
|
||||
label: i18nText(sequence.meta.locale, `legend.sequence.${entry.kind}`),
|
||||
}));
|
||||
|
||||
function legendEntries() {
|
||||
const presentKinds = new Set(asArray(sequence.messages).map((message) => message.variant || 'default'));
|
||||
return resolveLegend(sequence.meta?.legend, LEGEND_CATALOG, presentKinds);
|
||||
}
|
||||
|
||||
// The legend sits below the timeline content: the last message and its note,
|
||||
// activation bars, and segment frames. Its block starts LEGEND_CONTENT_GAP
|
||||
// below that content; from the block top to the canvas bottom a one-row legend
|
||||
// needs LEGEND_BLOCK_HEIGHT (title glyphs, row, and the 54px baseline inset).
|
||||
const LEGEND_CONTENT_GAP = 12;
|
||||
const LEGEND_BLOCK_HEIGHT = 86;
|
||||
const contentBottom = Math.max(
|
||||
0,
|
||||
...asArray(sequence.messages).map((message) => message.y + (message.note ? 22 : 6)),
|
||||
...asArray(sequence.activations).map((activation) => activation.to),
|
||||
...asArray(sequence.segments).map((segment) => segment.to),
|
||||
);
|
||||
function legendRequiredHeight(width) {
|
||||
const entries = legendEntries();
|
||||
if (!entries.length) return 0;
|
||||
return Math.ceil(contentBottom + LEGEND_CONTENT_GAP + LEGEND_BLOCK_HEIGHT
|
||||
+ legendFootprint(entries, { width: width - 80 }).extraHeight);
|
||||
}
|
||||
// A renderer-sized canvas grows to keep the legend clear of late messages;
|
||||
// an authored viewBox is honored and validated below.
|
||||
const viewBox = sequence.meta?.viewBox || [920, Math.max(760, legendRequiredHeight(920))];
|
||||
// The timeline scales with viewBox height: a taller viewBox gains message room,
|
||||
// a shorter one shrinks the readable band (validated below) instead of clipping.
|
||||
// `column_fit: "spread"` widens the lanes with the viewBox instead of keeping
|
||||
// the fixed 108px gap, so a wide canvas gains column distance and label room
|
||||
// rather than dead space on the right. The default stays "fixed" so existing
|
||||
// diagrams keep their coordinates.
|
||||
const columnFit = sequence.meta?.column_fit === 'spread' ? 'spread' : 'fixed';
|
||||
const participantCount = Math.max(1, asArray(sequence.participants).length);
|
||||
const sideMargin = 62;
|
||||
const participantW = columnFit === 'spread'
|
||||
? Math.max(86, Math.min(190, Math.round((viewBox[0] - sideMargin * 2) / participantCount) - 24))
|
||||
: 86;
|
||||
const colGap = columnFit === 'spread' && participantCount > 1
|
||||
? Math.max(108, (viewBox[0] - 40 - sideMargin - participantW) / (participantCount - 1))
|
||||
: 108;
|
||||
|
||||
// Showcase is the fast-authoring default; standard retains legacy label geometry.
|
||||
const readableMessages = sequence.meta?.quality_profile === 'showcase';
|
||||
const messageFontSize = readableMessages ? 11 : 9;
|
||||
const messageUnitWidth = readableMessages ? 6.6 : 5.2;
|
||||
const layout = {
|
||||
topY: 72,
|
||||
participantW,
|
||||
// Keep a separate top rail for the 11px semantic sigil and 16px brand mark.
|
||||
// Literal labels retain their fitted font size and full authored wording.
|
||||
participantH: 60,
|
||||
participantLabelY: 36,
|
||||
participantSublabelY: 50,
|
||||
lifelineTop: 142,
|
||||
lifelineBottom: viewBox[1] - 65,
|
||||
legendY: viewBox[1] - 54,
|
||||
leftX: columnFit === 'spread' ? sideMargin + participantW / 2 : sideMargin,
|
||||
colGap,
|
||||
labelH: readableMessages ? 18 : 16
|
||||
};
|
||||
|
||||
const participantBoxWidthNote = columnFit === 'spread'
|
||||
? `participant boxes are ${participantW}px for this viewBox width and ${participantCount} participants`
|
||||
: `participant boxes are a fixed ${participantW}px unless meta.column_fit is "spread"`;
|
||||
|
||||
const arrowClass = {
|
||||
...arrowClassMap,
|
||||
return: ['a-default', 'arrowhead']
|
||||
};
|
||||
|
||||
function participantX(index) {
|
||||
return layout.leftX + index * layout.colGap;
|
||||
}
|
||||
|
||||
const participants = new Map(asArray(sequence.participants).map((participant, index) => [
|
||||
participant.id,
|
||||
{
|
||||
...participant,
|
||||
index,
|
||||
cx: participantX(index),
|
||||
x: participantX(index) - layout.participantW / 2,
|
||||
y: layout.topY,
|
||||
width: layout.participantW,
|
||||
height: layout.participantH,
|
||||
cy: layout.topY + layout.participantH / 2
|
||||
}
|
||||
]));
|
||||
|
||||
function messageGeometry(message) {
|
||||
const from = participants.get(message.from);
|
||||
const to = participants.get(message.to);
|
||||
if (!from || !to || typeof message.y !== 'number') return null;
|
||||
const direction = to.cx > from.cx ? 1 : -1;
|
||||
const start = from.cx + direction * 7;
|
||||
const end = to.cx - direction * 7;
|
||||
return { start, end, center: (start + end) / 2 };
|
||||
}
|
||||
|
||||
function messageLabelBox(message, relationIndex = null) {
|
||||
const geometry = messageGeometry(message);
|
||||
if (!geometry) return null;
|
||||
const width = Math.max(34, textUnits(message.label) * messageUnitWidth + 12);
|
||||
return {
|
||||
relation: message,
|
||||
relationIndex,
|
||||
label: message.label,
|
||||
x: geometry.center - width / 2,
|
||||
y: message.y - 20,
|
||||
width,
|
||||
height: layout.labelH,
|
||||
};
|
||||
}
|
||||
|
||||
function messageRouteBox(message) {
|
||||
const geometry = messageGeometry(message);
|
||||
if (!geometry) return null;
|
||||
return {
|
||||
x: Math.min(geometry.start, geometry.end),
|
||||
y: message.y - 2,
|
||||
width: Math.abs(geometry.end - geometry.start),
|
||||
height: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function segmentLabelBox(segment) {
|
||||
const labelW = Math.max(42, textUnits(segment.label) * 5.2 + 14);
|
||||
const occupied = asArray(sequence.messages)
|
||||
.flatMap((message) => [messageLabelBox(message), messageRouteBox(message)])
|
||||
.filter(Boolean);
|
||||
const label = { x: 56, y: segment.from - 22, width: labelW, height: 18 };
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
if (!occupied.some((rect) => rectsOverlap(label, rect, 2))) break;
|
||||
label.y -= 22;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
const compositionFrames = asArray(sequence.segments).map((segment, index) => ({
|
||||
id: index,
|
||||
label: segment.label,
|
||||
kind: 'segment',
|
||||
x: 48,
|
||||
y: segment.from,
|
||||
width: viewBox[0] - 96,
|
||||
height: segment.to - segment.from,
|
||||
radius: 10,
|
||||
}));
|
||||
|
||||
function messagePath(message) {
|
||||
return {
|
||||
points: participants.has(message.from) && participants.has(message.to)
|
||||
? [[participants.get(message.from).cx, message.y], [participants.get(message.to).cx, message.y]]
|
||||
: []
|
||||
};
|
||||
}
|
||||
|
||||
function validateSequence() {
|
||||
const problems = [];
|
||||
if (participants.size !== asArray(sequence.participants).length) problems.push('Participant ids must be unique.');
|
||||
|
||||
if (layout.lifelineBottom - layout.lifelineTop < 120) {
|
||||
problems.push(`viewBox height ${viewBox[1]} leaves under 120px of timeline — set meta.viewBox[1] to at least ${layout.lifelineTop + 120 + 65}.`);
|
||||
}
|
||||
|
||||
for (const participant of participants.values()) {
|
||||
const estLabelW = textUnits(participant.label) * 6.8;
|
||||
if (estLabelW > layout.participantW + 6) {
|
||||
problems.push(`Label "${participant.label}" (~${Math.round(estLabelW)}px) is wider than the ${layout.participantW}px participant box — shorten it.`);
|
||||
}
|
||||
const brandRailProblem = brandTopRailProblem(participant, layout.participantW, 8, 'Participant');
|
||||
if (brandRailProblem) problems.push(brandRailProblem);
|
||||
// sublabel renders as a single unwrapped <text>; shrink-to-fit handles the
|
||||
// ordinary case, this rejects what it cannot rescue.
|
||||
if (participant.sublabel) {
|
||||
const availableTextW = availableNodeTextWidth(layout.participantW);
|
||||
const minimumW = minimumNodeTextWidth(participant.sublabel, participantTextFit.sublabelMinimum);
|
||||
if (minimumW > availableTextW) {
|
||||
problems.push(`Sublabel "${participant.sublabel}" needs ~${Math.ceil(minimumW)}px at the ${participantTextFit.sublabelMinimum}px legible minimum, but participant "${participant.id}" provides ${availableTextW}px — shorten the sublabel (${participantBoxWidthNote}).`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const message of asArray(sequence.messages)) {
|
||||
if (!participants.has(message.from)) problems.push(`Message "${message.label}" references unknown source "${message.from}".`);
|
||||
if (!participants.has(message.to)) problems.push(`Message "${message.label}" references unknown target "${message.to}".`);
|
||||
if (typeof message.y !== 'number') problems.push(`Message "${message.label}" must provide a numeric y.`);
|
||||
if (message.y < layout.lifelineTop + 18 || message.y > layout.lifelineBottom - 18) {
|
||||
problems.push(`Message "${message.label}" sits outside the readable timeline — keep y between ${layout.lifelineTop + 18} and ${layout.lifelineBottom - 18}.`);
|
||||
}
|
||||
if (participants.has(message.from) && participants.has(message.to)) {
|
||||
const distance = Math.abs(participants.get(message.to).cx - participants.get(message.from).cx);
|
||||
if (distance < 60) problems.push(`Message "${message.label}" spans ${Math.round(distance)}px (minimum 60px) — give its participants more column distance.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Participant headers are opaque nodes. Lifelines, activation bars, and
|
||||
// segment bands remain intentional pass-through geometry and are excluded.
|
||||
problems.push(...cleanFlowProblems({
|
||||
relations: sequence.messages,
|
||||
obstacles: participants.values(),
|
||||
pathFor: messagePath,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
obstacleKind: 'participant header',
|
||||
clearance: 0,
|
||||
routeHint: 'move the message y below the participant headers or reorder participants'
|
||||
}));
|
||||
problems.push(...cleanCrossingProblems({
|
||||
relations: sequence.messages,
|
||||
endpointIds: new Set(participants.keys()),
|
||||
pathFor: messagePath,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
profile: sequence.meta?.quality_profile,
|
||||
routeHint: 'separate the message y values; lifeline crossings remain allowed'
|
||||
}));
|
||||
problems.push(...cleanAmbiguousCorridorProblems({
|
||||
relations: sequence.messages,
|
||||
endpointIds: new Set(participants.keys()),
|
||||
pathFor: messagePath,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
profile: sequence.meta?.quality_profile,
|
||||
routeHint: 'separate the message y values so unrelated messages do not visually merge'
|
||||
}));
|
||||
problems.push(...cleanBorderRunProblems({
|
||||
relations: sequence.messages,
|
||||
endpointIds: new Set(participants.keys()),
|
||||
frames: compositionFrames,
|
||||
pathFor: messagePath,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
profile: sequence.meta?.quality_profile,
|
||||
routeHint: 'move the message y so it crosses a segment boundary perpendicularly or stays clearly inside the segment'
|
||||
}));
|
||||
problems.push(...cleanRouteRhythmProblems({
|
||||
relations: sequence.messages,
|
||||
endpointIds: new Set(participants.keys()),
|
||||
pathFor: messagePath,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
profile: sequence.meta?.quality_profile,
|
||||
routeHint: 'increase participant spacing or simplify message routing so every turn has room to read'
|
||||
}));
|
||||
|
||||
// Vertical crowding only matters when the arrows share horizontal space;
|
||||
// disjoint arrows may legitimately run in parallel rows.
|
||||
const placed = asArray(sequence.messages)
|
||||
.filter((m) => participants.has(m.from) && participants.has(m.to))
|
||||
.map((m) => ({
|
||||
label: m.label,
|
||||
y: m.y,
|
||||
x1: Math.min(participants.get(m.from).cx, participants.get(m.to).cx),
|
||||
x2: Math.max(participants.get(m.from).cx, participants.get(m.to).cx)
|
||||
}))
|
||||
.sort((a, b) => a.y - b.y);
|
||||
for (let i = 0; i < placed.length; i += 1) {
|
||||
for (let j = i + 1; j < placed.length && placed[j].y - placed[i].y < 28; j += 1) {
|
||||
if (placed[i].x1 < placed[j].x2 && placed[j].x1 < placed[i].x2) {
|
||||
problems.push(`Messages "${placed[i].label}" and "${placed[j].label}" are less than 28px apart and share horizontal space — spread their y values.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Label masks can extend well past the arrow span, so check the actual
|
||||
// label rectangles too — tangent arrows with long labels still collide.
|
||||
const labelRects = asArray(sequence.messages)
|
||||
.map((m, messageIndex) => messageLabelBox(m, messageIndex))
|
||||
.filter(Boolean);
|
||||
for (let i = 0; i < labelRects.length; i += 1) {
|
||||
for (let j = i + 1; j < labelRects.length; j += 1) {
|
||||
if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
|
||||
problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — spread their message y values or shorten the labels.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
problems.push(...cleanLabelRouteClearanceProblems({
|
||||
relations: sequence.messages,
|
||||
labels: labelRects,
|
||||
endpointIds: new Set(participants.keys()),
|
||||
pathFor: messagePath,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
profile: sequence.meta?.quality_profile,
|
||||
routeHint: 'spread the message y values, shorten the label, or reorder participants so the adjacent route stays visible'
|
||||
}));
|
||||
problems.push(...cleanLabelCanvasContainmentProblems({
|
||||
labels: labelRects,
|
||||
viewBox,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
profile: sequence.meta?.quality_profile,
|
||||
routeHint: 'shorten the label, reorder participants, or enlarge meta.viewBox',
|
||||
}));
|
||||
|
||||
for (const segment of asArray(sequence.segments)) {
|
||||
if (segment.to <= segment.from) {
|
||||
problems.push(`Segment "${segment.label}" has invalid y range (from ${segment.from} to ${segment.to}) — "to" must be greater than "from".`);
|
||||
}
|
||||
if (segment.from < layout.topY || segment.to > layout.lifelineBottom + 20) {
|
||||
problems.push(`Segment "${segment.label}" extends outside the canvas — keep its y range between ${layout.topY} and ${layout.lifelineBottom + 20}.`);
|
||||
}
|
||||
const labelBox = segmentLabelBox(segment);
|
||||
const availableWidth = Math.max(0, viewBox[0] - 48 - labelBox.x);
|
||||
if (labelBox.x + labelBox.width > viewBox[0] - 48) {
|
||||
const requiredWidth = Math.ceil(labelBox.x + labelBox.width + 48);
|
||||
problems.push(`Segment "${segment.label}" label (~${Math.round(labelBox.width)}px) exceeds the segment frame's available width (${availableWidth}px) — shorten the label or increase meta.viewBox[0] to at least ${requiredWidth}.`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const activation of asArray(sequence.activations)) {
|
||||
if (!participants.has(activation.participant)) problems.push(`Activation references unknown participant "${activation.participant}".`);
|
||||
if (activation.to <= activation.from) problems.push(`Activation for "${activation.participant}" has invalid time range — "to" must be greater than "from".`);
|
||||
}
|
||||
|
||||
const lastParticipant = asArray(sequence.participants)[asArray(sequence.participants).length - 1];
|
||||
if (lastParticipant && participants.get(lastParticipant.id).cx + layout.participantW / 2 > viewBox[0] - 40) {
|
||||
const requiredWidth = Math.ceil(participants.get(lastParticipant.id).cx + layout.participantW / 2 + 40);
|
||||
problems.push(`Participants exceed viewBox width — set meta.viewBox[0] to at least ${requiredWidth} or remove a participant.`);
|
||||
}
|
||||
|
||||
// Showcase must not silently drop the implicit legend because late content
|
||||
// leaves no room for it; give the exact canvas height instead.
|
||||
const legendHeight = legendRequiredHeight(viewBox[0]);
|
||||
if (sequence.meta?.quality_profile === 'showcase' && sequence.meta?.legend === undefined
|
||||
&& legendHeight > viewBox[1] && !measureLegend(legendEntries(), legendLayout())) {
|
||||
problems.push(`Sequence content ends at y=${contentBottom}, leaving no room for the legend below it — set meta.viewBox[1] to at least ${legendHeight} or omit meta.viewBox so the canvas grows.`);
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
throwDiagnosticProblems('Sequence layout validation failed', problems, {
|
||||
subject: { diagramType: 'sequence' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderParticipant(participant) {
|
||||
const fill = componentFill[participant.type] || 'c-external';
|
||||
const hasSub = participant.sublabel != null && participant.sublabel !== '';
|
||||
const sub = hasSub
|
||||
? `\n <text data-detail="context" x="${participant.cx}" y="${layout.topY + layout.participantSublabelY}" class="t-muted" font-size="${fittedNodeFontSize(participant.sublabel, layout.participantW, participantTextFit.sublabelPreferred, participantTextFit.sublabelMinimum)}" text-anchor="middle">${esc(participant.sublabel)}</text>`
|
||||
: '';
|
||||
const brand = renderBrandMark(participant, { x: participant.x + layout.participantW - 22, y: layout.topY + 6 });
|
||||
const labelFontSize = fittedNodeFontSize(participant.label, brandLabelFitWidth(participant, layout.participantW), 11, 8);
|
||||
const passport = {
|
||||
kind: participant.type,
|
||||
sublabel: participant.sublabel,
|
||||
context: i18nText(sequence.meta.locale, 'node.context.sequence'),
|
||||
...brandMetadataFor(participant),
|
||||
};
|
||||
return ` <g ${focusNodeAttrs(participant.id, participant.label, passport, sequence.meta.locale)}>
|
||||
${focusNodeTitle(participant.label, passport)}
|
||||
<rect x="${participant.x}" y="${layout.topY}" width="${layout.participantW}" height="${layout.participantH}" rx="6" class="c-mask"/>
|
||||
<rect x="${participant.x}" y="${layout.topY}" width="${layout.participantW}" height="${layout.participantH}" rx="6" class="${fill}"${animateAttr(sequence.meta, 'node', participant.index)} stroke-width="1.5"/>
|
||||
${renderSemanticSigil(participant.type, { icon: participant.icon, x: participant.x + 6, y: layout.topY + 6 })}${brand ? `\n ${brand}` : ''}
|
||||
<text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${participant.cx}" y="${layout.topY + layout.participantLabelY}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(participant.label)}</text>${sub}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
// Lifelines never enter the legend band. The legend is placed below all
|
||||
// timeline content, so stopping above its title still reaches every message.
|
||||
function lifelineEnd() {
|
||||
const legend = measureLegend(legendEntries(), legendLayout());
|
||||
return legend?.titleY == null ? layout.lifelineBottom : Math.min(layout.lifelineBottom, legend.titleY - 22);
|
||||
}
|
||||
|
||||
function renderLifeline(participant, end) {
|
||||
return ` <path d="M ${participant.cx} ${layout.lifelineTop} L ${participant.cx} ${end}" class="a-default" stroke-width="0.8" stroke-dasharray="3,7"/>`;
|
||||
}
|
||||
|
||||
function renderSegment(segment, index) {
|
||||
return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="segment" data-composition-frame-id="${index}" x="48" y="${segment.from}" width="${viewBox[0] - 96}" height="${segment.to - segment.from}" rx="10" class="c-lane" stroke-width="1"/>`;
|
||||
}
|
||||
|
||||
function renderSegmentLabel(segment, index) {
|
||||
const label = segmentLabelBox(segment);
|
||||
return ` <g data-graph-role="segment-label" data-segment-id="${index}">
|
||||
<rect x="${label.x}" y="${label.y}" width="${label.width}" height="${label.height}" rx="3" class="c-mask"/>
|
||||
<text x="${label.x + 6}" y="${label.y + 13}" class="t-dim" font-size="9" font-weight="600">${esc(segment.label)}</text>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function renderActivation(activation) {
|
||||
const participant = participants.get(activation.participant);
|
||||
const fill = componentFill[activation.type] || componentFill[participant.type] || 'c-external';
|
||||
const x = participant.cx - 5;
|
||||
const height = activation.to - activation.from;
|
||||
return ` <rect x="${x}" y="${activation.from}" width="10" height="${height}" rx="3" class="c-mask"/>
|
||||
<rect x="${x}" y="${activation.from}" width="10" height="${height}" rx="3" class="${fill}" stroke-width="1"/>`;
|
||||
}
|
||||
|
||||
function messageLabel(message, x1, x2) {
|
||||
const box = messageLabelBox(message);
|
||||
const center = box ? box.x + box.width / 2 : (x1 + x2) / 2;
|
||||
const y = message.y - 10;
|
||||
const labelW = box?.width || Math.max(34, textUnits(message.label) * messageUnitWidth + 12);
|
||||
// A colored line gets a label in the same color, as in the legend swatches.
|
||||
// Gray lines (default and return) keep the readable muted text color.
|
||||
const accent = ['emphasis', 'security', 'dashed'].includes(message.variant) ? edgeLabelAccent(message.variant) : 't-muted';
|
||||
return ` <g data-detail="context">
|
||||
<rect x="${center - labelW / 2}" y="${y - 10}" width="${labelW}" height="${layout.labelH}" rx="3" class="c-mask"/>
|
||||
<text x="${center}" y="${y}" class="${accent}" font-size="${messageFontSize}" text-anchor="middle">${esc(message.label)}</text>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function renderMessage(message, index) {
|
||||
const { start, end } = messageGeometry(message);
|
||||
const [cls, marker] = arrowClass[message.variant || 'default'] || arrowClass.default;
|
||||
const strokeWidth = message.variant === 'emphasis' ? 1.8 : 1.4;
|
||||
const dash = message.variant === 'return' ? ' stroke-dasharray="3,5"' : '';
|
||||
const note = message.note
|
||||
? `\n <text data-detail="fine" x="${Math.min(start, end) + 12}" y="${message.y + 18}" class="t-dim" font-size="7">${esc(message.note)}</text>`
|
||||
: '';
|
||||
return ` <g ${focusEdgeAttrs(message.from, message.to, message.label, index, message.id)}>
|
||||
<path data-composition-edge-from="${esc(message.from)}" data-composition-edge-to="${esc(message.to)}"${message.id ? ` data-composition-edge-id="${esc(message.id)}"` : ''} data-composition-points="${routePointsValue([[start, message.y], [end, message.y]])}" d="M ${start} ${message.y} L ${end} ${message.y}" class="${cls}"${animateAttr(sequence.meta, 'edge', index)} stroke-width="${strokeWidth}"${dash} marker-end="url(#${marker})"/>
|
||||
${messageLabel(message, start, end)}${note}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
|
||||
function legendLayout() {
|
||||
return {
|
||||
x: 40,
|
||||
baselineY: layout.legendY,
|
||||
width: viewBox[0] - 80,
|
||||
// The same content-based budget as legendRequiredHeight(): a wrapped
|
||||
// legend may use any rows it needs as long as it stays below the content.
|
||||
minTitleY: Math.max(layout.lifelineTop, contentBottom + LEGEND_CONTENT_GAP),
|
||||
unfit: sequence.meta?.legend === undefined ? 'hide' : 'error',
|
||||
diagramType: 'sequence',
|
||||
};
|
||||
}
|
||||
|
||||
function renderLegend() {
|
||||
return renderResolvedLegend({
|
||||
entries: legendEntries(),
|
||||
locale: sequence.meta.locale,
|
||||
layout: legendLayout(),
|
||||
renderSwatch: (entry) => `<path d="M ${entry.x} ${entry.baseline - 3} L ${entry.x + 34} ${entry.baseline - 3}" class="${entry.className}" stroke-width="${entry.strokeWidth || 1.4}"${entry.dash ? ` stroke-dasharray="${entry.dash}"` : ''} marker-end="url(#${entry.marker})"/>`,
|
||||
});
|
||||
}
|
||||
|
||||
function renderSvg() {
|
||||
const participantList = [...participants.values()];
|
||||
// Same default-canvas contract as lifecycle: 920x760 is below the 1.55 wide
|
||||
// ratio, so without intrinsic-height the desktop Reader can neither narrow
|
||||
// nor scroll it and every default sequence fails the browser gate.
|
||||
const readerFit = sequence.meta?.viewBox ? '' : ' data-reader-fit="intrinsic-height"';
|
||||
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" data-sequence-column-fit="${columnFit}"${readerFit} ${svgRootAttrs(sequence.meta)}>
|
||||
${svgAccessibleText(sequence.meta, 'sequence')}
|
||||
${renderDefinitions()}
|
||||
|
||||
<!-- Background Grid -->
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
<!-- Time Segments -->
|
||||
${asArray(sequence.segments).map(renderSegment).join('\n\n')}
|
||||
|
||||
<!-- Lifelines -->
|
||||
${participantList.map((participant) => renderLifeline(participant, lifelineEnd())).join('\n')}
|
||||
|
||||
<!-- Activations -->
|
||||
${asArray(sequence.activations).map(renderActivation).join('\n')}
|
||||
|
||||
<!-- Messages -->
|
||||
${asArray(sequence.messages).map(renderMessage).join('\n\n')}
|
||||
|
||||
<!-- Segment Labels -->
|
||||
${asArray(sequence.segments).map(renderSegmentLabel).join('\n')}
|
||||
|
||||
<!-- Participants -->
|
||||
${participantList.map(renderParticipant).join('\n\n')}
|
||||
|
||||
<!-- Legend -->
|
||||
${renderLegend()}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
validateSequence();
|
||||
writeDiagram({
|
||||
outPath,
|
||||
template,
|
||||
diagramType: 'sequence',
|
||||
meta: sequence.meta,
|
||||
svg: renderSvg(),
|
||||
cards: sequence.cards,
|
||||
sourceEvidence,
|
||||
});
|
||||
+1982
File diff suppressed because it is too large
Load Diff
+670
@@ -0,0 +1,670 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import net from 'node:net';
|
||||
import { BRAND_MARKS } from './generated-brand-marks.mjs';
|
||||
import { throwDiagnosticError } from './diagnostics.mjs';
|
||||
import { esc, textUnits } from './utils.mjs';
|
||||
|
||||
const COLLECTIONS = Object.freeze({
|
||||
architecture: 'components',
|
||||
workflow: 'nodes',
|
||||
sequence: 'participants',
|
||||
dataflow: 'nodes',
|
||||
lifecycle: 'states',
|
||||
});
|
||||
const MARK_BY_LOOKUP = new Map();
|
||||
const MARK_BY_DOMAIN = new Map();
|
||||
const RESOLVED_BY_NODE = new WeakMap();
|
||||
const RESOLVED_MARK = Symbol('archify.brandMark');
|
||||
const MAX_HTML_BYTES = 256 * 1024;
|
||||
const MAX_IMAGE_BYTES = 1024 * 1024;
|
||||
const MAX_CAPTURE_CONCURRENCY = 3;
|
||||
const DEFAULT_CAPTURE_TIMEOUT_MS = 8000;
|
||||
const USER_AGENT = 'Archify/2.15 brand-preview';
|
||||
|
||||
function lookupForms(value) {
|
||||
const raw = String(value ?? '').trim().toLocaleLowerCase('en-US');
|
||||
if (!raw) return [];
|
||||
const dashed = raw.replace(/[\s_]+/g, '-');
|
||||
const compact = raw.replace(/[\s_.-]+/g, '');
|
||||
return [...new Set([raw, dashed, compact])];
|
||||
}
|
||||
|
||||
for (const mark of BRAND_MARKS) {
|
||||
for (const value of [mark.id, mark.title, ...mark.aliases]) {
|
||||
for (const form of lookupForms(value)) {
|
||||
if (!MARK_BY_LOOKUP.has(form)) MARK_BY_LOOKUP.set(form, mark);
|
||||
}
|
||||
}
|
||||
for (const domain of mark.domains) MARK_BY_DOMAIN.set(domain, mark);
|
||||
}
|
||||
|
||||
function asUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value));
|
||||
return ['https:', 'http:'].includes(url.protocol) ? url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function domainMark(hostname) {
|
||||
const host = hostname.toLocaleLowerCase('en-US').replace(/\.$/, '');
|
||||
const candidates = [...MARK_BY_DOMAIN.entries()]
|
||||
.filter(([domain]) => host === domain || host.endsWith(`.${domain}`))
|
||||
.sort(([left], [right]) => right.length - left.length);
|
||||
return candidates[0]?.[1] || null;
|
||||
}
|
||||
|
||||
export function findBrandMark(value) {
|
||||
const url = asUrl(value);
|
||||
if (url) return domainMark(url.hostname);
|
||||
for (const form of lookupForms(value)) {
|
||||
const mark = MARK_BY_LOOKUP.get(form);
|
||||
if (mark) return mark;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function listBrandMarks(query = '') {
|
||||
const needle = String(query).trim().toLocaleLowerCase('en-US');
|
||||
return BRAND_MARKS.filter((mark) => {
|
||||
if (!needle) return true;
|
||||
return [mark.id, mark.title, mark.category, ...mark.aliases, ...mark.domains]
|
||||
.some((value) => String(value).toLocaleLowerCase('en-US').includes(needle));
|
||||
}).map(({ path, ...mark }) => mark);
|
||||
}
|
||||
|
||||
function ipv4Private(address) {
|
||||
const parts = address.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
|
||||
const [a, b, c] = parts;
|
||||
return a === 0 || a === 10 || a === 127 || a >= 224
|
||||
|| (a === 100 && b >= 64 && b <= 127)
|
||||
|| (a === 169 && b === 254)
|
||||
|| (a === 172 && b >= 16 && b <= 31)
|
||||
|| (a === 192 && b === 0 && (c === 0 || c === 2))
|
||||
|| (a === 192 && b === 88 && c === 99)
|
||||
|| (a === 192 && b === 168)
|
||||
|| (a === 198 && (b === 18 || b === 19))
|
||||
|| (a === 198 && b === 51 && c === 100)
|
||||
|| (a === 203 && b === 0 && c === 113);
|
||||
}
|
||||
|
||||
function ipv6Private(address) {
|
||||
const normalized = address.toLocaleLowerCase('en-US').split('%')[0];
|
||||
if (normalized === '::' || normalized === '::1') return true;
|
||||
if (normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('ff') || /^fe[89ab]/.test(normalized)) return true;
|
||||
if (normalized.startsWith('64:ff9b:') || normalized.startsWith('100:')
|
||||
|| normalized.startsWith('2001:db8:') || normalized.startsWith('2002:')) return true;
|
||||
const mappedDotted = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (mappedDotted) return ipv4Private(mappedDotted[1]);
|
||||
const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
||||
if (mappedHex) {
|
||||
const high = Number.parseInt(mappedHex[1], 16);
|
||||
const low = Number.parseInt(mappedHex[2], 16);
|
||||
return ipv4Private(`${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`);
|
||||
}
|
||||
const compatibleHex = normalized.match(/^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
||||
if (compatibleHex) {
|
||||
const high = Number.parseInt(compatibleHex[1], 16);
|
||||
const low = Number.parseInt(compatibleHex[2], 16);
|
||||
return ipv4Private(`${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isPrivateBrandAddress(address) {
|
||||
const family = net.isIP(address);
|
||||
return family === 4 ? ipv4Private(address) : (family === 6 ? ipv6Private(address) : true);
|
||||
}
|
||||
|
||||
function validateUrlShape(url, allowPrivate = process.env.ARCHIFY_BRAND_ALLOW_PRIVATE === '1') {
|
||||
if (!['https:', 'http:'].includes(url.protocol)) throw new Error('only HTTP(S) brand links are supported');
|
||||
if (url.username || url.password) throw new Error('brand links cannot contain credentials');
|
||||
const expectedPort = url.protocol === 'https:' ? '443' : '80';
|
||||
if (!allowPrivate && url.port && url.port !== expectedPort) {
|
||||
throw new Error('brand links must use a standard web port');
|
||||
}
|
||||
const host = url.hostname.toLocaleLowerCase('en-US').replace(/\.$/, '').replace(/^\[|\]$/g, '');
|
||||
if (!allowPrivate && (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local'))) {
|
||||
throw new Error('private brand links are not fetched');
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
function beforeDeadline(promise, deadline) {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) return Promise.reject(new Error('brand capture timed out'));
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('brand capture timed out')), remaining);
|
||||
timer.unref?.();
|
||||
promise.then(
|
||||
(value) => { clearTimeout(timer); resolve(value); },
|
||||
(error) => { clearTimeout(timer); reject(error); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveRequestTarget(url, deadline) {
|
||||
const allowPrivate = process.env.ARCHIFY_BRAND_ALLOW_PRIVATE === '1';
|
||||
const host = validateUrlShape(url, allowPrivate);
|
||||
const directFamily = net.isIP(host);
|
||||
const addresses = directFamily
|
||||
? [{ address: host, family: directFamily }]
|
||||
: await beforeDeadline(lookup(host, { all: true, verbatim: true }), deadline);
|
||||
if (!addresses.length || (!allowPrivate && addresses.some(({ address }) => isPrivateBrandAddress(address)))) {
|
||||
throw new Error('private brand links are not fetched');
|
||||
}
|
||||
return addresses[0];
|
||||
}
|
||||
|
||||
function timeoutSignal(milliseconds) {
|
||||
if (typeof AbortSignal.timeout === 'function') return AbortSignal.timeout(milliseconds);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), milliseconds);
|
||||
timer.unref?.();
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
function captureTimeoutMilliseconds() {
|
||||
const configured = Number(process.env.ARCHIFY_BRAND_CAPTURE_TIMEOUT_MS);
|
||||
if (!Number.isFinite(configured)) return DEFAULT_CAPTURE_TIMEOUT_MS;
|
||||
return Math.max(100, Math.min(30000, Math.round(configured)));
|
||||
}
|
||||
|
||||
function requestPinned(url, accept, target, deadline) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
const request = transport.request(url, {
|
||||
method: 'GET',
|
||||
signal: timeoutSignal(Math.max(1, Math.min(4500, deadline - Date.now()))),
|
||||
headers: { accept, 'accept-encoding': 'identity', 'user-agent': USER_AGENT },
|
||||
// Reuse the exact public address that passed validation. This closes the
|
||||
// DNS-rebinding gap between checking a hostname and opening its socket.
|
||||
lookup(_hostname, options, callback) {
|
||||
if (options?.all) callback(null, [target]);
|
||||
else callback(null, target.address, target.family);
|
||||
},
|
||||
}, (response) => {
|
||||
const status = response.statusCode || 0;
|
||||
resolve({
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
headers: {
|
||||
get(name) {
|
||||
const value = response.headers[String(name).toLocaleLowerCase('en-US')];
|
||||
return Array.isArray(value) ? value.join(', ') : (value ?? null);
|
||||
},
|
||||
},
|
||||
body: response,
|
||||
});
|
||||
});
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function checkedFetch(input, accept, deadline) {
|
||||
let current = new URL(input);
|
||||
for (let redirects = 0; redirects <= 3; redirects += 1) {
|
||||
if (Date.now() >= deadline) throw new Error('brand capture timed out');
|
||||
const target = await resolveRequestTarget(current, deadline);
|
||||
const response = await requestPinned(current, accept, target, deadline);
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
const location = response.headers.get('location');
|
||||
response.body.resume();
|
||||
if (!location || redirects === 3) throw new Error('brand link redirected too many times');
|
||||
current = new URL(location, current);
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) {
|
||||
response.body.resume();
|
||||
throw new Error(`brand link returned HTTP ${response.status}`);
|
||||
}
|
||||
// Raw HTTP responses are not decompressed. Check successful bodies before
|
||||
// HTML discovery or image validation so byte limits and digests stay valid.
|
||||
const contentEncoding = (response.headers.get('content-encoding') || '').trim().toLowerCase();
|
||||
if (contentEncoding && contentEncoding !== 'identity') {
|
||||
response.body.destroy();
|
||||
throw new Error(`unsupported brand content encoding ${contentEncoding}`);
|
||||
}
|
||||
return { response, finalUrl: current };
|
||||
}
|
||||
throw new Error('brand link redirected too many times');
|
||||
}
|
||||
|
||||
async function readLimited(response, maximum) {
|
||||
const declared = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declared) && declared > maximum) {
|
||||
response.body?.destroy?.();
|
||||
throw new Error('brand asset is too large');
|
||||
}
|
||||
if (response.body && typeof response.body[Symbol.asyncIterator] === 'function') {
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
for await (const value of response.body) {
|
||||
total += value.byteLength;
|
||||
if (total > maximum) {
|
||||
response.body.destroy?.();
|
||||
throw new Error('brand asset is too large');
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
if (!response.body?.getReader) {
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.length > maximum) throw new Error('brand asset is too large');
|
||||
return buffer;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maximum) {
|
||||
await reader.cancel();
|
||||
throw new Error('brand asset is too large');
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
|
||||
// Read only the bounded head, independent of network chunk boundaries. Scan
|
||||
// bytes once so many tiny chunks cannot cause repeated concatenation/rescanning.
|
||||
// This is a boundary scanner, not a DOM parser: comments, quoted attributes and
|
||||
// raw-text elements must not turn a literal </head> into an early stop.
|
||||
async function readHtmlHead(response, maximum) {
|
||||
const chunks = response.body && typeof response.body[Symbol.asyncIterator] === 'function'
|
||||
? response.body : [await readLimited(response, maximum)];
|
||||
const buffer = Buffer.alloc(maximum);
|
||||
let total = 0;
|
||||
let tagStart = -1;
|
||||
let quote = 0;
|
||||
let comment = false;
|
||||
let rawClosing = '';
|
||||
let matched = 0;
|
||||
for await (const value of chunks) {
|
||||
const chunk = Buffer.from(value);
|
||||
const length = Math.min(chunk.length, maximum - total);
|
||||
chunk.copy(buffer, total, 0, length);
|
||||
for (let offset = 0; offset < length; offset++) {
|
||||
const byte = chunk[offset];
|
||||
const position = total + offset;
|
||||
if (comment) {
|
||||
if (byte === 0x3e && buffer[position - 1] === 0x2d && buffer[position - 2] === 0x2d) comment = false;
|
||||
continue;
|
||||
}
|
||||
if (rawClosing) {
|
||||
const lower = byte >= 65 && byte <= 90 ? byte + 32 : byte;
|
||||
if (matched === rawClosing.length && [9, 10, 12, 13, 32, 47, 62].includes(byte)) {
|
||||
tagStart = position - matched;
|
||||
rawClosing = '';
|
||||
matched = 0;
|
||||
} else {
|
||||
matched = lower === rawClosing.charCodeAt(matched) ? matched + 1 : (byte === 0x3c ? 1 : 0);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (tagStart < 0) {
|
||||
if (byte === 0x3c) tagStart = position;
|
||||
continue;
|
||||
}
|
||||
if (position === tagStart + 1 && !((byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || [33, 47, 63].includes(byte))) {
|
||||
tagStart = byte === 0x3c ? position : -1;
|
||||
continue;
|
||||
}
|
||||
if (position === tagStart + 3 && buffer[tagStart + 1] === 0x21 && buffer[tagStart + 2] === 0x2d && byte === 0x2d) {
|
||||
comment = true;
|
||||
tagStart = -1;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (byte === quote) quote = 0;
|
||||
continue;
|
||||
}
|
||||
if (byte === 0x22 || byte === 0x27) {
|
||||
quote = byte;
|
||||
continue;
|
||||
}
|
||||
if (byte === 0x3e) {
|
||||
const tag = buffer.toString('utf8', tagStart, position + 1);
|
||||
if (/^<\/head[\t\n\f\r ]*>$/i.test(tag)) {
|
||||
response.body?.destroy?.();
|
||||
return buffer.toString('utf8', 0, position + 1);
|
||||
}
|
||||
const raw = /^<(script|style|title|textarea|xmp|iframe|noembed|noframes)(?=[\t\n\f\r />])/i.exec(tag);
|
||||
if (raw) rawClosing = `</${raw[1].toLowerCase()}`;
|
||||
tagStart = -1;
|
||||
}
|
||||
}
|
||||
total += length;
|
||||
if (chunk.length > length) {
|
||||
response.body?.destroy?.();
|
||||
throw new Error('brand asset is too large');
|
||||
}
|
||||
}
|
||||
return buffer.toString('utf8', 0, total);
|
||||
}
|
||||
|
||||
function attribute(tag, name) {
|
||||
const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
|
||||
return match ? (match[1] ?? match[2] ?? match[3] ?? '') : '';
|
||||
}
|
||||
|
||||
// HTML numeric references in the C1 range use the legacy Windows-1252 mapping.
|
||||
// https://html.spec.whatwg.org/multipage/parsing.html#numeric-character-reference-end-state
|
||||
const HTML_C1_REFERENCES = [
|
||||
0x20ac, 0x81, 0x201a, 0x192, 0x201e, 0x2026, 0x2020, 0x2021,
|
||||
0x2c6, 0x2030, 0x160, 0x2039, 0x152, 0x8d, 0x17d, 0x8f,
|
||||
0x90, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022, 0x2013, 0x2014,
|
||||
0x2dc, 0x2122, 0x161, 0x203a, 0x153, 0x9d, 0x17e, 0x178,
|
||||
];
|
||||
const BASIC_HTML_REFERENCES = { amp: '&', quot: '"', apos: "'", lt: '<', gt: '>' };
|
||||
|
||||
function decodeIconHref(value) {
|
||||
// Decode only after extracting the attribute, in one pass. Leave percent
|
||||
// escapes to URL parsing and do not reinterpret decoded quotes as markup.
|
||||
return value.replace(/&#(?:[xX]([0-9a-fA-F]+)|([0-9]+));?|&(amp|AMP|quot|QUOT|lt|LT|gt|GT)(?:;|(?![A-Za-z0-9=]))|&(apos);/g,
|
||||
(_match, hex, decimal, named, apostrophe) => {
|
||||
if (named || apostrophe) return BASIC_HTML_REFERENCES[(named || apostrophe).toLowerCase()];
|
||||
let point = Number.parseInt(hex || decimal, hex ? 16 : 10);
|
||||
if (point === 0 || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff)) return '\uFFFD';
|
||||
if (point >= 0x80 && point <= 0x9f) point = HTML_C1_REFERENCES[point - 0x80];
|
||||
return String.fromCodePoint(point);
|
||||
});
|
||||
}
|
||||
|
||||
function iconCandidates(html, pageUrl) {
|
||||
const candidates = [];
|
||||
for (const match of html.matchAll(/<link\b[^>]*>/gi)) {
|
||||
const tag = match[0];
|
||||
const rel = attribute(tag, 'rel').toLocaleLowerCase('en-US').split(/\s+/);
|
||||
if (!rel.some((value) => value === 'icon' || value === 'apple-touch-icon' || value === 'mask-icon')) continue;
|
||||
const href = decodeIconHref(attribute(tag, 'href'));
|
||||
if (!href) continue;
|
||||
try {
|
||||
const url = new URL(href, pageUrl);
|
||||
if (!['https:', 'http:'].includes(url.protocol)) continue;
|
||||
const type = attribute(tag, 'type').toLocaleLowerCase('en-US');
|
||||
const sizes = attribute(tag, 'sizes');
|
||||
const area = [...sizes.matchAll(/(\d+)x(\d+)/gi)]
|
||||
.reduce((best, size) => Math.max(best, Number(size[1]) * Number(size[2])), 0);
|
||||
const score = (type.includes('svg') || /\.svg(?:$|[?#])/i.test(url.href) ? 1000000 : 0)
|
||||
+ (rel.includes('apple-touch-icon') ? 500000 : 0)
|
||||
+ area;
|
||||
candidates.push({ url, score });
|
||||
} catch {
|
||||
// A malformed icon candidate is ignored; the deterministic fallback remains available.
|
||||
}
|
||||
}
|
||||
candidates.sort((left, right) => right.score - left.score);
|
||||
const fallback = new URL('/favicon.ico', pageUrl);
|
||||
const unique = new Map(candidates.map((candidate) => [candidate.url.href, candidate]));
|
||||
unique.delete(fallback.href);
|
||||
return [...unique.values()].slice(0, 5).concat({ url: fallback, score: -1 });
|
||||
}
|
||||
|
||||
async function imageData(response) {
|
||||
const contentType = (response.headers.get('content-type') || '').split(';')[0].trim().toLocaleLowerCase('en-US');
|
||||
const allowed = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/x-icon',
|
||||
'image/vnd.microsoft.icon',
|
||||
]);
|
||||
if (!allowed.has(contentType)) {
|
||||
response.body?.destroy?.();
|
||||
throw new Error(`unsupported brand image type ${contentType || 'unknown'}`);
|
||||
}
|
||||
const buffer = await readLimited(response, MAX_IMAGE_BYTES);
|
||||
const signatureMatches = contentType === 'image/png'
|
||||
? buffer.length >= 45
|
||||
&& buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||
&& buffer.readUInt32BE(8) === 13
|
||||
&& buffer.toString('ascii', 12, 16) === 'IHDR'
|
||||
&& buffer.readUInt32BE(16) > 0
|
||||
&& buffer.readUInt32BE(20) > 0
|
||||
&& buffer.toString('ascii', buffer.length - 8, buffer.length - 4) === 'IEND'
|
||||
: (contentType === 'image/jpeg'
|
||||
? buffer.length >= 20
|
||||
&& buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff
|
||||
&& buffer.at(-2) === 0xff && buffer.at(-1) === 0xd9
|
||||
: (contentType === 'image/webp'
|
||||
? buffer.length >= 16
|
||||
&& buffer.toString('ascii', 0, 4) === 'RIFF'
|
||||
&& buffer.toString('ascii', 8, 12) === 'WEBP'
|
||||
&& buffer.readUInt32LE(4) + 8 <= buffer.length
|
||||
: buffer.length >= 22
|
||||
&& buffer[0] === 0 && buffer[1] === 0 && buffer[2] === 1 && buffer[3] === 0
|
||||
&& buffer.readUInt16LE(4) > 0
|
||||
&& 6 + buffer.readUInt16LE(4) * 16 <= buffer.length));
|
||||
if (!signatureMatches) throw new Error(`brand asset bytes do not match ${contentType}`);
|
||||
return {
|
||||
dataUrl: `data:${contentType};base64,${buffer.toString('base64')}`,
|
||||
sha256: createHash('sha256').update(buffer).digest('hex'),
|
||||
contentType,
|
||||
};
|
||||
}
|
||||
|
||||
async function captureRemoteBrand(value, deadline = Date.now() + captureTimeoutMilliseconds()) {
|
||||
const sourceUrl = new URL(value);
|
||||
const fallback = (reason) => ({
|
||||
id: sourceUrl.hostname,
|
||||
title: sourceUrl.hostname,
|
||||
category: 'link',
|
||||
kind: 'fallback',
|
||||
status: 'unavailable',
|
||||
sourceUrl: sourceUrl.href,
|
||||
reason,
|
||||
});
|
||||
try {
|
||||
const page = await checkedFetch(sourceUrl, 'text/html,application/xhtml+xml,image/*;q=0.8', deadline);
|
||||
const pageType = (page.response.headers.get('content-type') || '').toLocaleLowerCase('en-US');
|
||||
if (pageType.startsWith('image/')) {
|
||||
const image = await imageData(page.response);
|
||||
return {
|
||||
id: sourceUrl.hostname,
|
||||
title: sourceUrl.hostname,
|
||||
category: 'link',
|
||||
kind: 'remote',
|
||||
status: 'captured',
|
||||
sourceUrl: sourceUrl.href,
|
||||
resolvedUrl: page.finalUrl.href,
|
||||
...image,
|
||||
};
|
||||
}
|
||||
if (!pageType.includes('text/html') && !pageType.includes('application/xhtml+xml')) {
|
||||
page.response.body?.destroy?.();
|
||||
return fallback('linked page is not HTML');
|
||||
}
|
||||
const html = await readHtmlHead(page.response, MAX_HTML_BYTES);
|
||||
const iconErrors = [];
|
||||
for (const candidate of iconCandidates(html, page.finalUrl)) {
|
||||
try {
|
||||
const fetched = await checkedFetch(candidate.url, 'image/*', deadline);
|
||||
const image = await imageData(fetched.response);
|
||||
return {
|
||||
id: sourceUrl.hostname,
|
||||
title: sourceUrl.hostname,
|
||||
category: 'link',
|
||||
kind: 'remote',
|
||||
status: 'captured',
|
||||
sourceUrl: sourceUrl.href,
|
||||
resolvedUrl: fetched.finalUrl.href,
|
||||
...image,
|
||||
};
|
||||
} catch (error) {
|
||||
iconErrors.push(error);
|
||||
// Try the next declared favicon before using the generic link mark.
|
||||
}
|
||||
}
|
||||
const usefulError = iconErrors.find((error) => /unsupported brand (?:image type|content encoding)/i.test(error?.message))
|
||||
|| iconErrors.at(-1);
|
||||
return fallback(usefulError?.message || 'no usable site icon was found');
|
||||
} catch (error) {
|
||||
return fallback(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureBrandReference(value) {
|
||||
const url = asUrl(value);
|
||||
if (!url) throw new Error('brand capture requires one HTTP(S) URL');
|
||||
validateUrlShape(url);
|
||||
const preset = findBrandMark(url.href);
|
||||
if (preset) return { brand: preset.id, resolved: { ...preset, kind: 'preset', status: 'preset' } };
|
||||
const resolved = await captureRemoteBrand(url.href);
|
||||
if (resolved.status !== 'captured' || !resolved.sha256) {
|
||||
throw new Error(`brand capture failed: ${resolved.reason || 'no usable site icon was found'}`);
|
||||
}
|
||||
return {
|
||||
brand: { url: url.href, sha256: resolved.sha256 },
|
||||
resolved,
|
||||
};
|
||||
}
|
||||
|
||||
function remoteBrand(value, cache, deadline) {
|
||||
const key = new URL(value).href;
|
||||
if (!cache.has(key)) cache.set(key, captureRemoteBrand(key, deadline));
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
function suggestions(value) {
|
||||
const needle = lookupForms(value)[0] || '';
|
||||
return BRAND_MARKS.map((mark) => ({
|
||||
id: mark.id,
|
||||
score: lookupForms(mark.id).some((form) => form.includes(needle) || needle.includes(form)) ? 0 : 1,
|
||||
})).sort((left, right) => left.score - right.score || left.id.localeCompare(right.id))
|
||||
.slice(0, 5)
|
||||
.map((entry) => entry.id);
|
||||
}
|
||||
|
||||
async function mapConcurrent(values, limit, visit) {
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, values.length) }, async () => {
|
||||
while (cursor < values.length) {
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
await visit(values[index], index);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
export async function prepareDiagramBrandMarks(diagramType, diagram) {
|
||||
const collection = COLLECTIONS[diagramType];
|
||||
const nodes = collection && Array.isArray(diagram[collection]) ? diagram[collection] : [];
|
||||
const unknown = [];
|
||||
const remoteByUrl = new Map();
|
||||
const deadline = Date.now() + captureTimeoutMilliseconds();
|
||||
await mapConcurrent(nodes, MAX_CAPTURE_CONCURRENCY, async (node, index) => {
|
||||
if (!node.brand) return;
|
||||
if (typeof node.brand === 'object') {
|
||||
const url = asUrl(node.brand.url);
|
||||
const resolved = url ? await remoteBrand(url.href, remoteByUrl, deadline) : null;
|
||||
if (!resolved || resolved.status !== 'captured') {
|
||||
unknown.push(`/${collection}/${index}/brand could not reproduce the pinned capture: ${resolved?.reason || 'invalid URL'}`);
|
||||
return;
|
||||
}
|
||||
if (resolved.sha256 !== node.brand.sha256) {
|
||||
unknown.push(`/${collection}/${index}/brand digest changed: expected ${node.brand.sha256}, received ${resolved.sha256}`);
|
||||
return;
|
||||
}
|
||||
node[RESOLVED_MARK] = resolved;
|
||||
RESOLVED_BY_NODE.set(node, resolved);
|
||||
return;
|
||||
}
|
||||
const preset = findBrandMark(node.brand);
|
||||
if (preset) {
|
||||
const resolved = { ...preset, kind: 'preset', status: 'preset', sourceUrl: preset.provenance.source };
|
||||
node[RESOLVED_MARK] = resolved;
|
||||
RESOLVED_BY_NODE.set(node, resolved);
|
||||
return;
|
||||
}
|
||||
const url = asUrl(node.brand);
|
||||
if (url) {
|
||||
unknown.push(`/${collection}/${index}/brand ${JSON.stringify(node.brand)} is an unpinned URL; capture it first with \`archify brands capture ${url.href} --json\``);
|
||||
return;
|
||||
}
|
||||
unknown.push(`/${collection}/${index}/brand ${JSON.stringify(node.brand)} is not a built-in brand; closest IDs: ${suggestions(node.brand).join(', ')}`);
|
||||
});
|
||||
if (unknown.length) {
|
||||
throwDiagnosticError(`Brand mark validation failed:\n- ${unknown.join('\n- ')}`, unknown.map((message) => ({
|
||||
code: message.includes('is an unpinned URL') ? 'brand/unpinned-url'
|
||||
: (message.includes('digest changed') ? 'brand/digest-mismatch'
|
||||
: (message.includes('could not reproduce') ? 'brand/capture-unavailable' : 'brand/unknown')),
|
||||
severity: 'error',
|
||||
message,
|
||||
subject: { diagramType, collection },
|
||||
evidence: {},
|
||||
supportedFixes: message.includes('is an unpinned URL')
|
||||
? ['run `archify brands capture <url> --json` and author the returned digest-pinned brand object']
|
||||
: ['choose an ID from `archify brands`', 'run `archify brands capture <url> --json` for an unknown official site'],
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
export function brandMarkFor(node) {
|
||||
return node?.[RESOLVED_MARK] || RESOLVED_BY_NODE.get(node) || null;
|
||||
}
|
||||
|
||||
export function brandMetadataFor(node) {
|
||||
const mark = brandMarkFor(node);
|
||||
return mark ? {
|
||||
brand: mark.title,
|
||||
brandId: mark.id,
|
||||
brandStatus: mark.status,
|
||||
brandSource: mark.sourceUrl,
|
||||
} : {};
|
||||
}
|
||||
|
||||
export function brandLabelFitWidth(node, width) {
|
||||
return brandMarkFor(node) ? Math.max(1, width - 48) : width;
|
||||
}
|
||||
|
||||
export function brandTopRailProblem(node, width, minimumFontSize, subject = 'Node') {
|
||||
if (!brandMarkFor(node)) return null;
|
||||
const available = width - 48;
|
||||
const required = textUnits(node.label) * minimumFontSize * 0.6;
|
||||
if (available >= required) return null;
|
||||
return `${subject} "${node.id}" brand top rail leaves ${Math.max(0, available)}px for its label, but `
|
||||
+ `"${node.label}" needs ~${Math.ceil(required)}px at the ${minimumFontSize}px legible minimum — widen the node or shorten the label.`;
|
||||
}
|
||||
|
||||
function markAttrs(mark) {
|
||||
return [
|
||||
`data-brand-mark="${esc(mark.id)}"`,
|
||||
`data-brand-title="${esc(mark.title)}"`,
|
||||
`data-brand-status="${esc(mark.status)}"`,
|
||||
mark.sourceUrl ? `data-brand-source="${esc(mark.sourceUrl)}"` : '',
|
||||
mark.sha256 ? `data-brand-sha256="${esc(mark.sha256)}"` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export function renderBrandMark(node, { x, y, size = 16 } = {}) {
|
||||
const mark = brandMarkFor(node);
|
||||
if (!mark) return '';
|
||||
const inset = 3;
|
||||
let content;
|
||||
if (mark.kind === 'preset') {
|
||||
const scale = (size - inset * 2) / mark.viewBox;
|
||||
content = `<path d="${esc(mark.path)}" transform="translate(${inset} ${inset}) scale(${scale})" fill="#${esc(mark.hex)}"/>`;
|
||||
} else if (mark.kind === 'remote') {
|
||||
content = `<image href="${esc(mark.dataUrl)}" x="${inset}" y="${inset}" width="${size - inset * 2}" height="${size - inset * 2}" preserveAspectRatio="xMidYMid meet"/>`;
|
||||
} else {
|
||||
const scale = size / 20;
|
||||
content = `<g transform="scale(${scale})" class="brand-mark-fallback"><circle cx="10" cy="10" r="5.2"/><path d="M4.8 10h10.4M10 4.8c1.6 1.6 2.4 3.3 2.4 5.2s-.8 3.6-2.4 5.2M10 4.8C8.4 6.4 7.6 8.1 7.6 10s.8 3.6 2.4 5.2"/></g>`;
|
||||
}
|
||||
return `<g aria-hidden="true" ${markAttrs(mark)} class="brand-mark" transform="translate(${x} ${y})">
|
||||
<rect width="${size}" height="${size}" rx="4" class="brand-mark-badge"/>
|
||||
${content}
|
||||
<rect width="${size}" height="${size}" rx="4" class="brand-mark-frame"/>
|
||||
</g>`;
|
||||
}
|
||||
+451
@@ -0,0 +1,451 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { applyTemplate, renderCards, esc } from './utils.mjs';
|
||||
import { validateSchema } from './validator.mjs';
|
||||
import { verifyRepositoryEvidence } from './repository-evidence.mjs';
|
||||
import { installRendererDiagnosticBoundary, throwDiagnosticError, throwDiagnosticProblems, recordDiagnostic } from './diagnostics.mjs';
|
||||
import { validateEngineeringProfile } from './engineering-profiles.mjs';
|
||||
import {
|
||||
resolveOutputPath,
|
||||
validateAuthoredOutputPath,
|
||||
} from './output-path.mjs';
|
||||
import {
|
||||
captureAtomicOutput,
|
||||
captureRegularFileBinding,
|
||||
publishRegularFileBinding,
|
||||
releaseRegularFileBinding,
|
||||
removeOwnedRegularFile,
|
||||
verifyAtomicOutput,
|
||||
} from './atomic-output.mjs';
|
||||
import { resolveLocale, translateMessage, registerLocale, SUPPORTED_LOCALES } from './i18n.mjs';
|
||||
import { prepareDiagramBrandMarks } from './brand-marks.mjs';
|
||||
|
||||
const outputPathGuards = new Map();
|
||||
let renderCandidateSequence = 0;
|
||||
|
||||
// meta.locale is renderer-owned Viewer UI, not authored content.
|
||||
// en and zh-CN ship as built-in catalogs.
|
||||
// Any other tag needs meta.translations (validated against the English
|
||||
// message-key set, layered over English per-key so partial/invalid entries
|
||||
// never break rendering) or it falls back to the English Viewer chrome —
|
||||
// the same "omit locale, disclose the fallback" contract as before, just
|
||||
// resolved from data instead of a hard-coded enum. See i18n.mjs.
|
||||
function applyLocaleTranslations(diagramType, diagram) {
|
||||
const locale = diagram.meta?.locale;
|
||||
if (!locale) return;
|
||||
const translations = diagram.meta?.translations;
|
||||
if (translations && Object.keys(translations).length) {
|
||||
const report = registerLocale(locale, translations);
|
||||
if (report.missingKeys.length || report.unknownKeys.length || report.placeholderMismatches.length) {
|
||||
recordDiagnostic({
|
||||
code: 'i18n/translation-coverage',
|
||||
severity: 'warning',
|
||||
message: `meta.translations for locale ${JSON.stringify(locale)} covers ${report.coveredKeys}/${report.totalKeys} renderer-owned messages (${Math.round(report.coverage * 100)}%); uncovered keys fall back to English.`,
|
||||
subject: { diagramType, path: '/meta/translations' },
|
||||
evidence: {
|
||||
missingKeys: report.missingKeys.slice(0, 10),
|
||||
missingKeysTotal: report.missingKeys.length,
|
||||
unknownKeys: report.unknownKeys.slice(0, 10),
|
||||
unknownKeysTotal: report.unknownKeys.length,
|
||||
placeholderMismatches: report.placeholderMismatches.slice(0, 10),
|
||||
placeholderMismatchesTotal: report.placeholderMismatches.length,
|
||||
},
|
||||
supportedFixes: ['Add the missing keys to meta.translations.', 'Match each translation\'s {placeholders} to the English source string.'],
|
||||
});
|
||||
// Coverage is a fact about this render, not just a diagnostic-mode
|
||||
// artifact: print it to stderr unconditionally so `render`/`deliver`/
|
||||
// `validate` disclose the fallback even without ARCHIFY_DIAGNOSTIC_FORMAT.
|
||||
console.warn(`archify: meta.translations for locale ${JSON.stringify(locale)} covers ${report.coveredKeys}/${report.totalKeys} renderer-owned messages (${Math.round(report.coverage * 100)}%); uncovered keys fall back to English.`);
|
||||
}
|
||||
} else if (!SUPPORTED_LOCALES.includes(locale)) {
|
||||
recordDiagnostic({
|
||||
code: 'i18n/locale-fallback',
|
||||
severity: 'warning',
|
||||
message: `meta.locale ${JSON.stringify(locale)} has no built-in catalog and no meta.translations; the Viewer chrome and <html lang> fall back to English.`,
|
||||
subject: { diagramType, path: '/meta/locale' },
|
||||
supportedFixes: ['Supply meta.translations for this locale.', `Use a built-in locale: ${SUPPORTED_LOCALES.join(', ')}.`],
|
||||
});
|
||||
console.warn(`archify: meta.locale ${JSON.stringify(locale)} has no built-in catalog and no meta.translations; the Viewer chrome and <html lang> fall back to English.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Common CLI head: node render-<type>.mjs [input.json] [output.html]
|
||||
// Keep this synchronous because callers also use it to establish the guarded
|
||||
// output path before testing a last-moment filesystem alias change.
|
||||
export function loadDiagram({ rendererDir, diagramType, defaultExample, argv = process.argv }) {
|
||||
// Compilers also import this module for SVG helpers. Only CLI execution
|
||||
// should install a process-level handler, before reading or validating input.
|
||||
installRendererDiagnosticBoundary();
|
||||
const skillRoot = path.resolve(rendererDir, '../..');
|
||||
const inputPath = path.resolve(argv[2] || path.join(skillRoot, 'examples', defaultExample));
|
||||
let input;
|
||||
try {
|
||||
input = fs.readFileSync(inputPath, 'utf8');
|
||||
} catch (error) {
|
||||
if (!isFilesystemError(error)) throw error;
|
||||
const message = `Input could not be read: ${error.message}`;
|
||||
throwDiagnosticError(message, [{
|
||||
code: 'input/read', message,
|
||||
subject: { input: inputPath },
|
||||
evidence: { systemCode: error.code, reason: error.message },
|
||||
supportedFixes: ['provide one readable JSON input file'],
|
||||
}]);
|
||||
}
|
||||
let diagram;
|
||||
try {
|
||||
diagram = JSON.parse(input);
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) throw error;
|
||||
const message = `Input JSON could not be parsed: ${error.message}`;
|
||||
throwDiagnosticError(message, [{
|
||||
code: 'input/json-parse', message,
|
||||
subject: { input: inputPath },
|
||||
evidence: { reason: error.message },
|
||||
supportedFixes: ['repair the JSON syntax and run validation again'],
|
||||
}]);
|
||||
}
|
||||
const authoredOutput = diagram?.meta?.output;
|
||||
if (authoredOutput !== undefined) validateAuthoredOutputPath(authoredOutput);
|
||||
validateSchema(diagramType, diagram);
|
||||
applyLocaleTranslations(diagramType, diagram);
|
||||
validateCrossCollectionContracts(diagramType, diagram);
|
||||
validateEngineeringProfile(diagramType, diagram);
|
||||
const sourceEvidence = verifyRepositoryEvidence(diagramType, diagram, process.env.ARCHIFY_REPO_ROOT);
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets/template.html'), 'utf8');
|
||||
const outputRequest = {
|
||||
requestedOutput: argv[3],
|
||||
authoredOutput: diagram.meta?.output,
|
||||
defaultOutput: `${diagramType}.html`,
|
||||
inputPaths: [inputPath],
|
||||
cwd: process.cwd(),
|
||||
};
|
||||
let outPath;
|
||||
try {
|
||||
({ outputPath: outPath } = resolveOutputPath(outputRequest));
|
||||
} catch (error) {
|
||||
throwOutputError(error, path.resolve(outputRequest.requestedOutput || outputRequest.authoredOutput || outputRequest.defaultOutput));
|
||||
}
|
||||
outputPathGuards.set(outPath, outputRequest);
|
||||
return { diagram, template, outPath, sourceEvidence };
|
||||
}
|
||||
|
||||
// Brand URL capture is the only asynchronous authoring step. Typed renderers
|
||||
// opt into it through this wrapper without changing loadDiagram's long-lived
|
||||
// synchronous safety contract.
|
||||
export async function loadDiagramWithBrandMarks(options) {
|
||||
const loaded = loadDiagram(options);
|
||||
await prepareDiagramBrandMarks(options.diagramType, loaded.diagram);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
const START_TYPES = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']);
|
||||
|
||||
function isFilesystemError(error) {
|
||||
return typeof error?.code === 'string'
|
||||
&& typeof error?.syscall === 'string'
|
||||
&& typeof error?.errno === 'number';
|
||||
}
|
||||
|
||||
function throwOutputError(error, output) {
|
||||
if (error?.archifyDiagnostics || !isFilesystemError(error)) throw error;
|
||||
const message = `Output could not be written: ${error.message}`;
|
||||
throwDiagnosticError(message, [{
|
||||
code: 'output/write', message,
|
||||
subject: { output },
|
||||
evidence: { systemCode: error.code, reason: error.message },
|
||||
supportedFixes: ['choose a writable HTML file path and ensure its parent directories can be created'],
|
||||
}]);
|
||||
}
|
||||
|
||||
function throwAtomicOutputFailure(result, output) {
|
||||
const reason = result.reason || { code: 'unclassified' };
|
||||
const changed = result.status === 'different';
|
||||
const candidate = reason.code.startsWith('candidate-');
|
||||
const nonRegular = ['target-not-regular-file', 'candidate-not-regular-file'].includes(reason.code);
|
||||
const hardlinked = ['target-hardlinked', 'candidate-hardlinked'].includes(reason.code);
|
||||
const message = changed
|
||||
? 'Output target changed while the rendered artifact was being prepared.'
|
||||
: nonRegular
|
||||
? candidate
|
||||
? 'Temporary output candidate is no longer a regular file.'
|
||||
: 'Output already exists and is not a regular file.'
|
||||
: hardlinked
|
||||
? candidate
|
||||
? 'Temporary output candidate has multiple hard-link names.'
|
||||
: 'Output already exists through multiple hard-link names.'
|
||||
: 'Output target stability could not be determined safely before commit.';
|
||||
throwDiagnosticError(message, [{
|
||||
code: changed
|
||||
? 'output/target-changed'
|
||||
: nonRegular
|
||||
? 'output/target-not-regular-file'
|
||||
: hardlinked
|
||||
? 'output/target-hardlinked'
|
||||
: 'output/target-indeterminate',
|
||||
message,
|
||||
subject: { output },
|
||||
evidence: { relation: reason },
|
||||
supportedFixes: [hardlinked
|
||||
? 'choose a non-hardlinked output path; atomic replacement cannot update every hard-link name'
|
||||
: 'retry after other processes stop replacing or redirecting the output path'],
|
||||
}]);
|
||||
}
|
||||
|
||||
function stageRenderedHtml(outputPath, html, mode) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
renderCandidateSequence += 1;
|
||||
const candidatePath = path.join(
|
||||
path.dirname(outputPath),
|
||||
`.archify-render-${process.pid}-${Date.now().toString(36)}-${renderCandidateSequence}.tmp`,
|
||||
);
|
||||
let descriptor;
|
||||
let identity;
|
||||
try {
|
||||
const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0);
|
||||
descriptor = fs.openSync(
|
||||
candidatePath,
|
||||
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow,
|
||||
mode ?? 0o666,
|
||||
);
|
||||
let metadata;
|
||||
try {
|
||||
metadata = fs.fstatSync(descriptor, { bigint: true });
|
||||
} catch (error) {
|
||||
// A transient first inspection failure must not strand the exclusive
|
||||
// candidate. A successful retry binds cleanup to the still-open file;
|
||||
// if both inspections fail, preserving the unknown entry is safer.
|
||||
try {
|
||||
const retry = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (retry.isFile() && retry.ino !== 0n) {
|
||||
identity = { device: retry.dev, inode: retry.ino };
|
||||
}
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
if (!metadata.isFile() || metadata.ino === 0n) {
|
||||
throw new Error('Temporary render candidate identity could not be verified safely.');
|
||||
}
|
||||
identity = { device: metadata.dev, inode: metadata.ino };
|
||||
fs.writeFileSync(descriptor, html);
|
||||
// Creation modes are filtered through the process umask. An atomic
|
||||
// replacement must retain the exact permissions of an existing target,
|
||||
// while a brand-new target should keep normal umask behavior.
|
||||
if (mode !== null) fs.fchmodSync(descriptor, mode);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
return { candidatePath, identity };
|
||||
} catch (error) {
|
||||
if (descriptor !== undefined) {
|
||||
try { fs.closeSync(descriptor); } catch {}
|
||||
}
|
||||
if (error.code === 'EEXIST') continue;
|
||||
if (identity) {
|
||||
const cleanup = removeOwnedRegularFile(candidatePath, identity);
|
||||
if (!['removed', 'absent', 'preserved'].includes(cleanup.status)) {
|
||||
const cleanupError = new Error(`${error.message}; temporary render candidate cleanup also failed.`);
|
||||
cleanupError.cause = error;
|
||||
throw cleanupError;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const error = new Error(`Could not reserve a temporary render candidate beside "${outputPath}".`);
|
||||
error.code = 'EEXIST';
|
||||
error.errno = -17;
|
||||
error.syscall = 'open';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Common CLI tail: fill the template and write the standalone HTML file.
|
||||
export function writeDiagram({ outPath, template, diagramType, meta, svg, cards, sourceEvidence = null }) {
|
||||
if (!START_TYPES.has(diagramType)) throw new Error(`writeDiagram: unknown diagram type ${JSON.stringify(diagramType)}`);
|
||||
const outputGuard = outputPathGuards.get(outPath);
|
||||
const html = applyTemplate(template, {
|
||||
title: meta.title,
|
||||
subtitle: meta.subtitle,
|
||||
svg,
|
||||
cards: renderCards(cards),
|
||||
locale: meta.locale,
|
||||
visualPreset: meta.visual_preset || 'classic',
|
||||
sourceEvidence,
|
||||
});
|
||||
let candidatePath;
|
||||
let candidateIdentity;
|
||||
let candidateBinding;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
const outputCapture = captureAtomicOutput(outPath);
|
||||
if (outputCapture.status !== 'captured') throwAtomicOutputFailure(outputCapture, outPath);
|
||||
const beforeStage = verifyAtomicOutput(outputCapture.snapshot);
|
||||
if (beforeStage.status !== 'match') throwAtomicOutputFailure(beforeStage, outPath);
|
||||
({ candidatePath, identity: candidateIdentity } = stageRenderedHtml(
|
||||
outputCapture.commitPath,
|
||||
html,
|
||||
outputCapture.mode,
|
||||
));
|
||||
|
||||
// The renderer may spend substantial time building HTML after loadDiagram
|
||||
// establishes the guard. Re-run it after staging so a last-moment alias
|
||||
// cannot redirect the commit onto an input file.
|
||||
if (outputGuard) resolveOutputPath(outputGuard);
|
||||
const candidateCapture = captureRegularFileBinding(candidatePath, {
|
||||
subject: 'candidate',
|
||||
expectedSha256: createHash('sha256').update(html).digest('hex'),
|
||||
expectedBytes: Buffer.byteLength(html),
|
||||
expectedIdentity: candidateIdentity,
|
||||
...(outputCapture.mode === null ? {} : { expectedMode: outputCapture.mode }),
|
||||
});
|
||||
if (candidateCapture.status !== 'captured') throwAtomicOutputFailure(candidateCapture, outPath);
|
||||
candidateBinding = candidateCapture.binding;
|
||||
const beforeCommit = verifyAtomicOutput(outputCapture.snapshot);
|
||||
if (beforeCommit.status !== 'match') throwAtomicOutputFailure(beforeCommit, outPath);
|
||||
const publication = publishRegularFileBinding(
|
||||
candidateBinding,
|
||||
candidatePath,
|
||||
outputCapture.snapshot,
|
||||
{ subject: 'candidate' },
|
||||
);
|
||||
if (!['committed', 'committed-with-warning'].includes(publication.status)) {
|
||||
throwAtomicOutputFailure(publication, outPath);
|
||||
}
|
||||
const releasedCandidate = releaseRegularFileBinding(candidateBinding);
|
||||
candidateBinding = undefined;
|
||||
if (releasedCandidate.status !== 'released') throwAtomicOutputFailure(releasedCandidate, outPath);
|
||||
candidatePath = undefined;
|
||||
candidateIdentity = undefined;
|
||||
} catch (error) {
|
||||
throwOutputError(error, outPath);
|
||||
} finally {
|
||||
outputPathGuards.delete(outPath);
|
||||
if (candidateBinding) releaseRegularFileBinding(candidateBinding);
|
||||
if (candidatePath && candidateIdentity) {
|
||||
const cleanup = removeOwnedRegularFile(candidatePath, candidateIdentity);
|
||||
if (!['removed', 'absent', 'preserved'].includes(cleanup.status)) {
|
||||
throwAtomicOutputFailure(cleanup, outPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(outPath);
|
||||
}
|
||||
|
||||
const SEMANTIC_COLLECTIONS = {
|
||||
architecture: 'components',
|
||||
workflow: 'nodes',
|
||||
sequence: 'participants',
|
||||
dataflow: 'nodes',
|
||||
lifecycle: 'states',
|
||||
};
|
||||
|
||||
const RELATIONSHIP_COLLECTIONS = {
|
||||
architecture: 'connections',
|
||||
workflow: 'edges',
|
||||
sequence: 'messages',
|
||||
dataflow: 'flows',
|
||||
lifecycle: 'transitions',
|
||||
};
|
||||
|
||||
// Relationship IDs are optional for backwards compatibility, but once an
|
||||
// author supplies one it becomes the durable identity used by viewer links.
|
||||
// Keep uniqueness enforcement in the shared zero-install path so every typed
|
||||
// renderer fails the same way even when development dependencies are absent.
|
||||
export function validateRelationshipIds(diagramType, diagram) {
|
||||
const collection = RELATIONSHIP_COLLECTIONS[diagramType];
|
||||
const relationships = collection && Array.isArray(diagram[collection]) ? diagram[collection] : [];
|
||||
const seen = new Set();
|
||||
const problems = [];
|
||||
|
||||
relationships.forEach((relationship, index) => {
|
||||
if (relationship.id === undefined || relationship.id === null || relationship.id === '') return;
|
||||
if (seen.has(relationship.id)) {
|
||||
problems.push(`/${collection}/${index}/id duplicates relationship id ${JSON.stringify(relationship.id)}`);
|
||||
}
|
||||
seen.add(relationship.id);
|
||||
});
|
||||
|
||||
if (problems.length) {
|
||||
throwDiagnosticProblems('Relationship identity validation failed', problems, {
|
||||
code: 'relationship/duplicate-id',
|
||||
subject: { diagramType, collection },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Share relationship-ID semantic checks between the loader
|
||||
// and workflow compiler without performing filesystem operations (see #429).
|
||||
export function validateCrossCollectionContracts(diagramType, diagram) {
|
||||
validateRelationshipIds(diagramType, diagram);
|
||||
}
|
||||
|
||||
// Accessible name for the generated diagram SVG.
|
||||
export function svgRootAttrs(meta, explicitQualityProfile) {
|
||||
const animation = meta.animation === 'trace' ? ' data-animation="trace"' : '';
|
||||
const preset = ` data-preset="${esc(meta.visual_preset || 'classic')}"`;
|
||||
const engineeringProfile = meta.engineering_profile
|
||||
? ` data-engineering-profile="${esc(meta.engineering_profile)}"`
|
||||
: '';
|
||||
const requestedProfile = explicitQualityProfile || process.env.ARCHIFY_QUALITY_PROFILE || meta.quality_profile;
|
||||
const qualityProfile = requestedProfile === 'showcase' ? 'showcase' : 'standard';
|
||||
const advisory = requestedProfile ? '' : ' data-quality-gates="advisory"';
|
||||
return `role="img" lang="${esc(resolveLocale(meta.locale))}" aria-labelledby="archify-diagram-title archify-diagram-description"${animation}${preset}${engineeringProfile} data-quality-profile="${esc(qualityProfile)}"${advisory}`;
|
||||
}
|
||||
|
||||
// Keep the accessible name inside the SVG so it survives standalone SVG
|
||||
// export and embedding. The fixed IDs are deterministic because an Archify
|
||||
// artifact intentionally contains one primary diagram SVG.
|
||||
export function svgAccessibleText(meta, kind) {
|
||||
const description = meta.subtitle || translateMessage(meta.locale, `diagram.description.${kind}`);
|
||||
return ` <title id="archify-diagram-title">${esc(meta.title)}</title>\n <desc id="archify-diagram-description">${esc(description)}</desc>`;
|
||||
}
|
||||
|
||||
export function animateAttr(meta, kind, step) {
|
||||
if (meta.animation !== 'trace') return '';
|
||||
// Ambient trace must finish inside the fixed six-second WebM capture. The
|
||||
// cap affects visual delay only; authored order and semantic identity stay
|
||||
// untouched in the JSON, DOM, and relationship contracts.
|
||||
const safeStep = Number.isFinite(step) && step >= 0 ? Math.min(12, Math.floor(step)) : 0;
|
||||
return ` data-animate="${kind}" style="--step:${safeStep}"`;
|
||||
}
|
||||
|
||||
// Stable semantic hooks for the standalone HTML explorer. IDs already pass
|
||||
// the schema's conservative identifier pattern; escape again at the markup
|
||||
// boundary so these helpers remain safe if that contract expands later.
|
||||
export function focusNodeAttrs(id, label, metadata = {}, locale) {
|
||||
const optional = [
|
||||
['data-node-kind', metadata.kind],
|
||||
['data-node-sublabel', metadata.sublabel],
|
||||
['data-node-tag', metadata.tag],
|
||||
['data-node-context', metadata.context],
|
||||
['data-node-brand', metadata.brand],
|
||||
['data-node-brand-id', metadata.brandId],
|
||||
['data-node-brand-status', metadata.brandStatus],
|
||||
['data-node-brand-source', metadata.brandSource],
|
||||
].filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '')
|
||||
.map(([name, value]) => ` ${name}="${esc(String(value))}"`)
|
||||
.join('');
|
||||
const detail = [metadata.sublabel, metadata.context, metadata.brand]
|
||||
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '')
|
||||
.join(', ');
|
||||
const aria = detail
|
||||
? translateMessage(locale, 'node.focus.detail', { label, detail })
|
||||
: translateMessage(locale, 'node.focus', { label });
|
||||
return `id="node-${esc(id)}" data-node-id="${esc(id)}" data-node-label="${esc(label)}" tabindex="0" role="button" aria-label="${esc(aria)}" aria-pressed="false"${optional}`;
|
||||
}
|
||||
|
||||
// Native SVG titles preserve a compact details-on-demand fallback when the
|
||||
// canonical SVG is embedded inline outside the full Archify viewer.
|
||||
export function focusNodeTitle(label, metadata = {}) {
|
||||
const parts = [label, metadata.sublabel, metadata.context, metadata.tag, metadata.brand]
|
||||
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '');
|
||||
return `<title>${esc(parts.join(' · '))}</title>`;
|
||||
}
|
||||
|
||||
export function focusEdgeAttrs(from, to, label, key, id) {
|
||||
const named = label ? ` data-edge-label="${esc(label)}"` : '';
|
||||
const keyed = key !== undefined && key !== null ? ` data-edge-key="${esc(String(key))}"` : '';
|
||||
const identified = id !== undefined && id !== null && String(id).trim() !== ''
|
||||
? ` data-edge-id="${esc(String(id))}"`
|
||||
: '';
|
||||
return `data-edge-from="${esc(from)}" data-edge-to="${esc(to)}"${named}${keyed}${identified}`;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
export const DESKTOP_READABILITY_VIEWPORT = Object.freeze({ width: 1440, height: 900 });
|
||||
export const DESKTOP_READER_MIN_WIDTH = 960;
|
||||
export const DESKTOP_READER_HORIZONTAL_CHROME = 30;
|
||||
export const DESKTOP_READER_DIAGRAM_WIDTH = DESKTOP_READER_MIN_WIDTH - DESKTOP_READER_HORIZONTAL_CHROME;
|
||||
export const MIN_PROJECTED_NODE_TEXT_PX = 6;
|
||||
export const DECLARED_WIDE_READER_CONTRACT = 'declared-wide-v1';
|
||||
export const DECLARED_WIDE_READER_RATIO = 1.55;
|
||||
export const DECLARED_WIDE_READER_MAX_WIDTH = 1920;
|
||||
export const DECLARED_WIDE_REFERENCE_BODY_HORIZONTAL_PX = 64;
|
||||
export const DECLARED_WIDE_REFERENCE_DIAGRAM_HORIZONTAL_PX = 30;
|
||||
|
||||
export function projectedNodeTextPx(sourceFontPx, viewBoxWidth, diagramWidth = DESKTOP_READER_DIAGRAM_WIDTH) {
|
||||
if (![sourceFontPx, viewBoxWidth, diagramWidth].every(Number.isFinite) || viewBoxWidth <= 0 || diagramWidth <= 0) {
|
||||
return Number.NaN;
|
||||
}
|
||||
return sourceFontPx * Math.min(1, diagramWidth / viewBoxWidth);
|
||||
}
|
||||
|
||||
export function minimumReadableSourceTextPx(
|
||||
viewBoxWidth,
|
||||
diagramWidth = DESKTOP_READER_DIAGRAM_WIDTH,
|
||||
minimumProjectedPx = MIN_PROJECTED_NODE_TEXT_PX,
|
||||
) {
|
||||
if (![viewBoxWidth, diagramWidth, minimumProjectedPx].every(Number.isFinite)
|
||||
|| viewBoxWidth <= 0
|
||||
|| diagramWidth <= 0
|
||||
|| minimumProjectedPx <= 0) {
|
||||
return Number.NaN;
|
||||
}
|
||||
return minimumProjectedPx / Math.min(1, diagramWidth / viewBoxWidth);
|
||||
}
|
||||
|
||||
// This is deliberately separate from the legacy 930px projection. Architecture
|
||||
// boundary convergence depends on that legacy default, while only a recognized
|
||||
// v2 wide Reader may use this declared-width proof.
|
||||
export function declaredWideReadabilityBudget({
|
||||
viewBoxWidth,
|
||||
viewBoxHeight,
|
||||
minimumSourceTextPx,
|
||||
requestedMinimumTextPx,
|
||||
viewportWidth = DESKTOP_READABILITY_VIEWPORT.width,
|
||||
bodyHorizontalPx = DECLARED_WIDE_REFERENCE_BODY_HORIZONTAL_PX,
|
||||
diagramHorizontalPx = DECLARED_WIDE_REFERENCE_DIAGRAM_HORIZONTAL_PX,
|
||||
minimumReaderWidth = DESKTOP_READER_MIN_WIDTH,
|
||||
maximumReaderWidth = DECLARED_WIDE_READER_MAX_WIDTH,
|
||||
} = {}) {
|
||||
const values = [
|
||||
viewBoxWidth, viewBoxHeight, minimumSourceTextPx, requestedMinimumTextPx,
|
||||
viewportWidth, bodyHorizontalPx, diagramHorizontalPx, minimumReaderWidth, maximumReaderWidth,
|
||||
];
|
||||
if (!values.every(Number.isFinite) || viewBoxWidth <= 0 || viewBoxHeight <= 0
|
||||
|| minimumSourceTextPx <= 0 || requestedMinimumTextPx <= 0 || viewportWidth <= 0
|
||||
|| bodyHorizontalPx < 0 || diagramHorizontalPx < 0 || minimumReaderWidth <= 0
|
||||
|| maximumReaderWidth < minimumReaderWidth || viewBoxWidth / viewBoxHeight < DECLARED_WIDE_READER_RATIO) {
|
||||
return null;
|
||||
}
|
||||
const requestedTargetPx = Math.max(MIN_PROJECTED_NODE_TEXT_PX, requestedMinimumTextPx);
|
||||
const requestedScale = Math.min(1, requestedTargetPx / minimumSourceTextPx);
|
||||
const desiredReaderWidth = Math.max(minimumReaderWidth, viewBoxWidth * requestedScale + diagramHorizontalPx);
|
||||
const viewportCap = Math.max(0, viewportWidth - bodyHorizontalPx);
|
||||
const cap = Math.min(maximumReaderWidth, viewportCap);
|
||||
const actualReaderWidth = Math.min(desiredReaderWidth, cap);
|
||||
const guaranteedSvgWidth = Math.max(0, actualReaderWidth - diagramHorizontalPx);
|
||||
const projectedMinimumTextPx = projectedNodeTextPx(minimumSourceTextPx, viewBoxWidth, guaranteedSvgWidth);
|
||||
const limit = actualReaderWidth < desiredReaderWidth
|
||||
? (viewportCap <= maximumReaderWidth ? 'viewport-cap' : 'reader-cap')
|
||||
: 'source-size';
|
||||
return {
|
||||
requestedTargetPx,
|
||||
requestedScale,
|
||||
desiredReaderWidth,
|
||||
viewportCap,
|
||||
maximumReaderWidth,
|
||||
actualReaderWidth,
|
||||
guaranteedSvgWidth,
|
||||
projectedMinimumTextPx,
|
||||
hardFloorPx: MIN_PROJECTED_NODE_TEXT_PX,
|
||||
hardFloorMet: projectedMinimumTextPx >= MIN_PROJECTED_NODE_TEXT_PX,
|
||||
requestedTargetMet: projectedMinimumTextPx >= requestedMinimumTextPx,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
// Vertical chrome that always stacks with the SVG at the 1440x900 desktop
|
||||
// viewport, measured from the delivered Viewer with the shortest one-line
|
||||
// header and no cards: body padding 12, header 39, diagram padding/border 75.
|
||||
// Cards are excluded so the prediction stays a lower bound.
|
||||
export const DESKTOP_FIXED_VERTICAL_CHROME_PX = Object.freeze({ body: 12, header: 39, diagram: 75 });
|
||||
|
||||
// A canvas the Reader can neither narrow (viewBox ratio below the wide
|
||||
// threshold) nor scroll readably (no intrinsic-height fit) renders at the full
|
||||
// reader width, so its page height is a function of the viewBox alone. Returns
|
||||
// null when the Reader has a way to fit the page; otherwise the certain
|
||||
// overflow at 1440x900 before any cards are counted.
|
||||
export function predictedFixedWidthOverflow({
|
||||
viewBoxWidth,
|
||||
viewBoxHeight,
|
||||
readerFit,
|
||||
diagramType,
|
||||
viewport = DESKTOP_READABILITY_VIEWPORT,
|
||||
bodyHorizontalPx = DECLARED_WIDE_REFERENCE_BODY_HORIZONTAL_PX,
|
||||
diagramHorizontalPx = DECLARED_WIDE_REFERENCE_DIAGRAM_HORIZONTAL_PX,
|
||||
chrome = DESKTOP_FIXED_VERTICAL_CHROME_PX,
|
||||
} = {}) {
|
||||
if (![viewBoxWidth, viewBoxHeight].every(Number.isFinite) || viewBoxWidth <= 0 || viewBoxHeight <= 0) return null;
|
||||
const ratio = viewBoxWidth / viewBoxHeight;
|
||||
if (readerFit === 'intrinsic-height'
|
||||
|| (readerFit === 'authored-height' && diagramType === 'architecture')
|
||||
|| ratio >= DECLARED_WIDE_READER_RATIO) return null;
|
||||
const svgWidthPx = viewport.width - bodyHorizontalPx - diagramHorizontalPx;
|
||||
const svgHeightPx = Math.round(svgWidthPx * viewBoxHeight / viewBoxWidth);
|
||||
const fixedChromePx = chrome.body + chrome.header + chrome.diagram;
|
||||
const pageHeightPx = svgHeightPx + fixedChromePx;
|
||||
if (pageHeightPx <= viewport.height) return null;
|
||||
return {
|
||||
viewportWidth: viewport.width,
|
||||
viewportHeight: viewport.height,
|
||||
ratio: Math.round(ratio * 100) / 100,
|
||||
wideRatio: DECLARED_WIDE_READER_RATIO,
|
||||
svgWidthPx,
|
||||
svgHeightPx,
|
||||
fixedChromePx,
|
||||
pageHeightPx,
|
||||
overflowPx: pageHeightPx - viewport.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeFixedWidthOverflow(issue) {
|
||||
const maximumViewBoxHeight = Math.floor(issue.viewBoxWidth / issue.wideRatio);
|
||||
const wideViewBoxWidth = Math.ceil(issue.viewBoxHeight * issue.wideRatio);
|
||||
return `Preserve every node, relationship, and label. This ${issue.viewBoxWidth}x${issue.viewBoxHeight} canvas (ratio ${issue.ratio}) declares no intrinsic-height fit and is below the ${issue.wideRatio} wide ratio, so the desktop Reader can neither narrow it nor accept vertical scroll: it renders ${issue.svgHeightPx}px tall at the full ${issue.svgWidthPx}px width and the page reaches ${issue.pageHeightPx}px before cards against ${issue.viewportHeight}px, a certain visual-check failure. Either compact vertical spacing so meta.viewBox height is at most ${maximumViewBoxHeight} at this width, or spread content sideways so the width is at least ${wideViewBoxWidth} at this height; for architecture, omitting meta.viewBox lets the renderer size the canvas and declare the fit.`;
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const DIAGNOSTIC_MODE = process.env.ARCHIFY_DIAGNOSTIC_FORMAT === 'json';
|
||||
const recorded = [];
|
||||
const recordedMessages = new Set();
|
||||
const boundaryKey = Symbol.for('archify.renderer-diagnostic-boundary');
|
||||
let recordingSuppressionDepth = 0;
|
||||
|
||||
function plainObject(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
||||
}
|
||||
|
||||
function normalizedDiagnostic(diagnostic) {
|
||||
const message = String(diagnostic?.message || 'Archify could not classify this failure.').trim();
|
||||
return {
|
||||
code: String(diagnostic?.code || 'internal/unclassified'),
|
||||
severity: diagnostic?.severity === 'warning' ? 'warning' : 'error',
|
||||
message,
|
||||
subject: plainObject(diagnostic?.subject),
|
||||
evidence: plainObject(diagnostic?.evidence),
|
||||
supportedFixes: Array.isArray(diagnostic?.supportedFixes)
|
||||
? [...new Set(diagnostic.supportedFixes.map((fix) => String(fix).trim()).filter(Boolean))]
|
||||
: [],
|
||||
...(Array.isArray(diagnostic?.suppresses) ? {
|
||||
suppresses: [...new Set(diagnostic.suppresses.map((code) => String(code).trim()).filter(Boolean))],
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function recordDiagnostic(diagnostic) {
|
||||
if (!DIAGNOSTIC_MODE || recordingSuppressionDepth > 0) return;
|
||||
const normalized = normalizedDiagnostic(diagnostic);
|
||||
if (recordedMessages.has(normalized.message)) return;
|
||||
recordedMessages.add(normalized.message);
|
||||
recorded.push(normalized);
|
||||
}
|
||||
|
||||
export function withDiagnosticRecordingSuppressed(callback) {
|
||||
recordingSuppressionDepth += 1;
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
recordingSuppressionDepth -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function throwDiagnosticError(message, diagnostics) {
|
||||
for (const diagnostic of diagnostics || []) recordDiagnostic(diagnostic);
|
||||
const error = new Error(message);
|
||||
error.archifyDiagnostics = (diagnostics || []).map(normalizedDiagnostic);
|
||||
throw error;
|
||||
}
|
||||
|
||||
export function throwDiagnosticProblems(prefix, problems, { code = 'layout/constraint', subject = {}, diagnostics: details = [] } = {}) {
|
||||
const messages = (problems || []).map((problem) => String(problem));
|
||||
const byMessage = new Map(details.map((entry) => [entry.message, entry]));
|
||||
const diagnostics = messages.map((message) => normalizedDiagnostic(byMessage.get(message) || {
|
||||
code,
|
||||
severity: 'error',
|
||||
message,
|
||||
subject,
|
||||
evidence: {},
|
||||
supportedFixes: [],
|
||||
}));
|
||||
throwDiagnosticError(`${prefix}:\n- ${messages.join('\n- ')}`, diagnostics);
|
||||
}
|
||||
|
||||
function fallbackDiagnostic(error) {
|
||||
const input = process.argv[2] ? path.resolve(process.argv[2]) : undefined;
|
||||
return normalizedDiagnostic({
|
||||
code: 'internal/unclassified',
|
||||
severity: 'error',
|
||||
message: error?.message || 'Renderer failed without a diagnostic.',
|
||||
subject: { input },
|
||||
evidence: { errorName: error?.name || 'Error' },
|
||||
supportedFixes: [],
|
||||
});
|
||||
}
|
||||
export function rendererFailure(error) {
|
||||
const attached = Array.isArray(error?.archifyDiagnostics)
|
||||
? error.archifyDiagnostics.map(normalizedDiagnostic)
|
||||
: [];
|
||||
// Earlier diagnostics do not classify a later, unrelated implementation error.
|
||||
const diagnostics = attached.length
|
||||
? (recorded.length ? recorded : attached)
|
||||
: [fallbackDiagnostic(error)];
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
ok: false,
|
||||
source: 'renderer',
|
||||
error: error?.message || 'Renderer failed without a diagnostic.',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
// Match the public CLI's text format without making its standalone doctor
|
||||
// bootstrap depend on this renderer runtime being present.
|
||||
function formatDiagnostics(error, diagnostics = []) {
|
||||
if (!diagnostics.length) return error;
|
||||
return [
|
||||
error,
|
||||
...diagnostics.map((entry) => {
|
||||
const fix = entry.supportedFixes?.length ? ` Fix: ${entry.supportedFixes.join('; ')}.` : '';
|
||||
return `[${entry.code}] ${entry.message}${fix}`;
|
||||
}),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const readerSignal = new Int32Array(new SharedArrayBuffer(4));
|
||||
|
||||
function waitForReader() {
|
||||
// Sleep instead of spinning on EAGAIN. A retry budget looks like a safeguard
|
||||
// and behaves like a truncation gate: a spinning loop burns thousands of
|
||||
// attempts in a few milliseconds, so a reader that is merely slow to start
|
||||
// exhausts it and loses the tail of the receipt. Waiting costs nothing while
|
||||
// the reader catches up, and a reader that goes away raises EPIPE, which the
|
||||
// caller already treats as a real write failure.
|
||||
Atomics.wait(readerSignal, 0, 0, 1);
|
||||
}
|
||||
|
||||
export function installRendererDiagnosticBoundary() {
|
||||
if (globalThis[boundaryKey]) return;
|
||||
globalThis[boundaryKey] = true;
|
||||
if (!DIAGNOSTIC_MODE) {
|
||||
process.once('uncaughtException', (error) => {
|
||||
// Only errors classified at their operation boundary are author-facing.
|
||||
// Preserve Node's debugging information for unexpected implementation errors.
|
||||
if (!error?.archifyDiagnostics?.length) {
|
||||
// The once-listener is already removed. Rethrow outside the exception
|
||||
// handler so Node retains its normal stack and exit code (not code 7).
|
||||
process.nextTick(() => { throw error; });
|
||||
return;
|
||||
}
|
||||
const payload = `${formatDiagnostics(error.message, error.archifyDiagnostics)}\n`;
|
||||
process.stderr.once('error', () => process.exit(1));
|
||||
process.stderr.write(payload, () => process.exit(1));
|
||||
});
|
||||
return;
|
||||
}
|
||||
process.on('uncaughtException', (error) => {
|
||||
const payload = `${JSON.stringify(rendererFailure(error))}\n`;
|
||||
try {
|
||||
// stderr may be a pipe. fs.writeSync performs a PARTIAL write once the
|
||||
// payload exceeds the OS pipe buffer (8KB on macOS) and returns the byte
|
||||
// count actually written. Ignoring that return value silently truncated
|
||||
// large diagnostic payloads mid-JSON, so the parent CLI's JSON.parse
|
||||
// failed and the fail-closed boundary reported internal/unclassified
|
||||
// instead of the diagnostics we had already computed. Loop until drained.
|
||||
// A full pipe also makes writeSync throw EAGAIN; wait for the reader
|
||||
// rather than treat it as a stream failure, otherwise the tail is
|
||||
// dropped just the same.
|
||||
const buffer = Buffer.from(payload, 'utf8');
|
||||
let written = 0;
|
||||
while (written < buffer.length) {
|
||||
try {
|
||||
written += fs.writeSync(process.stderr.fd, buffer, written, buffer.length - written);
|
||||
} catch (writeError) {
|
||||
if (writeError?.code === 'EAGAIN') {
|
||||
waitForReader();
|
||||
continue;
|
||||
}
|
||||
throw writeError;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// The renderer is already failing. Avoid replacing its real error with a
|
||||
// secondary stream failure; the parent CLI still has the exit status.
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { throwDiagnosticError } from './diagnostics.mjs';
|
||||
|
||||
const DEPLOYMENT_PROFILE = 'deployment-ownership';
|
||||
const DEPLOYMENT_BOUNDARY_KINDS = new Set(['region', 'security-group']);
|
||||
const PRIVATE_STATE_TYPES = new Set(['database']);
|
||||
|
||||
function subject(collection, index, item = {}) {
|
||||
return {
|
||||
diagramType: 'architecture',
|
||||
profile: DEPLOYMENT_PROFILE,
|
||||
collection,
|
||||
index,
|
||||
...(item.id ? { id: item.id } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function membership(boundaries, componentId, kind) {
|
||||
return boundaries
|
||||
.map((boundary, index) => ({ boundary, index }))
|
||||
.filter(({ boundary }) => boundary.kind === kind && boundary.wraps.includes(componentId));
|
||||
}
|
||||
|
||||
export function deploymentOwnershipDiagnostics(diagram) {
|
||||
const components = Array.isArray(diagram.components) ? diagram.components : [];
|
||||
const boundaries = (Array.isArray(diagram.boundaries) ? diagram.boundaries : [])
|
||||
.map((boundary) => ({ ...boundary, wraps: Array.isArray(boundary.wraps) ? boundary.wraps : [] }));
|
||||
const connections = Array.isArray(diagram.connections) ? diagram.connections : [];
|
||||
const diagnostics = [];
|
||||
|
||||
for (const kind of DEPLOYMENT_BOUNDARY_KINDS) {
|
||||
const count = boundaries.filter((boundary) => boundary.kind === kind).length;
|
||||
if (count > 0) continue;
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-boundary-kind',
|
||||
severity: 'error',
|
||||
message: `Deployment ownership requires at least one ${kind} boundary.`,
|
||||
subject: subject('boundaries', -1),
|
||||
evidence: { requiredKind: kind, found: count },
|
||||
supportedFixes: [`add one ${kind} boundary with an explicit wraps list`],
|
||||
});
|
||||
}
|
||||
|
||||
components.forEach((component, index) => {
|
||||
if (component.type === 'external') return;
|
||||
if (typeof component.tag !== 'string' || component.tag.trim() === '') {
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-owner-missing',
|
||||
severity: 'error',
|
||||
message: `Deployment component ${JSON.stringify(component.id)} does not name its owner in tag.`,
|
||||
subject: subject('components', index, component),
|
||||
evidence: { componentType: component.type, ownerField: 'tag' },
|
||||
supportedFixes: [`set /components/${index}/tag to the responsible team or owner`],
|
||||
});
|
||||
}
|
||||
|
||||
const regions = membership(boundaries, component.id, 'region');
|
||||
if (regions.length === 0) {
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-region-scope',
|
||||
severity: 'error',
|
||||
message: `Deployment component ${JSON.stringify(component.id)} is not assigned to a region boundary.`,
|
||||
subject: subject('components', index, component),
|
||||
evidence: { componentType: component.type, regionMemberships: 0 },
|
||||
supportedFixes: ['add the component id to the real region boundary wraps list'],
|
||||
});
|
||||
} else if (regions.length > 1) {
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-region-ambiguous',
|
||||
severity: 'error',
|
||||
message: `Deployment component ${JSON.stringify(component.id)} belongs to more than one region boundary.`,
|
||||
subject: subject('components', index, component),
|
||||
evidence: {
|
||||
componentType: component.type,
|
||||
regions: regions.map(({ boundary, index: boundaryIndex }) => ({ boundaryIndex, label: boundary.label })),
|
||||
},
|
||||
supportedFixes: ['keep the component id in exactly one real region boundary wraps list'],
|
||||
});
|
||||
}
|
||||
|
||||
if (PRIVATE_STATE_TYPES.has(component.type)) {
|
||||
const privateScopes = membership(boundaries, component.id, 'security-group');
|
||||
if (privateScopes.length === 0) {
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-private-state',
|
||||
severity: 'error',
|
||||
message: `Stateful component ${JSON.stringify(component.id)} is not assigned to a private security-group boundary.`,
|
||||
subject: subject('components', index, component),
|
||||
evidence: { componentType: component.type, privateMemberships: 0 },
|
||||
supportedFixes: ['add the component id to the real private security-group boundary wraps list'],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
boundaries.forEach((boundary, index) => {
|
||||
if (boundary.kind !== 'security-group') return;
|
||||
const members = boundary.wraps.map((id) => ({
|
||||
id,
|
||||
regions: membership(boundaries, id, 'region').map(({ boundary: region, index: boundaryIndex }) => ({
|
||||
boundaryIndex,
|
||||
label: region.label,
|
||||
})),
|
||||
}));
|
||||
const regionIndexes = new Set(members.flatMap((member) => member.regions.map((region) => region.boundaryIndex)));
|
||||
const consistent = members.length > 0
|
||||
&& members.every((member) => member.regions.length === 1)
|
||||
&& regionIndexes.size === 1;
|
||||
if (consistent) return;
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-private-region-consistency',
|
||||
severity: 'error',
|
||||
message: `Private boundary ${JSON.stringify(boundary.label)} must contain components from exactly one shared region.`,
|
||||
subject: subject('boundaries', index, boundary),
|
||||
evidence: { boundaryKind: boundary.kind, members },
|
||||
supportedFixes: ['assign every private-boundary component to exactly one shared region boundary'],
|
||||
});
|
||||
});
|
||||
|
||||
connections.forEach((connection, index) => {
|
||||
const crossedBoundaries = boundaries
|
||||
.map((boundary, boundaryIndex) => ({
|
||||
boundaryIndex,
|
||||
kind: boundary.kind,
|
||||
label: boundary.label,
|
||||
fromInside: boundary.wraps.includes(connection.from),
|
||||
toInside: boundary.wraps.includes(connection.to),
|
||||
}))
|
||||
.filter((boundary) => DEPLOYMENT_BOUNDARY_KINDS.has(boundary.kind) && boundary.fromInside !== boundary.toInside);
|
||||
if (crossedBoundaries.length === 0 || (typeof connection.label === 'string' && connection.label.trim() !== '')) return;
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-crossing-mechanism',
|
||||
severity: 'error',
|
||||
message: `Cross-boundary connection ${JSON.stringify(connection.id || `${connection.from}->${connection.to}`)} does not name its mechanism.`,
|
||||
subject: subject('connections', index, connection),
|
||||
evidence: {
|
||||
from: connection.from,
|
||||
to: connection.to,
|
||||
crossedBoundaries: crossedBoundaries.map(({ boundaryIndex, kind, label }) => ({ boundaryIndex, kind, label })),
|
||||
},
|
||||
supportedFixes: [`set /connections/${index}/label to the real cross-boundary mechanism`],
|
||||
});
|
||||
});
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export function validateEngineeringProfile(diagramType, diagram) {
|
||||
const profile = diagram.meta?.engineering_profile;
|
||||
if (!profile) return;
|
||||
if (diagramType !== 'architecture' || profile !== DEPLOYMENT_PROFILE) return;
|
||||
const diagnostics = deploymentOwnershipDiagnostics(diagram);
|
||||
if (!diagnostics.length) return;
|
||||
throwDiagnosticError(
|
||||
`Engineering profile ${JSON.stringify(profile)} failed:\n${diagnostics.map((entry) => `- ${entry.message}`).join('\n')}`,
|
||||
diagnostics,
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1941
File diff suppressed because it is too large
Load Diff
+603
@@ -0,0 +1,603 @@
|
||||
export const SUPPORTED_LOCALES = ['en', 'zh-CN'];
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
|
||||
const ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (character) => ESCAPE_MAP[character]);
|
||||
}
|
||||
|
||||
// One catalog feeds renderer-time SVG/HTML copy and the selected runtime
|
||||
// catalog embedded in each standalone artifact. Keeping both built-in locales in one
|
||||
// tuple makes missing translations impossible to hide behind an English
|
||||
// fallback during development.
|
||||
const MESSAGE_PAIRS = {
|
||||
'page.title': ['{title} Diagram', '{title}'],
|
||||
'diagram.description.architecture': ['An architecture diagram generated by Archify.', '由 Archify 生成的架构图。'],
|
||||
'diagram.description.workflow': ['A workflow diagram generated by Archify.', '由 Archify 生成的工作流图。'],
|
||||
'diagram.description.sequence': ['A sequence diagram generated by Archify.', '由 Archify 生成的时序图。'],
|
||||
'diagram.description.dataflow': ['A data-flow diagram generated by Archify.', '由 Archify 生成的数据流图。'],
|
||||
'diagram.description.lifecycle': ['A lifecycle diagram generated by Archify.', '由 Archify 生成的生命周期图。'],
|
||||
'node.focus': ['Focus {label}', '聚焦{label}'],
|
||||
'node.focus.detail': ['Focus {label}, {detail}', '聚焦{label},{detail}'],
|
||||
'node.context.architecture': ['Architecture component', '架构组件'],
|
||||
'node.context.workflow': ['Workflow node', '工作流节点'],
|
||||
'node.context.sequence': ['Sequence participant', '时序参与者'],
|
||||
'node.context.dataflow': ['Data-flow node', '数据流节点'],
|
||||
'node.context.lifecycle': ['Lifecycle state', '生命周期状态'],
|
||||
'legend.title': ['Legend', '图例'],
|
||||
|
||||
'legend.architecture.frontend': ['Frontend', '前端'],
|
||||
'legend.architecture.backend': ['Backend', '后端'],
|
||||
'legend.architecture.database': ['Database', '数据库'],
|
||||
'legend.architecture.cloud': ['Cloud', '云服务'],
|
||||
'legend.architecture.security': ['Security', '安全'],
|
||||
'legend.architecture.messagebus': ['Message bus', '消息总线'],
|
||||
'legend.architecture.external': ['External', '外部系统'],
|
||||
'legend.workflow.frontend': ['User UI', '用户界面'],
|
||||
'legend.workflow.backend': ['Agent logic', 'Agent 逻辑'],
|
||||
'legend.workflow.security': ['Policy', '策略'],
|
||||
'legend.workflow.messagebus': ['Tool action', '工具操作'],
|
||||
'legend.workflow.database': ['Context / trace', '上下文 / 追踪'],
|
||||
'legend.workflow.cloud': ['Cloud service', '云服务'],
|
||||
'legend.workflow.external': ['External system', '外部系统'],
|
||||
'legend.sequence.emphasis': ['main request', '主路径请求'],
|
||||
'legend.sequence.return': ['return', '返回'],
|
||||
'legend.sequence.security': ['security', '安全'],
|
||||
'legend.sequence.dashed': ['async trace', '异步追踪'],
|
||||
'legend.sequence.default': ['message', '普通消息'],
|
||||
'legend.dataflow.emphasis': ['primary data', '主要数据'],
|
||||
'legend.dataflow.security': ['policy / PII', '策略 / PII'],
|
||||
'legend.dataflow.dashed': ['async batch', '异步批处理'],
|
||||
'legend.dataflow.database': ['data store', '数据存储'],
|
||||
'legend.dataflow.default': ['data flow', '数据流'],
|
||||
'legend.lifecycle.start': ['initial state', '起点'],
|
||||
'legend.lifecycle.active': ['active', '进行中'],
|
||||
'legend.lifecycle.waiting': ['waiting', '等待'],
|
||||
'legend.lifecycle.decision': ['decision', '决策'],
|
||||
'legend.lifecycle.success': ['success', '成功'],
|
||||
'legend.lifecycle.failure': ['failure / exit', '失败 / 退出'],
|
||||
'legend.lifecycle.neutral': ['neutral', '中性状态'],
|
||||
'legend.lifecycle.external': ['external', '外部状态'],
|
||||
'legend.lifecycle.final': ['final state', '终态'],
|
||||
|
||||
'viewer.kind.frontend': ['Frontend', '前端'],
|
||||
'viewer.kind.backend': ['Backend', '后端'],
|
||||
'viewer.kind.database': ['Database', '数据库'],
|
||||
'viewer.kind.cloud': ['Cloud', '云服务'],
|
||||
'viewer.kind.security': ['Security', '安全'],
|
||||
'viewer.kind.messagebus': ['Message bus', '消息总线'],
|
||||
'viewer.kind.external': ['External', '外部系统'],
|
||||
'viewer.kind.neutral': ['Neutral', '中性'],
|
||||
'viewer.kind.node': ['Node', '节点'],
|
||||
'viewer.kind.start': ['Start', '开始'],
|
||||
'viewer.kind.active': ['Active', '活动'],
|
||||
'viewer.kind.waiting': ['Waiting', '等待'],
|
||||
'viewer.kind.decision': ['Decision', '决策'],
|
||||
'viewer.kind.success': ['Success', '成功'],
|
||||
'viewer.kind.failure': ['Failure', '失败'],
|
||||
|
||||
'viewer.toolbar.actions': ['Diagram actions', '图表操作'],
|
||||
'viewer.theme.toggle.title': ['Toggle theme (T)', '切换主题(T)'],
|
||||
'viewer.theme.toggle': ['Toggle color theme', '切换颜色主题'],
|
||||
'viewer.theme.dark': ['Dark', '深色'],
|
||||
'viewer.theme.light': ['Light', '浅色'],
|
||||
'viewer.preset.choose.title': ['Choose visual style (S cycles)', '选择视觉风格(S 循环切换)'],
|
||||
'viewer.preset.choose': ['Choose visual style', '选择视觉风格'],
|
||||
'viewer.preset.style': ['Style', '风格'],
|
||||
'viewer.preset.menu': ['Visual style', '视觉风格'],
|
||||
'viewer.preset.identity': ['Visual identity', '视觉表达'],
|
||||
'viewer.preset.cycles': ['to cycle', '循环切换'],
|
||||
'viewer.preset.classic': ['Classic', '经典'],
|
||||
'viewer.preset.classic.short': ['Classic', '经典'],
|
||||
'viewer.preset.classic.hint': ['Stable technical default', '稳定的技术默认风格'],
|
||||
'viewer.preset.flow': ['Signal Flow', '信号流'],
|
||||
'viewer.preset.flow.short': ['Flow', '流动'],
|
||||
'viewer.preset.flow.hint': ['Motion-forward presentation', '突出动态流向'],
|
||||
'viewer.preset.blueprint': ['Blueprint', '蓝图'],
|
||||
'viewer.preset.blueprint.hint': ['Engineering review', '工程评审'],
|
||||
'viewer.preset.editorial': ['Editorial', '编辑风格'],
|
||||
'viewer.preset.editorial.hint': ['Publication and launch notes', '适合发布与上线说明'],
|
||||
'viewer.preset.badge.signalFlow': ['SIGNAL FLOW', '信号流'],
|
||||
'viewer.preset.badge.blueprint': ['BLUEPRINT / REV 01', '蓝图 / 修订 01'],
|
||||
'viewer.preset.badge.editorial': ['EDITORIAL / FIELD NOTE', '编辑风格 / 现场笔记'],
|
||||
'viewer.preset.badge.editorialPlate': ['ARCHIFY / PLATE 04', 'ARCHIFY / 图版 04'],
|
||||
'viewer.preset.current': ['Visual style: {style}. Choose visual style', '当前视觉风格:{style}。选择视觉风格'],
|
||||
'viewer.motion.live': ['Live', '动态'],
|
||||
'viewer.motion.still': ['Still', '静态'],
|
||||
'viewer.motion.pause': ['Pause motion', '暂停动效'],
|
||||
'viewer.motion.resume': ['Resume motion', '恢复动效'],
|
||||
'viewer.motion.reduced': ['Motion paused by reduced-motion preference', '已根据减少动态效果偏好暂停动效'],
|
||||
'viewer.motion.hidden': ['Motion paused while this page is hidden', '页面不可见时已暂停动效'],
|
||||
'viewer.motion.yielding': ['Pause motion; currently yielding to {owner}', '暂停动效;当前让位于{owner}'],
|
||||
'viewer.motion.yielding.title': ['Live preview enabled · yielding to {owner}', '动态预览已启用 · 正在让位于{owner}'],
|
||||
'viewer.owner.route': ['Route Probe', '路径探测'],
|
||||
'viewer.owner.lens': ['Semantic Lens', '语义透镜'],
|
||||
'viewer.owner.relationship': ['Relationship Preview', '关系预览'],
|
||||
'viewer.owner.intent': ['Intent Trace', '意图追踪'],
|
||||
'viewer.owner.focus': ['semantic focus', '语义聚焦'],
|
||||
'viewer.owner.legend': ['legend preview', '图例预览'],
|
||||
'viewer.owner.reader': ['reader interaction', '读者交互'],
|
||||
'viewer.present.enter': ['Enter presentation stage', '进入演示模式'],
|
||||
'viewer.present.enter.title': ['Presentation stage (F)', '演示模式(F)'],
|
||||
'viewer.present.exit': ['Exit presentation stage', '退出演示模式'],
|
||||
'viewer.present.exit.title': ['Exit presentation stage (F or Escape)', '退出演示模式(F 或 Escape)'],
|
||||
'viewer.present.present': ['Present', '演示'],
|
||||
'viewer.present.exit.label': ['Exit', '退出'],
|
||||
|
||||
'viewer.export.button': ['Export', '导出'],
|
||||
'viewer.export.button.title': ['Export diagram (E)', '导出图表(E)'],
|
||||
'viewer.export.diagram': ['Export diagram', '导出图表'],
|
||||
'viewer.export.menu': ['Export', '导出'],
|
||||
'viewer.export.subtitle': ['Portable, clean outputs', '便携、整洁的输出'],
|
||||
'viewer.export.share': ['Share', '分享'],
|
||||
'viewer.export.shareCard': ['Share Card', '分享卡片'],
|
||||
'viewer.export.routeShareCard': ['Route Share Card', '路径分享卡片'],
|
||||
'viewer.export.reachShareCard': ['Reach Share Card', '可达范围分享卡片'],
|
||||
'viewer.export.copyDiagram': ['Copy diagram', '复制图表'],
|
||||
'viewer.export.clipboardPng': ['PNG to clipboard', '复制 PNG 到剪贴板'],
|
||||
'viewer.export.raster': ['Raster images', '位图'],
|
||||
'viewer.export.image': ['Image', '图像'],
|
||||
'viewer.export.lossless': ['Lossless image', '无损图像'],
|
||||
'viewer.export.compact': ['Compact image', '紧凑图像'],
|
||||
'viewer.export.modern': ['Modern image', '现代图像格式'],
|
||||
'viewer.export.vectorMotion': ['Vector and motion', '矢量与动效'],
|
||||
'viewer.export.vectorMotion.heading': ['Vector & motion', '矢量与动效'],
|
||||
'viewer.export.editable': ['Editable vector', '可编辑矢量图'],
|
||||
'viewer.export.svg.auto': ['SVG · Auto', 'SVG · 自动'],
|
||||
'viewer.export.svg.auto.hint': ['Matches host theme', '匹配宿主主题'],
|
||||
'viewer.export.svg.light': ['SVG · Light', 'SVG · 浅色'],
|
||||
'viewer.export.svg.light.hint': ['Always light', '始终浅色'],
|
||||
'viewer.export.svg.dark': ['SVG · Dark', 'SVG · 深色'],
|
||||
'viewer.export.svg.dark.hint': ['Always dark', '始终深色'],
|
||||
'viewer.export.motion6s': ['6s motion', '6 秒动效'],
|
||||
'viewer.export.unsupported': ['Not supported by this browser', '当前浏览器不支持'],
|
||||
'viewer.export.clipboardUnsupported': ['Clipboard image write not supported by this browser', '当前浏览器不支持写入图片剪贴板'],
|
||||
'viewer.export.clipboardUnsupported.short': ['Clipboard image write not supported in this browser.', '此浏览器不支持写入图片剪贴板。'],
|
||||
'viewer.export.motionUnavailable': ['Motion capture unavailable in this browser', '当前浏览器无法录制动效'],
|
||||
'viewer.export.webmUnavailable': ['WebM unavailable in this browser', '当前浏览器不支持 WebM'],
|
||||
'viewer.export.failed': ['Export failed: {message}', '导出失败:{message}'],
|
||||
'viewer.export.unknownVariant': ['Unknown Share Card variant: {variant}', '未知的分享卡片类型:{variant}'],
|
||||
'viewer.export.routeRequired': ['Trace a route before exporting a Route Share Card', '请先追踪路径,再导出路径分享卡片'],
|
||||
'viewer.export.reachRequired': ['Trace authored reach before exporting a Reach Share Card', '请先追踪编写可达范围,再导出可达范围分享卡片'],
|
||||
'viewer.export.unknown': ['unknown', '未知错误'],
|
||||
'viewer.export.routeFailed': ['Route Share Card export failed: {message}', '路径分享卡片导出失败:{message}'],
|
||||
'viewer.export.reachFailed': ['Reach Share Card export failed: {message}', '可达范围分享卡片导出失败:{message}'],
|
||||
'viewer.export.copyFailed': ['Copy failed: {message}', '复制失败:{message}'],
|
||||
'viewer.export.copiedPng': ['Copied PNG to clipboard', '已将 PNG 复制到剪贴板'],
|
||||
'viewer.export.downloadedRoute': ['Downloaded Route Share Card', '已下载路径分享卡片'],
|
||||
'viewer.export.downloadedReach': ['Downloaded Reach Share Card', '已下载可达范围分享卡片'],
|
||||
'viewer.export.downloadedWebm': ['Downloaded WebM', '已下载 WebM'],
|
||||
'viewer.export.recording': ['Recording 6 seconds of motion…', '正在录制 6 秒动效…'],
|
||||
'viewer.export.card.routeSummary.one': ['Path: {source} → {target} · {count} step', '路径:{source} → {target} · {count} 步'],
|
||||
'viewer.export.card.routeSummary.other': ['Path: {source} → {target} · {count} steps', '路径:{source} → {target} · {count} 步'],
|
||||
'viewer.export.card.reachSummary': ['Authored {direction} from {origin} · {nodes} · {links} · max {hops}', '从{origin}开始的编写{direction} · {nodes} · {links} · 最深 {hops}'],
|
||||
'viewer.export.card.node.one': ['{count} node', '{count} 个节点'],
|
||||
'viewer.export.card.node.other': ['{count} nodes', '{count} 个节点'],
|
||||
'viewer.export.card.link.one': ['{count} link', '{count} 条连接'],
|
||||
'viewer.export.card.link.other': ['{count} links', '{count} 条连接'],
|
||||
'viewer.export.card.hop.one': ['{count} hop', '{count} 跳'],
|
||||
'viewer.export.card.hop.other': ['{count} hops', '{count} 跳'],
|
||||
'viewer.export.card.routeBadge': ['ARCHIFY · ROUTE · {hops}', 'ARCHIFY · 路径 · {hops}'],
|
||||
'viewer.export.card.reachBadge': ['ARCHIFY · {direction} REACH', 'ARCHIFY · {direction}可达范围'],
|
||||
'viewer.export.direction.upstream': ['Upstream', '上游'],
|
||||
'viewer.export.direction.downstream': ['Downstream', '下游'],
|
||||
'viewer.export.error.canvasUnavailable': ['Canvas unavailable for {label}', '无法为{label}使用画布'],
|
||||
'viewer.export.error.contextUnavailable': ['2D canvas context unavailable for {label}', '无法为{label}创建二维画布上下文'],
|
||||
'viewer.export.error.toBlobUnavailable': ['canvas.toBlob unavailable for {label}', '{label}无法使用 canvas.toBlob'],
|
||||
'viewer.export.error.toBlobNull': ['canvas.toBlob returned no data for {label}', '{label}的 canvas.toBlob 未返回数据'],
|
||||
'viewer.export.error.variantsCombined': ['Share Card variants cannot be combined', '无法同时组合多种分享卡片类型'],
|
||||
'viewer.export.error.viewerState': ['Share Card export could not remove temporary viewer state', '分享卡片导出无法移除临时 Viewer 状态'],
|
||||
'viewer.export.error.routeState': ['Route Card export could not preserve the resolved route safely', '路径卡片导出无法安全保留已解析路径'],
|
||||
'viewer.export.error.reachState': ['Reach Card export could not preserve authored reach safely', '可达范围卡片导出无法安全保留编写的可达范围'],
|
||||
'viewer.export.error.webmRequirements': ['WebM motion export requires a trace animation and browser MediaRecorder support', 'WebM 动效导出需要追踪动画及浏览器 MediaRecorder 支持'],
|
||||
'viewer.export.error.mediaRecorder': ['MediaRecorder failed', 'MediaRecorder 录制失败'],
|
||||
'viewer.export.error.emptyWebm': ['MediaRecorder produced an empty WebM', 'MediaRecorder 生成了空的 WebM'],
|
||||
'viewer.export.error.webmBackground': ['SVG background could not be loaded for WebM export', '无法为 WebM 导出加载 SVG 背景'],
|
||||
|
||||
'viewer.guide.title': ['Explore this system', '探索此系统'],
|
||||
'viewer.focus.selectedNodes': ['{count} selected nodes', '已选择 {count} 个节点'],
|
||||
|
||||
'viewer.guide.eyebrow': ['Diagram guide', '图表指南'],
|
||||
'viewer.guide.close': ['Close diagram guide', '关闭图表指南'],
|
||||
'viewer.guide.inspecting': ['Inspecting compiled semantics', '正在检查已编译语义'],
|
||||
'viewer.guide.actions': ['Diagram exploration actions', '图表探索操作'],
|
||||
'viewer.guide.find': ['Find any node', '查找任意节点'],
|
||||
'viewer.guide.find.hint': ['Search labels, responsibilities, kinds, and stable IDs.', '搜索标签、职责、类型和稳定 ID。'],
|
||||
'viewer.guide.route': ['Trace a route', '追踪路径'],
|
||||
'viewer.guide.route.aria': ['Trace a directed route', '追踪有向路径'],
|
||||
'viewer.guide.route.hint': ['Ask how two semantic nodes connect in authored direction.', '查看两个语义节点如何按编写方向连接。'],
|
||||
'viewer.guide.map': ['See the whole system', '查看完整系统'],
|
||||
'viewer.guide.map.hint': ['Open Semantic Radar with a live viewport and stable nodes.', '打开带实时视口和稳定节点的语义雷达。'],
|
||||
'viewer.guide.lens': ['Compare semantic kinds', '比较语义类型'],
|
||||
'viewer.guide.lens.hint': ['Count roles, reveal their traffic, and compare direct authored links.', '统计角色、显示流量并比较直接编写的连接。'],
|
||||
'viewer.guide.present': ['Enter Presentation Stage', '进入演示模式'],
|
||||
'viewer.guide.present.hint': ['Give the live diagram the viewport without changing export.', '让实时图表占满视口,同时不改变导出。'],
|
||||
'viewer.guide.shortcuts': ['Additional keyboard shortcuts', '其他键盘快捷键'],
|
||||
'viewer.guide.shortcut.export': ['Export', '导出'],
|
||||
'viewer.guide.shortcut.theme': ['Theme', '主题'],
|
||||
'viewer.guide.shortcut.style': ['Style', '风格'],
|
||||
'viewer.guide.shortcut.reset': ['Reset', '重置'],
|
||||
'viewer.guide.shortcut.zoomIn': ['Zoom in', '放大'],
|
||||
'viewer.guide.shortcut.zoomOut': ['Zoom out', '缩小'],
|
||||
'viewer.guide.shortcut.close': ['Close', '关闭'],
|
||||
'viewer.guide.facts': ['{nodes} · {relationships}', '{nodes} · {relationships}'],
|
||||
'viewer.guide.fact.node.one': ['{count} semantic node', '{count} 个语义节点'],
|
||||
'viewer.guide.fact.node.other': ['{count} semantic nodes', '{count} 个语义节点'],
|
||||
'viewer.guide.fact.relationship.one': ['{count} relationship', '{count} 条关系'],
|
||||
'viewer.guide.fact.relationship.other': ['{count} relationships', '{count} 条关系'],
|
||||
'viewer.guide.open': ['Open diagram guide', '打开图表指南'],
|
||||
|
||||
'viewer.finder.title': ['Find a node', '查找节点'],
|
||||
'viewer.finder.close': ['Close node finder', '关闭节点查找器'],
|
||||
'viewer.finder.placeholder': ['Search labels or IDs', '搜索标签或 ID'],
|
||||
'viewer.finder.search': ['Search diagram nodes', '搜索图表节点'],
|
||||
'viewer.finder.results': ['Diagram nodes', '图表节点'],
|
||||
'viewer.finder.empty': ['No matching nodes', '没有匹配的节点'],
|
||||
'viewer.finder.result.focus': ['Focus {label}', '聚焦{label}'],
|
||||
'viewer.finder.result.routeStart': ['Choose {label} as route start', '选择{label}作为路径起点'],
|
||||
'viewer.finder.result.routeTarget': ['Choose {label} as route destination, {links}', '选择{label}作为路径终点,{links}'],
|
||||
'viewer.finder.status.empty': ['No matching nodes', '没有匹配的节点'],
|
||||
'viewer.finder.status.count.one': ['{count} matching node', '{count} 个匹配节点'],
|
||||
'viewer.finder.status.count.other': ['{count} matching nodes', '{count} 个匹配节点'],
|
||||
'viewer.finder.noun.nodes': ['nodes', '个节点'],
|
||||
'viewer.finder.link.one': ['{count} link', '{count} 条连接'],
|
||||
'viewer.finder.link.other': ['{count} links', '{count} 条连接'],
|
||||
'viewer.finder.result.focus.one': ['Focus {label}, {count} related connection', '聚焦{label},{count} 条相关连接'],
|
||||
'viewer.finder.result.focus.other': ['Focus {label}, {count} related connections', '聚焦{label},{count} 条相关连接'],
|
||||
'viewer.finder.status.filtered': ['{visible} of {available} {noun}', '{visible}/{available} {noun}'],
|
||||
'viewer.finder.status.all': ['{available} {noun}', '{available} {noun}'],
|
||||
|
||||
'viewer.passport.eyebrow': ['Semantic passport', '语义护照'],
|
||||
'viewer.passport.metadata': ['Node metadata', '节点元数据'],
|
||||
'viewer.passport.evidence': ['Verified source evidence', '已验证的源代码证据'],
|
||||
'viewer.passport.verified': ['Verified source', '已验证来源'],
|
||||
'viewer.passport.verificationScope': ['Verified against local Git at the pinned revision. Remote access has not been checked.', '已按固定修订版本验证本地 Git 证据,未检查远程访问权限。'],
|
||||
'viewer.passport.reach': ['Authored reach', '编写可达范围'],
|
||||
'viewer.passport.reach.trace': ['Trace authored reachability', '追踪编写的可达性'],
|
||||
'viewer.passport.upstream': ['Upstream', '上游'],
|
||||
'viewer.passport.downstream': ['Downstream', '下游'],
|
||||
'viewer.passport.upstream.trace': ['Trace upstream authored reachability', '追踪上游编写可达性'],
|
||||
'viewer.passport.downstream.trace': ['Trace downstream authored reachability', '追踪下游编写可达性'],
|
||||
'viewer.passport.close': ['Close semantic passport', '关闭语义护照'],
|
||||
'viewer.passport.move': ['Move semantic passport. Drag, use arrow keys, or press Home to reset.', '移动语义护照。可拖动、使用方向键移动,或按 Home 恢复自动位置。'],
|
||||
'viewer.passport.copy': ['Copy link', '复制链接'],
|
||||
'viewer.passport.copy.focus': ['Copy link to focused node', '复制聚焦节点的链接'],
|
||||
'viewer.passport.relations': ['Relations', '关系'],
|
||||
'viewer.passport.relations.show': ['Show connected relationships', '显示关联关系'],
|
||||
'viewer.passport.relations.hide': ['Hide connected relationships', '隐藏关联关系'],
|
||||
'viewer.passport.relations.list': ['Connected relationships', '关联关系'],
|
||||
'viewer.passport.copyRelation': ['Copy relation', '复制关系'],
|
||||
'viewer.passport.copyNode': ['Copy node', '复制节点'],
|
||||
'viewer.passport.copyPinned': ['Copy link to pinned relationship', '复制固定关系的链接'],
|
||||
'viewer.passport.copySource': ['Copy link to source node', '复制来源节点的链接'],
|
||||
'viewer.passport.copy.focused.success': ['Focused node link copied', '已复制聚焦节点链接'],
|
||||
'viewer.passport.copy.pinned.success': ['Pinned relationship link copied', '已复制固定关系链接'],
|
||||
'viewer.passport.copy.focused.failed': ['Could not copy focused node link', '无法复制聚焦节点链接'],
|
||||
'viewer.passport.copy.pinned.failed': ['Could not copy pinned relationship link', '无法复制固定关系链接'],
|
||||
'viewer.passport.relationship.none': ['No connected relationships', '没有关联关系'],
|
||||
'viewer.passport.relationship.count.one': ['{count} relation', '{count} 条关系'],
|
||||
'viewer.passport.relationship.count.other': ['{count} relations', '{count} 条关系'],
|
||||
'viewer.passport.relationship.show.one': ['Show {count} connected relationship', '显示 {count} 条关联关系'],
|
||||
'viewer.passport.relationship.show.other': ['Show {count} connected relationships', '显示 {count} 条关联关系'],
|
||||
'viewer.passport.relationship.summary': ['{out} outgoing · {in} incoming{loops}', '{out} 条出向 · {in} 条入向{loops}'],
|
||||
'viewer.passport.relationship.loops': [' · {count} loop', ' · {count} 条自环'],
|
||||
'viewer.passport.relationship.explorer': ['Direct relationship explorer', '直接关系浏览器'],
|
||||
'viewer.passport.relationship.help': ['Use arrow keys to explore relationships. Press Enter or Space to pin details; Escape clears.', '使用方向键浏览关系。按 Enter 或空格键固定详情;按 Escape 清除。'],
|
||||
'viewer.passport.relationship.loopsBack': ['loops back', '回环'],
|
||||
'viewer.passport.relationship.connectsTo': ['connects to', '连接到'],
|
||||
'viewer.passport.relationship.connectsFrom': ['connects from', '连接自'],
|
||||
'viewer.passport.relationship.pinned': ['Pinned relationship · {from} → {to} · {label}', '已固定关系 · {from} → {to} · {label}'],
|
||||
'viewer.passport.relationship.inspect': ['Inspect relationship {index} of {total}: {from} to {to}, {label}. Press Enter for details.', '检查第 {index}/{total} 条关系:{from} 到 {to},{label}。按 Enter 查看详情。'],
|
||||
'viewer.passport.relationship.group.out': ['Outgoing', '出向'],
|
||||
'viewer.passport.relationship.group.in': ['Incoming', '入向'],
|
||||
'viewer.passport.relationship.group.loop': ['Self loops', '自环'],
|
||||
'viewer.passport.relationship.row': ['{group}: {relationship}, {neighbor}', '{group}:{relationship},{neighbor}'],
|
||||
'viewer.passport.relationship.direction.out': ['OUT →', '出 →'],
|
||||
'viewer.passport.relationship.direction.in': ['← IN', '← 入'],
|
||||
'viewer.passport.relationship.direction.loop': ['LOOP', '自环'],
|
||||
'viewer.passport.sourceCount.one': ['{count} verified source reference', '{count} 个已验证来源引用'],
|
||||
'viewer.passport.sourceCount.other': ['{count} verified source references', '{count} 个已验证来源引用'],
|
||||
'viewer.passport.sourceMarker': ['SRC', '来源'],
|
||||
'viewer.passport.beacon.one': ['{count} verified source; focus this node to inspect', '{count} 个已验证来源;聚焦此节点以检查'],
|
||||
'viewer.passport.beacon.other': ['{count} verified sources; focus this node to inspect', '{count} 个已验证来源;聚焦此节点以检查'],
|
||||
'viewer.passport.repository.open': ['Open verified repository revision {revision}', '打开已验证的仓库修订版本 {revision}'],
|
||||
'viewer.passport.source.open': ['Open verified source {path} at revision {revision}', '打开修订版本 {revision} 中已验证的来源 {path}'],
|
||||
'viewer.passport.source.openLink': ['Open ↗', '打开 ↗'],
|
||||
'viewer.passport.reach.upstream.one': ['Trace {count} upstream authored node', '追踪 {count} 个上游编写节点'],
|
||||
'viewer.passport.reach.upstream.other': ['Trace {count} upstream authored nodes', '追踪 {count} 个上游编写节点'],
|
||||
'viewer.passport.reach.downstream.one': ['Trace {count} downstream authored node', '追踪 {count} 个下游编写节点'],
|
||||
'viewer.passport.reach.downstream.other': ['Trace {count} downstream authored nodes', '追踪 {count} 个下游编写节点'],
|
||||
'viewer.passport.reach.noUpstream': ['No upstream authored nodes', '没有上游编写节点'],
|
||||
'viewer.passport.reach.noDownstream': ['No downstream authored nodes', '没有下游编写节点'],
|
||||
'viewer.passport.reach.status': ['{direction} · {nodes} nodes · {links} links · max {hops} hops', '{direction} · {nodes} 个节点 · {links} 条连接 · 最深 {hops} 跳'],
|
||||
|
||||
'viewer.route.eyebrow': ['Path', '路径'],
|
||||
'viewer.route.start': ['Click where the path starts', '点击路径的起点'],
|
||||
'viewer.route.start.find': ['Find start', '查找起点'],
|
||||
'viewer.route.start.find.aria': ['Find a route start', '查找路径起点'],
|
||||
'viewer.route.copy': ['Copy link', '复制链接'],
|
||||
'viewer.route.copy.aria': ['Copy link to traced route', '复制已追踪路径的链接'],
|
||||
'viewer.route.clear': ['Clear', '清除'],
|
||||
'viewer.route.clear.aria': ['Clear route probe', '清除路径探测'],
|
||||
'viewer.route.traced': ['Path', '路径'],
|
||||
'viewer.route.pickTwo': ['Click two nodes on the diagram', '在图上点两个节点'],
|
||||
'viewer.route.pickOne': ['Click a node on the diagram', '在图上点一个节点'],
|
||||
'viewer.route.controls': ['Route journey controls', '路径旅程控制'],
|
||||
'viewer.route.previous': ['Previous route position', '上一个路径位置'],
|
||||
'viewer.route.play': ['Play route journey', '播放路径旅程'],
|
||||
'viewer.route.pause': ['Pause route journey', '暂停路径旅程'],
|
||||
'viewer.route.replay': ['Replay route journey', '重播路径旅程'],
|
||||
'viewer.route.next': ['Next route position', '下一个路径位置'],
|
||||
'viewer.route.journey': ['Journey', '旅程'],
|
||||
'viewer.route.pause.label': ['Pause', '暂停'],
|
||||
'viewer.route.replay.label': ['Replay', '重播'],
|
||||
'viewer.route.overview': ['Overview', '总览'],
|
||||
'viewer.route.overview.aria': ['Show complete route overview', '显示完整路径总览'],
|
||||
'viewer.route.instructions': ['Click a start, then an end. Paths follow the arrows.', '先点起点,再点终点,沿箭头方向找路。'],
|
||||
'viewer.route.destination': ['Where does the path from {label} end?', '从{label}出发,走到哪里?'],
|
||||
'viewer.route.destination.find': ['Find target', '查找目标'],
|
||||
'viewer.route.destination.find.aria': ['Find a reachable route destination', '查找可达的路径目标'],
|
||||
'viewer.route.differentDestination': ['Choose a different destination', '选择其他目标'],
|
||||
'viewer.route.distinct': ['Pick two different nodes.', '请选两个不同的节点。'],
|
||||
'viewer.route.unreachable': ['Cannot reach {label} from here', '走不到{label}'],
|
||||
'viewer.route.unreachable.detail': ['{target} cannot be reached from {source} along the arrows. Pick a highlighted node.', '沿箭头方向,从{source}走不到{target}。请选一个高亮节点。'],
|
||||
'viewer.route.start.instructions': ['Next, pick the end. Only nodes you can reach will light up.', '接着选终点,能走到的节点会亮起。'],
|
||||
'viewer.route.copy.success': ['Traced route link copied', '已复制路径链接'],
|
||||
'viewer.route.copy.failed': ['Could not copy traced route link', '无法复制路径链接'],
|
||||
'viewer.route.position': ['Route position {index} of {total}: {label}', '路径位置 {index}/{total}:{label}'],
|
||||
'viewer.route.step': ['Step {index} of {total} · {phase} · {label}', '第 {index}/{total} 步 · {phase} · {label}'],
|
||||
'viewer.route.motionRequired': ['Automatic journey requires Live motion', '自动旅程需要动态模式'],
|
||||
'viewer.route.trigger.clear': ['Clear traced route', '清除已追踪路径'],
|
||||
'viewer.route.overview.status': ['{nodes} · {hops} · shortest path', '{nodes} · {hops} · 最短路径'],
|
||||
'viewer.route.overview.node.one': ['{count} node', '{count} 个节点'],
|
||||
'viewer.route.overview.node.other': ['{count} nodes', '{count} 个节点'],
|
||||
'viewer.route.overview.hop.one': ['{count} step', '{count} 步'],
|
||||
'viewer.route.overview.hop.other': ['{count} steps', '{count} 步'],
|
||||
'viewer.route.phase.playing': ['Playing', '播放中'],
|
||||
'viewer.route.phase.complete': ['Complete', '已完成'],
|
||||
'viewer.route.phase.inspecting': ['Inspecting', '检查中'],
|
||||
'viewer.route.destination.count.one': ['{count} reachable node is highlighted. Click it.', '有 {count} 个能走到的节点已高亮,点一下。'],
|
||||
'viewer.route.destination.count.other': ['{count} reachable nodes are highlighted. Click one.', '有 {count} 个能走到的节点已高亮,点一个。'],
|
||||
'viewer.route.noOutgoing': ['No arrows lead out of this node. Clear it and pick another start.', '这个节点没有向外的箭头。清除后换一个起点。'],
|
||||
'viewer.route.result.title': ['{source} to {target}', '{source} 到 {target}'],
|
||||
'viewer.route.finder.source.title': ['Choose route start', '选择路径起点'],
|
||||
'viewer.route.finder.source.placeholder': ['Search route sources', '搜索路径来源'],
|
||||
'viewer.route.finder.source.empty': ['No matching route sources', '没有匹配的路径来源'],
|
||||
'viewer.route.finder.source.results': ['Nodes that can start a route', '可作为路径起点的节点'],
|
||||
'viewer.route.finder.source.noun': ['route sources', '个路径来源'],
|
||||
'viewer.route.finder.source.badge': ['start', '起点'],
|
||||
'viewer.route.finder.target.title': ['Destination from {label}', '从{label}出发的目标'],
|
||||
'viewer.route.finder.target.placeholder': ['Search reachable destinations', '搜索可达目标'],
|
||||
'viewer.route.finder.target.empty': ['No matching reachable destinations', '没有匹配的可达目标'],
|
||||
'viewer.route.finder.target.results': ['Reachable route destinations', '可达路径目标'],
|
||||
'viewer.route.finder.target.noun': ['reachable destinations', '个可达目标'],
|
||||
'viewer.route.hop.one': ['{count} hop', '{count} 跳'],
|
||||
'viewer.route.hop.other': ['{count} hops', '{count} 跳'],
|
||||
|
||||
'viewer.lens.eyebrow': ['Semantic lens', '语义透镜'],
|
||||
'viewer.lens.title': ['Compare system roles', '比较系统角色'],
|
||||
'viewer.lens.close': ['Close semantic lens', '关闭语义透镜'],
|
||||
'viewer.lens.instruction': ['Choose up to two semantic kinds. One reveals its real traffic; two compare only direct authored relationships.', '最多选择两种语义类型。选择一种可显示其真实流量;选择两种只比较直接编写的关系。'],
|
||||
'viewer.lens.kinds': ['Semantic kinds', '语义类型'],
|
||||
'viewer.lens.choose': ['Choose a kind to inspect its nodes and touching relationships.', '选择一种类型以检查其节点和相连关系。'],
|
||||
'viewer.lens.copy': ['Copy link to semantic lens', '复制语义透镜链接'],
|
||||
'viewer.lens.clear': ['Clear semantic lens', '清除语义透镜'],
|
||||
'viewer.lens.open': ['Open semantic lens', '打开语义透镜'],
|
||||
'viewer.lens.openActive': ['Open active semantic lens', '打开当前语义透镜'],
|
||||
'viewer.lens.legend': ['Semantic legend', '语义图例'],
|
||||
'viewer.lens.legend.inspect.one': ['Inspect {label}, {count} node', '检查{label},{count} 个节点'],
|
||||
'viewer.lens.legend.inspect.other': ['Inspect {label}, {count} nodes', '检查{label},{count} 个节点'],
|
||||
'viewer.lens.kind.count.one': ['{label}, {count} node', '{label},{count} 个节点'],
|
||||
'viewer.lens.kind.count.other': ['{label}, {count} nodes', '{label},{count} 个节点'],
|
||||
'viewer.lens.compare.one': ['{first} → {second}: {forward} · {second} → {first}: {reverse} · {count} direct relationship', '{first} → {second}:{forward} · {second} → {first}:{reverse} · 共 {count} 条直接关系'],
|
||||
'viewer.lens.compare.other': ['{first} → {second}: {forward} · {second} → {first}: {reverse} · {count} direct relationships', '{first} → {second}:{forward} · {second} → {first}:{reverse} · 共 {count} 条直接关系'],
|
||||
'viewer.lens.single': ['{nodes} · {relationships} · connected peers remain visible', '{nodes} · {relationships} · 已连接节点保持可见'],
|
||||
'viewer.lens.node.one': ['{count} {label} node', '{count} 个{label}节点'],
|
||||
'viewer.lens.node.other': ['{count} {label} nodes', '{count} 个{label}节点'],
|
||||
'viewer.lens.relationship.one': ['{count} touching relationship', '{count} 条相连关系'],
|
||||
'viewer.lens.relationship.other': ['{count} touching relationships', '{count} 条相连关系'],
|
||||
|
||||
'viewer.radar.title': ['Semantic radar', '语义雷达'],
|
||||
'viewer.radar.building': ['Building overview', '正在构建总览'],
|
||||
'viewer.radar.openFull': ['Open full semantic radar', '打开完整语义雷达'],
|
||||
'viewer.radar.open': ['Open radar', '打开雷达'],
|
||||
'viewer.radar.close': ['Close semantic radar', '关闭语义雷达'],
|
||||
'viewer.radar.surface': ['Diagram overview. Click a node to focus it, or use arrow keys to pan.', '图表总览。点击节点进行聚焦,或使用方向键平移。'],
|
||||
'viewer.radar.click': ['Click node', '点击节点'],
|
||||
'viewer.radar.drag': ['Drag to pan', '拖动平移'],
|
||||
'viewer.radar.space': ['Semantic radar needs more MAP space.', '语义雷达需要更多地图可见空间。'],
|
||||
'viewer.radar.nodes': ['Semantic diagram radar nodes', '语义图表雷达节点'],
|
||||
'viewer.radar.focus': ['Focus {label} from Semantic Radar', '从语义雷达聚焦{label}'],
|
||||
'viewer.radar.status': ['{count} nodes · {viewport}', '{count} 个节点 · {viewport}'],
|
||||
'viewer.radar.fullMap': ['{count} nodes · full map', '{count} 个节点 · 完整地图'],
|
||||
'viewer.radar.compacted': ['Radar compacted to avoid covering the Semantic Passport or MAP controls.', '已收紧雷达,避免遮挡语义护照或地图控件。'],
|
||||
'viewer.radar.cancelWaiting': ['Cancel semantic radar waiting for more MAP space', '取消等待更多地图空间的语义雷达'],
|
||||
'viewer.radar.needsSpace': ['Semantic radar needs more visible MAP space', '语义雷达需要更多可见地图空间'],
|
||||
'viewer.radar.viewport.full': ['full map', '完整地图'],
|
||||
'viewer.radar.viewport.width': ['{percent}% width', '宽度 {percent}%'],
|
||||
'viewer.radar.viewport.scale': ['{percent}% viewport', '视口 {percent}%'],
|
||||
|
||||
'viewer.nav.controls': ['Diagram view controls', '图表视图控制'],
|
||||
'viewer.nav.route': ['Trace a directed route', '追踪有向路径'],
|
||||
'viewer.nav.route.title': ['Trace route (R)', '追踪路径(R)'],
|
||||
'viewer.nav.route.short': ['PATH', '路径'],
|
||||
'viewer.nav.radar': ['Open semantic radar', '打开语义雷达'],
|
||||
'viewer.nav.radar.title': ['Semantic radar (M)', '语义雷达(M)'],
|
||||
'viewer.nav.radar.short': ['MAP', '地图'],
|
||||
'viewer.nav.lens': ['Open semantic lens', '打开语义透镜'],
|
||||
'viewer.nav.lens.title': ['Semantic lens (L)', '语义透镜(L)'],
|
||||
'viewer.nav.lens.short': ['LENS', '透镜'],
|
||||
'viewer.nav.find': ['Find a node', '查找节点'],
|
||||
'viewer.nav.find.title': ['Find a node (/)', '查找节点(/)'],
|
||||
'viewer.outline.title': ['Node index', '节点索引'],
|
||||
'viewer.rail.show': ['Notes & index', '要点与索引'],
|
||||
'viewer.rail.controls': ['Side panel', '侧栏'],
|
||||
'viewer.rail.collapse': ['Collapse side panel', '收起侧栏'],
|
||||
'viewer.rail.bottom': ['Move panel below the diagram', '移到图下方'],
|
||||
'viewer.rail.right': ['Move panel beside the diagram', '移到图右侧'],
|
||||
'viewer.nav.guide': ['Open diagram guide', '打开图表指南'],
|
||||
'viewer.nav.guide.title': ['Diagram guide (?)', '图表指南(?)'],
|
||||
'viewer.nav.zoomOut': ['Zoom out', '缩小'],
|
||||
'viewer.nav.zoomOut.title': ['Zoom out (-)', '缩小(-)'],
|
||||
'viewer.nav.reset': ['Reset diagram view', '重置图表视图'],
|
||||
'viewer.nav.reset.title': ['Reset view (0)', '重置视图(0)'],
|
||||
'viewer.nav.read': ['READ', '阅读'],
|
||||
'viewer.nav.zoomIn': ['Zoom in', '放大'],
|
||||
'viewer.nav.zoomIn.title': ['Zoom in (+)', '放大(+)'],
|
||||
'viewer.nav.camera': ['{hint}. Reset diagram view', '{hint}。重置图表视图'],
|
||||
'viewer.nav.camera.title': ['{semantic}{hint} · reset view (0)', '{semantic}{hint} · 重置视图(0)'],
|
||||
'viewer.nav.camera.semantic': ['Semantic camera active · ', '语义相机已启用 · '],
|
||||
'viewer.nav.level.map': ['MAP', '概览'],
|
||||
'viewer.nav.level.read': ['READ', '阅读'],
|
||||
'viewer.nav.level.full': ['FULL', '完整'],
|
||||
'viewer.nav.level.auto': ['AUTO', '自动'],
|
||||
'viewer.nav.detail.map': ['Zoom in to reveal relationship labels and node context', '放大以显示关系标签和节点上下文'],
|
||||
'viewer.nav.detail.read': ['Zoom in again to reveal tags and annotations', '再次放大以显示标签和注释'],
|
||||
'viewer.nav.detail.full': ['Full diagram detail', '完整图表详情'],
|
||||
|
||||
'viewer.intent.summary': ['{label}. {out} outgoing, {in} incoming{loops}. {total} connections. Press Enter for details.', '{label}。{out} 条出向,{in} 条入向{loops}。共 {total} 条连接。按 Enter 查看详情。'],
|
||||
'viewer.intent.loops': [', {count} self loop', ',{count} 条自环'],
|
||||
|
||||
'viewer.common.copied': ['Copied', '已复制'],
|
||||
'viewer.common.copyFailed': ['Copy failed', '复制失败'],
|
||||
'viewer.common.copyLink': ['Copy link', '复制链接'],
|
||||
'viewer.common.clear': ['Clear', '清除'],
|
||||
'viewer.common.close': ['Close', '关闭'],
|
||||
};
|
||||
|
||||
for (const [key, messages] of Object.entries(MESSAGE_PAIRS)) {
|
||||
if (messages.length !== SUPPORTED_LOCALES.length || messages.some((message) => typeof message !== 'string')) {
|
||||
throw new Error(`Incomplete Archify i18n tuple ${JSON.stringify(key)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const BUILTIN_CATALOGS = Object.fromEntries(SUPPORTED_LOCALES.map((locale, index) => [
|
||||
locale,
|
||||
Object.fromEntries(Object.entries(MESSAGE_PAIRS).map(([key, pair]) => [key, pair[index]])),
|
||||
]));
|
||||
|
||||
const EN = BUILTIN_CATALOGS.en;
|
||||
|
||||
const CANONICAL_KEYS = Object.keys(EN);
|
||||
const PLACEHOLDER_PATTERN = /\{([a-zA-Z0-9_]+)\}/g;
|
||||
|
||||
function extractPlaceholders(message) {
|
||||
return new Set([...String(message).matchAll(PLACEHOLDER_PATTERN)].map((match) => match[1]));
|
||||
}
|
||||
|
||||
const CANONICAL_PLACEHOLDERS = Object.fromEntries(
|
||||
CANONICAL_KEYS.map((key) => [key, extractPlaceholders(EN[key])]),
|
||||
);
|
||||
|
||||
function placeholdersMatch(expected, actual) {
|
||||
if (expected.size !== actual.size) return false;
|
||||
for (const token of expected) if (!actual.has(token)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Runtime catalogs registered by registerLocale(), keyed by whatever locale
|
||||
// tag the caller supplied (e.g. an agent-authored 'fr'). Kept separate from
|
||||
// BUILTIN_CATALOGS so a caller can never accidentally shadow a shipped
|
||||
// catalog with a partial one.
|
||||
const RUNTIME_CATALOGS = new Map();
|
||||
|
||||
function catalogFor(locale) {
|
||||
return RUNTIME_CATALOGS.get(locale) || BUILTIN_CATALOGS[locale];
|
||||
}
|
||||
|
||||
// Validates caller-supplied translation data against the canonical (English)
|
||||
// message-key set. Pure and side-effect free: registerLocale() calls this
|
||||
// and additionally builds/installs the resolved catalog.
|
||||
export function validateTranslations(translations = {}) {
|
||||
const supplied = Object.keys(translations || {});
|
||||
const suppliedSet = new Set(supplied);
|
||||
const missingKeys = CANONICAL_KEYS.filter((key) => !suppliedSet.has(key));
|
||||
const unknownKeys = supplied.filter((key) => !Object.hasOwn(CANONICAL_PLACEHOLDERS, key));
|
||||
const placeholderMismatches = [];
|
||||
const usableKeys = [];
|
||||
for (const key of supplied) {
|
||||
if (!Object.hasOwn(CANONICAL_PLACEHOLDERS, key)) continue;
|
||||
const value = translations[key];
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
placeholderMismatches.push({ key, expected: [...CANONICAL_PLACEHOLDERS[key]].sort(), actual: null });
|
||||
continue;
|
||||
}
|
||||
const actual = extractPlaceholders(value);
|
||||
if (placeholdersMatch(CANONICAL_PLACEHOLDERS[key], actual)) {
|
||||
usableKeys.push(key);
|
||||
} else {
|
||||
placeholderMismatches.push({
|
||||
key,
|
||||
expected: [...CANONICAL_PLACEHOLDERS[key]].sort(),
|
||||
actual: [...actual].sort(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
totalKeys: CANONICAL_KEYS.length,
|
||||
coveredKeys: usableKeys.length,
|
||||
coverage: CANONICAL_KEYS.length ? usableKeys.length / CANONICAL_KEYS.length : 1,
|
||||
missingKeys,
|
||||
unknownKeys,
|
||||
placeholderMismatches,
|
||||
};
|
||||
}
|
||||
|
||||
// Registers a fully-resolved catalog for an arbitrary locale tag, built by
|
||||
// layering validated translations over the English base. A key that is
|
||||
// missing, non-string, or whose interpolation placeholders don't match the
|
||||
// canonical set falls back to its English string — partial or malformed
|
||||
// translation data can never break rendering. Returns the same coverage
|
||||
// report validateTranslations() would, for the caller to surface as an
|
||||
// explicit fallback/coverage diagnostic before rendering.
|
||||
export function registerLocale(locale, translations = {}) {
|
||||
const report = validateTranslations(translations);
|
||||
const catalog = { ...EN };
|
||||
for (const key of CANONICAL_KEYS) {
|
||||
const value = translations?.[key];
|
||||
if (typeof value !== 'string' || value.length === 0) continue;
|
||||
if (placeholdersMatch(CANONICAL_PLACEHOLDERS[key], extractPlaceholders(value))) {
|
||||
catalog[key] = value;
|
||||
}
|
||||
}
|
||||
RUNTIME_CATALOGS.set(locale, catalog);
|
||||
return { locale, ...report };
|
||||
}
|
||||
|
||||
export function resolveLocale(locale) {
|
||||
return catalogFor(locale) ? locale : DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function formatMessage(template, values = {}) {
|
||||
return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => (
|
||||
Object.hasOwn(values, key) ? String(values[key]) : match
|
||||
));
|
||||
}
|
||||
|
||||
export function translateMessage(locale, key, values = {}) {
|
||||
const resolved = resolveLocale(locale);
|
||||
const catalog = catalogFor(resolved);
|
||||
if (!Object.hasOwn(catalog, key)) {
|
||||
throw new Error(`Missing Archify i18n message ${JSON.stringify(key)} for ${resolved}`);
|
||||
}
|
||||
return formatMessage(catalog[key], values);
|
||||
}
|
||||
|
||||
export function translateCount(locale, key, count, values = {}) {
|
||||
const suffix = count === 1 ? 'one' : 'other';
|
||||
return translateMessage(locale, `${key}.${suffix}`, { ...values, count });
|
||||
}
|
||||
|
||||
export function viewerCatalog(locale) {
|
||||
const resolved = resolveLocale(locale);
|
||||
return Object.fromEntries(Object.entries(catalogFor(resolved)).filter(([key]) => key.startsWith('viewer.')));
|
||||
}
|
||||
|
||||
export function localizeTemplate(template, locale) {
|
||||
return template.replace(/\{\{i18n:([a-zA-Z0-9_.-]+)\}\}/g, (_match, key) => escapeHtml(translateMessage(locale, key)));
|
||||
}
|
||||
|
||||
export function catalogKeys() {
|
||||
return [...CANONICAL_KEYS];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Serialize computed layout for dry-run / inspect (#9). */
|
||||
|
||||
export function componentBox(c) {
|
||||
return {
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
label: c.label,
|
||||
x: Math.round(c.x),
|
||||
y: Math.round(c.y),
|
||||
width: c.width,
|
||||
height: c.height,
|
||||
...(Number.isInteger(c.row) ? { row: c.row } : {}),
|
||||
...(Number.isInteger(c.col) ? { col: c.col } : {}),
|
||||
...(Array.isArray(c.pos) ? { pos: c.pos.map(Math.round) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function boundaryBox(b) {
|
||||
return {
|
||||
kind: b.kind,
|
||||
label: b.label,
|
||||
x: Math.round(b.x),
|
||||
y: Math.round(b.y),
|
||||
width: Math.round(b.width),
|
||||
height: Math.round(b.height),
|
||||
wraps: b.wraps,
|
||||
};
|
||||
}
|
||||
|
||||
export function connectionPath(conn, routed, labelAt) {
|
||||
return {
|
||||
from: conn.from,
|
||||
to: conn.to,
|
||||
label: conn.label ?? null,
|
||||
variant: conn.variant ?? 'default',
|
||||
route: conn.route ?? 'auto',
|
||||
// These points are repair inputs: rounding a fractional port makes a
|
||||
// reused waypoint diagonal relative to the actual endpoint.
|
||||
points: routed.points.map(([x, y]) => [x, y]),
|
||||
...(labelAt ? { labelAt: labelAt.map(Math.round) } : {}),
|
||||
};
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
import { throwDiagnosticError } from './diagnostics.mjs';
|
||||
import { rectsOverlap, segmentIntersectsRect } from './geometry.mjs';
|
||||
import { esc, textUnits } from './utils.mjs';
|
||||
import { translateMessage } from './i18n.mjs';
|
||||
|
||||
const DEFAULT_FONT_SIZE = 8;
|
||||
const DEFAULT_ITEM_GAP = 22;
|
||||
const DEFAULT_LINE_GAP = 22;
|
||||
const DEFAULT_SWATCH_GAP = 8;
|
||||
const TEXT_ADVANCE_EM = 0.62;
|
||||
const INTERACTIVE_BADGE_ALLOWANCE = 21;
|
||||
|
||||
export function relationshipLegendObstacles(relations, { pointsFor, labelRectFor } = {}) {
|
||||
const obstacles = [];
|
||||
for (const [index, relation] of (Array.isArray(relations) ? relations : []).entries()) {
|
||||
const points = typeof pointsFor === 'function' ? pointsFor(relation, index) : [];
|
||||
const finitePoints = (Array.isArray(points) ? points : []).filter((point) => (
|
||||
Array.isArray(point) && point.length === 2 && point.every(Number.isFinite)
|
||||
));
|
||||
for (let pointIndex = 0; pointIndex < finitePoints.length - 1; pointIndex += 1) {
|
||||
obstacles.push({
|
||||
kind: 'relationship-segment',
|
||||
start: finitePoints[pointIndex],
|
||||
end: finitePoints[pointIndex + 1],
|
||||
});
|
||||
}
|
||||
const labelRect = typeof labelRectFor === 'function' ? labelRectFor(relation, index) : null;
|
||||
if (labelRect && [labelRect.x, labelRect.y, labelRect.width, labelRect.height].every(Number.isFinite)) {
|
||||
obstacles.push({ kind: 'relationship-label', ...labelRect });
|
||||
}
|
||||
}
|
||||
return obstacles;
|
||||
}
|
||||
|
||||
export function resolveLegend(config, catalog, presentKinds) {
|
||||
const mode = config?.mode || 'auto';
|
||||
if (mode === 'hidden') return [];
|
||||
const present = presentKinds instanceof Set ? presentKinds : new Set(presentKinds || []);
|
||||
const overrides = config?.entries || {};
|
||||
|
||||
return catalog.flatMap((catalogEntry) => {
|
||||
const override = overrides[catalogEntry.kind] || {};
|
||||
const selectedByMode = mode === 'all' || present.has(catalogEntry.kind);
|
||||
const visible = override.visible === true || (selectedByMode && override.visible !== false);
|
||||
if (!visible) return [];
|
||||
return [{
|
||||
...catalogEntry,
|
||||
label: override.label || catalogEntry.label,
|
||||
present: present.has(catalogEntry.kind),
|
||||
interactive: catalogEntry.interactive !== false && present.has(catalogEntry.kind),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function measuredEntryWidth(entry, fontSize, swatchGap) {
|
||||
const swatchWidth = entry.swatchWidth ?? 14;
|
||||
return Math.ceil(
|
||||
swatchWidth
|
||||
+ swatchGap
|
||||
+ textUnits(entry.label) * fontSize * TEXT_ADVANCE_EM
|
||||
+ (entry.interactive ? INTERACTIVE_BADGE_ALLOWANCE : 0),
|
||||
);
|
||||
}
|
||||
|
||||
// One pure footprint calculation owns both auto-viewBox sizing and final SVG
|
||||
// placement. Callers must not maintain a second approximation of legend width
|
||||
// or row count; that would make generated geometry disagree with validation.
|
||||
export function legendFootprint(entries, {
|
||||
width,
|
||||
fontSize = DEFAULT_FONT_SIZE,
|
||||
itemGap = DEFAULT_ITEM_GAP,
|
||||
lineGap = DEFAULT_LINE_GAP,
|
||||
swatchGap = DEFAULT_SWATCH_GAP,
|
||||
} = {}) {
|
||||
if (!entries.length) {
|
||||
return { measured: [], rows: [], rowCount: 0, minWidth: 0, extraHeight: 0 };
|
||||
}
|
||||
const measured = entries.map((entry) => ({
|
||||
...entry,
|
||||
width: measuredEntryWidth(entry, fontSize, entry.swatchGap ?? swatchGap),
|
||||
}));
|
||||
const rows = [[]];
|
||||
let cursor = 0;
|
||||
for (const entry of measured) {
|
||||
const row = rows.at(-1);
|
||||
const required = (row.length ? itemGap : 0) + entry.width;
|
||||
if (row.length && cursor + required > width) {
|
||||
rows.push([entry]);
|
||||
cursor = entry.width;
|
||||
} else {
|
||||
row.push(entry);
|
||||
cursor += required;
|
||||
}
|
||||
}
|
||||
return {
|
||||
measured,
|
||||
rows,
|
||||
rowCount: rows.length,
|
||||
minWidth: measured.reduce((width, entry) => Math.max(width, entry.width), 0),
|
||||
extraHeight: (rows.length - 1) * lineGap,
|
||||
};
|
||||
}
|
||||
|
||||
export function measureLegend(entries, {
|
||||
x,
|
||||
baselineY,
|
||||
width,
|
||||
fontSize = DEFAULT_FONT_SIZE,
|
||||
itemGap = DEFAULT_ITEM_GAP,
|
||||
lineGap = DEFAULT_LINE_GAP,
|
||||
swatchGap = DEFAULT_SWATCH_GAP,
|
||||
minTitleY = 0,
|
||||
obstacles = [],
|
||||
unfit = 'error',
|
||||
diagramType = 'diagram',
|
||||
} = {}) {
|
||||
if (!entries.length) return { entries: [], rowCount: 0, titleY: null };
|
||||
const footprint = legendFootprint(entries, { width, fontSize, itemGap, lineGap, swatchGap });
|
||||
const tooWide = footprint.measured.find((entry) => entry.width > width);
|
||||
if (tooWide) {
|
||||
if (unfit === 'hide') return null;
|
||||
const message = `[legend/label-too-wide] ${diagramType} legend label for "${tooWide.kind}" needs ${tooWide.width}px but only ${width}px is available.`;
|
||||
throwDiagnosticError(message, [{
|
||||
code: 'legend/label-too-wide',
|
||||
severity: 'error',
|
||||
message,
|
||||
subject: { diagramType, path: `/meta/legend/entries/${tooWide.kind}/label` },
|
||||
evidence: { kind: tooWide.kind, measuredWidthPx: tooWide.width, availableWidthPx: width },
|
||||
supportedFixes: ['shorten the legend label or use a wider viewBox'],
|
||||
}]);
|
||||
}
|
||||
|
||||
const titleY = baselineY - footprint.extraHeight - 20;
|
||||
const legendTopY = titleY - 10;
|
||||
if (legendTopY < minTitleY) {
|
||||
if (unfit === 'hide') return null;
|
||||
const message = `[legend/vertical-overflow] ${diagramType} legend needs ${footprint.rowCount} rows, which would start at y=${legendTopY} above the available legend band at y=${minTitleY}.`;
|
||||
throwDiagnosticError(message, [{
|
||||
code: 'legend/vertical-overflow',
|
||||
severity: 'error',
|
||||
message,
|
||||
subject: { diagramType, path: '/meta/legend' },
|
||||
evidence: { rowCount: footprint.rowCount, requiredTopY: legendTopY, availableTopY: minTitleY },
|
||||
supportedFixes: ['shorten legend labels, hide nonessential entries, or use a wider viewBox'],
|
||||
}]);
|
||||
}
|
||||
|
||||
const positioned = [];
|
||||
footprint.rows.forEach((row, rowIndex) => {
|
||||
let entryX = x;
|
||||
const baseline = baselineY - (footprint.rowCount - rowIndex - 1) * lineGap;
|
||||
for (const entry of row) {
|
||||
positioned.push({ ...entry, x: entryX, baseline, row: rowIndex });
|
||||
entryX += entry.width + itemGap;
|
||||
}
|
||||
});
|
||||
|
||||
const legendRects = [
|
||||
{ kind: 'title', x, y: legendTopY, width: 48, height: 14 },
|
||||
...positioned.map((entry) => ({
|
||||
kind: entry.kind,
|
||||
x: entry.x,
|
||||
y: entry.baseline - 10,
|
||||
width: entry.width,
|
||||
height: 14,
|
||||
})),
|
||||
];
|
||||
const collision = legendRects.find((legendRect) => obstacles.some((obstacle) => (
|
||||
Array.isArray(obstacle.start) && Array.isArray(obstacle.end)
|
||||
? segmentIntersectsRect({ start: obstacle.start, end: obstacle.end }, legendRect)
|
||||
: rectsOverlap(obstacle, legendRect)
|
||||
)));
|
||||
if (collision) {
|
||||
if (unfit === 'hide') return null;
|
||||
const message = `[legend/content-overlap] ${diagramType} legend entry "${collision.kind}" overlaps authored relationship geometry.`;
|
||||
throwDiagnosticError(message, [{
|
||||
code: 'legend/content-overlap',
|
||||
severity: 'error',
|
||||
message,
|
||||
subject: { diagramType, path: '/meta/legend' },
|
||||
evidence: { legendKind: collision.kind, legendRect: collision },
|
||||
supportedFixes: ['shorten or hide legend entries, use a wider viewBox, or move the authored relationship route/label out of the legend band'],
|
||||
}]);
|
||||
}
|
||||
|
||||
return {
|
||||
entries: positioned,
|
||||
rowCount: footprint.rowCount,
|
||||
titleY,
|
||||
fontSize,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderLegend({ entries, layout, renderSwatch, locale }) {
|
||||
if (!entries.length) return '';
|
||||
const measured = measureLegend(entries, layout);
|
||||
if (!measured) return '';
|
||||
const hasInteractiveEntries = measured.entries.some((entry) => entry.interactive);
|
||||
const renderedFontSize = measured.fontSize < 8 ? measured.fontSize + 0.5 : measured.fontSize + 2;
|
||||
const rootAttributes = hasInteractiveEntries ? ' data-legend="" data-legend-bridge=""' : ' data-legend=""';
|
||||
const parts = [
|
||||
` <g${rootAttributes}>`,
|
||||
` <text x="${layout.x}" y="${measured.titleY}" class="t-primary" font-size="12" font-weight="650">${esc(translateMessage(locale, 'legend.title'))}</text>`,
|
||||
];
|
||||
|
||||
for (const entry of measured.entries) {
|
||||
const interactive = entry.interactive
|
||||
? ` data-legend-kind="${esc(entry.kind)}" data-legend-label="${esc(entry.label)}"`
|
||||
: '';
|
||||
parts.push(` <g data-legend-semantic-kind="${esc(entry.kind)}"${interactive} data-legend-x="${entry.x}" data-legend-baseline="${entry.baseline}" data-legend-width="${entry.width}">`);
|
||||
parts.push(` ${renderSwatch(entry)}`);
|
||||
parts.push(` <text x="${entry.x + (entry.swatchWidth ?? 14) + (entry.swatchGap ?? DEFAULT_SWATCH_GAP)}" y="${entry.baseline}" class="t-muted" font-size="${renderedFontSize}" font-weight="500">${esc(entry.label)}</text>`);
|
||||
parts.push(' </g>');
|
||||
}
|
||||
parts.push(' </g>');
|
||||
return parts.join('\n');
|
||||
}
|
||||
+440
@@ -0,0 +1,440 @@
|
||||
import path from 'node:path';
|
||||
import {
|
||||
containedBy,
|
||||
isValidWindowsSmbShareName,
|
||||
isWindowsIpcShare,
|
||||
resolvePhysicalLocation,
|
||||
sameLocation,
|
||||
} from './path-semantics.mjs';
|
||||
import { PortablePathError, validatePortablePath } from './portable-path.mjs';
|
||||
|
||||
export function canonicalFuturePath(targetPath) {
|
||||
const resolution = resolvePhysicalLocation(targetPath);
|
||||
if (resolution.status === 'resolved') {
|
||||
if (resolution.location.kind === 'existing') return resolution.location.path;
|
||||
return path.join(
|
||||
resolution.location.ancestorPath,
|
||||
...resolution.location.unresolved,
|
||||
);
|
||||
}
|
||||
const output = path.resolve(targetPath);
|
||||
if (resolution.reason.code === 'symlink-cycle') {
|
||||
throw new OutputPathError(`Output path contains a symbolic-link cycle: "${output}".`, {
|
||||
code: 'output/symlink-cycle',
|
||||
message: 'Output path could not be resolved because it contains a symbolic-link cycle.',
|
||||
subject: { output },
|
||||
evidence: { relation: resolution.reason },
|
||||
supportedFixes: ['remove the symbolic-link cycle or choose an output path outside it'],
|
||||
});
|
||||
}
|
||||
throw new OutputPathError('Output path could not be resolved safely.', {
|
||||
code: 'output/path-resolution-indeterminate',
|
||||
message: 'Output path could not be resolved safely for the requested filesystem location.',
|
||||
subject: { output },
|
||||
evidence: { relation: resolution.reason },
|
||||
supportedFixes: ['use an ordinary local filesystem path that can be resolved safely, then retry'],
|
||||
});
|
||||
}
|
||||
|
||||
export function pathsAlias(leftPath, rightPath) {
|
||||
const relation = sameLocation(leftPath, rightPath);
|
||||
if (relation.status === 'match') return true;
|
||||
if (relation.status === 'different') return false;
|
||||
if (relation.reason.code === 'ancestor-not-directory') return false;
|
||||
if (relation.reason.code === 'symlink-cycle') {
|
||||
const output = path.resolve(relation.reason.side === 'right' ? rightPath : leftPath);
|
||||
throw new OutputPathError(`Output path contains a symbolic-link cycle: "${output}".`, {
|
||||
code: 'output/symlink-cycle',
|
||||
message: 'Output path could not be resolved because it contains a symbolic-link cycle.',
|
||||
subject: { output },
|
||||
evidence: { relation: relation.reason },
|
||||
supportedFixes: ['remove the symbolic-link cycle or choose an output path outside it'],
|
||||
});
|
||||
}
|
||||
throw new OutputPathError('Path identity could not be determined safely.', {
|
||||
code: 'output/path-identity-indeterminate',
|
||||
message: 'Path identity could not be determined safely for the requested filesystem location.',
|
||||
subject: { left: path.resolve(leftPath), right: path.resolve(rightPath) },
|
||||
evidence: { relation: relation.reason },
|
||||
supportedFixes: ['use ordinary local filesystem paths whose identity can be verified, then retry'],
|
||||
});
|
||||
}
|
||||
|
||||
function pathIsInside(directoryPath, targetPath) {
|
||||
const relation = containedBy(directoryPath, targetPath);
|
||||
if (relation.status === 'match') return true;
|
||||
if (relation.status === 'different') return false;
|
||||
throw new OutputPathError('Output containment could not be determined safely.', {
|
||||
code: 'output/containment-indeterminate',
|
||||
message: 'Output containment could not be determined safely for the requested filesystem location.',
|
||||
subject: { output: path.resolve(targetPath), cwd: path.resolve(directoryPath) },
|
||||
evidence: { relation: relation.reason },
|
||||
supportedFixes: ['use an output beneath an ordinary local directory whose identity can be verified'],
|
||||
});
|
||||
}
|
||||
|
||||
function authoredOutputDiagnostic(error, rawOutput) {
|
||||
const absolute = error?.reason === 'absolute';
|
||||
const code = absolute ? 'output/meta-absolute' : 'output/meta-path-syntax';
|
||||
const message = absolute
|
||||
? 'meta.output must be a relative path resolved from the current working directory.'
|
||||
: 'meta.output must be a portable POSIX-relative path.';
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
subject: { output: rawOutput, path: '/meta/output' },
|
||||
evidence: {
|
||||
reason: error?.reason || 'invalid',
|
||||
...(error?.segmentIndex !== undefined ? { segmentIndex: error.segmentIndex } : {}),
|
||||
...(error?.segment !== undefined ? { segment: error.segment } : {}),
|
||||
...(error?.utf8Bytes !== undefined ? { utf8Bytes: error.utf8Bytes } : {}),
|
||||
...(error?.utf16CodeUnits !== undefined ? { utf16CodeUnits: error.utf16CodeUnits } : {}),
|
||||
...(error?.limit !== undefined ? { limit: error.limit } : {}),
|
||||
},
|
||||
supportedFixes: ['set meta.output to a portable relative .html path such as reports/diagram.html'],
|
||||
};
|
||||
}
|
||||
|
||||
function nativeOutputDiagnostic(rawOutput, reason, details = {}) {
|
||||
return {
|
||||
code: 'output/native-path-syntax',
|
||||
message: 'The output path is not a valid native filesystem path on this host.',
|
||||
subject: { output: rawOutput },
|
||||
evidence: { reason, ...details },
|
||||
supportedFixes: ['choose an ordinary filesystem path without device names, alternate data streams, trailing dots or spaces, or overlong components'],
|
||||
};
|
||||
}
|
||||
|
||||
function throwNativeOutputDiagnostic(rawOutput, reason, details = {}) {
|
||||
const diagnostic = nativeOutputDiagnostic(rawOutput, reason, details);
|
||||
throw new OutputPathError(diagnostic.message, diagnostic);
|
||||
}
|
||||
|
||||
function windowsExtendedTailComponents(rawOutput, tail) {
|
||||
const authoredComponents = tail.split('\\');
|
||||
// Preserve one trailing separator for directory arguments, but never repair
|
||||
// an empty component inside the raw extended namespace.
|
||||
if (authoredComponents.slice(0, -1).some((component) => component.length === 0)) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
|
||||
}
|
||||
return authoredComponents.filter(Boolean);
|
||||
}
|
||||
|
||||
function rejectWindowsIpcShare(rawOutput, share) {
|
||||
if (isWindowsIpcShare(share)) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-ipc-namespace', { share });
|
||||
}
|
||||
}
|
||||
|
||||
function validateWindowsNativeComponent(
|
||||
rawOutput,
|
||||
component,
|
||||
componentIndex,
|
||||
{ allowReservedName = false } = {},
|
||||
) {
|
||||
try {
|
||||
// Prefix the component so a colon is classified as an ADS separator,
|
||||
// rather than allowing the generic URI detector to claim it first.
|
||||
validatePortablePath(`native/${component}`, { profile: 'output' });
|
||||
} catch (error) {
|
||||
if (!(error instanceof PortablePathError)) throw error;
|
||||
if (allowReservedName && error.reason === 'windows-reserved-name') return;
|
||||
// Native Windows arguments may intentionally name an existing 8.3 alias.
|
||||
// Portable authored/archive paths reject that ambiguous spelling, while
|
||||
// native resolution lets the filesystem prove the existing target.
|
||||
if (error.reason === 'windows-short-name') {
|
||||
if (component.length <= 255) return;
|
||||
throwNativeOutputDiagnostic(rawOutput, 'component-too-long', {
|
||||
component,
|
||||
componentIndex,
|
||||
utf16CodeUnits: component.length,
|
||||
limit: 255,
|
||||
});
|
||||
}
|
||||
// Native Windows filesystems bound components in UTF-16 code units. The
|
||||
// stricter UTF-8 bound belongs to portable authored/archive names only.
|
||||
if (error.reason === 'component-too-long' && error.utf16CodeUnits <= 255) return;
|
||||
throwNativeOutputDiagnostic(rawOutput, error.reason, {
|
||||
component,
|
||||
componentIndex,
|
||||
...(error.character !== undefined ? { character: error.character } : {}),
|
||||
...(error.utf8Bytes !== undefined ? { utf8Bytes: error.utf8Bytes } : {}),
|
||||
...(error.utf16CodeUnits !== undefined ? { utf16CodeUnits: error.utf16CodeUnits } : {}),
|
||||
...(error.limit !== undefined ? { limit: error.limit } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateWindowsUncRootComponents(rawOutput, server, share) {
|
||||
rejectWindowsIpcShare(rawOutput, share);
|
||||
if (!isValidWindowsSmbShareName(share)) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-unc-share-name', {
|
||||
share,
|
||||
utf16CodeUnits: share.length,
|
||||
limit: 80,
|
||||
});
|
||||
}
|
||||
// UNC servers and shares are root components, not DOS file names. Keep the
|
||||
// server's ordinary native syntax checks while allowing names such as CON.
|
||||
validateWindowsNativeComponent(rawOutput, server, 0, { allowReservedName: true });
|
||||
}
|
||||
|
||||
function windowsExtendedPathComponents(rawOutput) {
|
||||
// The extended-length namespace deliberately bypasses Win32 normalization.
|
||||
// Inspect its original spelling so a dot segment cannot retarget a UNC share.
|
||||
if (rawOutput.includes('/')) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-separator', { character: '/' });
|
||||
}
|
||||
if (/^\\\\\?\\(?:GLOBALROOT|Device)(?:\\|$)/iu.test(rawOutput)) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-device-namespace');
|
||||
}
|
||||
|
||||
const drive = rawOutput.match(/^\\\\\?\\[A-Za-z]:\\/u);
|
||||
if (drive) {
|
||||
return windowsExtendedTailComponents(rawOutput, rawOutput.slice(drive[0].length));
|
||||
}
|
||||
|
||||
const uncPrefix = rawOutput.match(/^\\\\\?\\UNC\\/iu);
|
||||
if (uncPrefix) {
|
||||
const authoredTail = rawOutput.slice(uncPrefix[0].length);
|
||||
const components = authoredTail.split('\\');
|
||||
if (components.length < 2 || components[0].length === 0 || components[1].length === 0) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
|
||||
}
|
||||
validateWindowsUncRootComponents(rawOutput, components[0], components[1]);
|
||||
return windowsExtendedTailComponents(rawOutput, components.slice(2).join('\\'));
|
||||
}
|
||||
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
|
||||
}
|
||||
|
||||
function validateWindowsRawUncRoot(rawOutput) {
|
||||
if (!/^[\\/]{2}/u.test(rawOutput) || /^[\\/]{2}[.?][\\/]/u.test(rawOutput)) return;
|
||||
// Validate the raw server/share boundary before win32.normalize can collapse
|
||||
// an empty share or mix the two UNC separator spellings.
|
||||
const unc = rawOutput.match(/^([\\/])\1([^\\/]+)\1([^\\/]+)(?:[\\/]|$)/u);
|
||||
if (!unc) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-unc-root');
|
||||
}
|
||||
validateWindowsUncRootComponents(rawOutput, unc[2], unc[3]);
|
||||
}
|
||||
|
||||
function windowsPathComponents(rawOutput, normalized) {
|
||||
const authoredUncPrefix = /^[\\/]{2}/u.test(rawOutput);
|
||||
if (/^\\\\\.\\/u.test(normalized)) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-device-namespace');
|
||||
}
|
||||
if (/^\\\\\?\\/u.test(normalized)) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
|
||||
}
|
||||
if (normalized.startsWith('\\\\')) {
|
||||
const unc = normalized.match(/^\\\\([^\\]+)\\([^\\]+)(?:\\|$)/u);
|
||||
if (!unc) throwNativeOutputDiagnostic(rawOutput, 'windows-unc-root');
|
||||
return normalized.slice(unc[0].length).split('\\').filter(Boolean);
|
||||
}
|
||||
if (authoredUncPrefix) throwNativeOutputDiagnostic(rawOutput, 'windows-unc-root');
|
||||
if (normalized.startsWith('\\')) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'current-drive-rooted');
|
||||
}
|
||||
const root = path.win32.parse(normalized).root;
|
||||
return normalized
|
||||
.slice(root.length)
|
||||
.split(/[\\/]+/u)
|
||||
// normalize() retains leading navigation for a relative path. Those dot
|
||||
// segments are path syntax, not filename components subject to name rules.
|
||||
.filter((component) => component && component !== '.' && component !== '..');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate command-line/default output paths using the active host's native
|
||||
* syntax. Unlike authored portable paths, absolute paths and native separators
|
||||
* remain supported. File outputs reject a trailing separator before native
|
||||
* resolution can erase it; directory callers must opt in explicitly. The
|
||||
* component bound also protects derived sidecars from failing after an
|
||||
* operation has already started mutating the destination.
|
||||
*/
|
||||
export function validateNativeOutputPath(
|
||||
rawOutput,
|
||||
{ platform = process.platform, kind = 'file' } = {},
|
||||
) {
|
||||
if (kind !== 'file' && kind !== 'directory') {
|
||||
throw new TypeError(`Unsupported native output path kind: ${JSON.stringify(kind)}`);
|
||||
}
|
||||
if (typeof rawOutput !== 'string' || rawOutput.length === 0) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'empty');
|
||||
}
|
||||
if (rawOutput.includes('\0')) throwNativeOutputDiagnostic(rawOutput, 'nul-character');
|
||||
if (/[\uD800-\uDFFF]/u.test(rawOutput)) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'unpaired-surrogate');
|
||||
}
|
||||
const hasTrailingSeparator = platform === 'win32'
|
||||
? /[\\/]$/u.test(rawOutput)
|
||||
: rawOutput.endsWith(path.posix.sep);
|
||||
if (kind === 'file' && hasTrailingSeparator) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'trailing-separator', { kind });
|
||||
}
|
||||
|
||||
let normalized;
|
||||
let components;
|
||||
if (platform === 'win32') {
|
||||
if (/^[A-Za-z]:(?:$|[^\\/])/u.test(rawOutput)) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'drive-relative');
|
||||
}
|
||||
const extendedPrefix = rawOutput.startsWith('\\\\?\\');
|
||||
if (extendedPrefix) {
|
||||
components = windowsExtendedPathComponents(rawOutput);
|
||||
} else {
|
||||
validateWindowsRawUncRoot(rawOutput);
|
||||
normalized = path.win32.normalize(rawOutput);
|
||||
components = windowsPathComponents(rawOutput, normalized);
|
||||
}
|
||||
for (const [componentIndex, component] of components.entries()) {
|
||||
validateWindowsNativeComponent(rawOutput, component, componentIndex);
|
||||
}
|
||||
} else {
|
||||
normalized = path.resolve(rawOutput);
|
||||
const root = path.parse(normalized).root;
|
||||
components = normalized.slice(root.length).split(path.sep).filter(Boolean);
|
||||
for (const [componentIndex, component] of components.entries()) {
|
||||
const utf8Bytes = Buffer.byteLength(component, 'utf8');
|
||||
if (utf8Bytes > 255) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'component-too-long', {
|
||||
component,
|
||||
componentIndex,
|
||||
utf8Bytes,
|
||||
limit: 255,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return rawOutput;
|
||||
}
|
||||
|
||||
/** Validate a CLI directory argument before native resolution can normalize away its raw syntax. */
|
||||
export function resolveNativeOutputDirectory(
|
||||
rawDirectory,
|
||||
{ platform = process.platform, cwd = process.cwd() } = {},
|
||||
) {
|
||||
validateNativeOutputPath(rawDirectory, { platform, kind: 'directory' });
|
||||
const pathApi = platform === 'win32' ? path.win32 : path.posix;
|
||||
return pathApi.resolve(cwd, rawDirectory);
|
||||
}
|
||||
|
||||
export function validateAuthoredOutputPath(rawOutput, { cwd = process.cwd() } = {}) {
|
||||
try {
|
||||
validatePortablePath(rawOutput, { profile: 'output' });
|
||||
} catch (error) {
|
||||
if (!(error instanceof PortablePathError)) throw error;
|
||||
const diagnostic = authoredOutputDiagnostic(error, rawOutput);
|
||||
throw new OutputPathError(diagnostic.message, diagnostic);
|
||||
}
|
||||
if (path.posix.extname(rawOutput).toLowerCase() !== '.html') {
|
||||
throw new OutputPathError('meta.output must target an .html file.', {
|
||||
code: 'output/meta-extension',
|
||||
message: 'meta.output must target an .html file.',
|
||||
subject: { output: rawOutput, path: '/meta/output' },
|
||||
supportedFixes: ['change meta.output to a portable path ending in .html'],
|
||||
});
|
||||
}
|
||||
const outputPath = path.resolve(cwd, rawOutput);
|
||||
if (path.extname(canonicalFuturePath(outputPath)).toLowerCase() !== '.html') {
|
||||
throw new OutputPathError('meta.output must resolve to an .html file.', {
|
||||
code: 'output/meta-resolved-extension',
|
||||
message: 'meta.output must resolve to an .html file after symbolic links are followed.',
|
||||
subject: { output: rawOutput },
|
||||
supportedFixes: ['remove the symbolic-link alias or point it to an .html target inside the current working directory'],
|
||||
});
|
||||
}
|
||||
if (!pathIsInside(cwd, outputPath)) {
|
||||
throw new OutputPathError('meta.output must stay inside the current working directory.', {
|
||||
code: 'output/meta-outside-cwd',
|
||||
message: 'meta.output must stay inside the current working directory after symbolic links are resolved.',
|
||||
subject: { output: rawOutput, cwd: path.resolve(cwd) },
|
||||
supportedFixes: ['set meta.output to a relative .html path inside the current working directory'],
|
||||
});
|
||||
}
|
||||
return rawOutput;
|
||||
}
|
||||
|
||||
export class OutputPathError extends Error {
|
||||
constructor(message, diagnostic) {
|
||||
super(message);
|
||||
this.name = 'OutputPathError';
|
||||
this.archifyDiagnostics = [{
|
||||
severity: 'error',
|
||||
subject: {},
|
||||
evidence: {},
|
||||
supportedFixes: [],
|
||||
...diagnostic,
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOutputPath({
|
||||
requestedOutput,
|
||||
authoredOutput,
|
||||
defaultOutput,
|
||||
inputPaths = [],
|
||||
inputDescription = 'an input',
|
||||
otherOutputPaths = [],
|
||||
cwd = process.cwd(),
|
||||
requiredExtension = '.html',
|
||||
platform = process.platform,
|
||||
}) {
|
||||
if (authoredOutput !== undefined) validateAuthoredOutputPath(authoredOutput, { cwd });
|
||||
const source = requestedOutput !== undefined
|
||||
? 'cli'
|
||||
: (authoredOutput !== undefined ? 'meta' : 'default');
|
||||
const rawOutput = source === 'cli'
|
||||
? requestedOutput
|
||||
: (source === 'meta' ? authoredOutput : defaultOutput);
|
||||
if (source !== 'meta') validateNativeOutputPath(rawOutput, { platform, kind: 'file' });
|
||||
const outputPath = path.resolve(cwd, rawOutput);
|
||||
for (const inputPath of inputPaths) {
|
||||
if (!pathsAlias(outputPath, inputPath)) continue;
|
||||
throw new OutputPathError(`Output must not replace ${inputDescription}.`, {
|
||||
code: 'output/input-alias',
|
||||
message: `Output must not replace ${inputDescription}, including through a symbolic-link or future-path alias.`,
|
||||
subject: { output: outputPath, input: path.resolve(inputPath) },
|
||||
supportedFixes: ['choose an output path that is distinct from every input path'],
|
||||
});
|
||||
}
|
||||
for (const otherOutputPath of otherOutputPaths) {
|
||||
if (!pathsAlias(outputPath, otherOutputPath)) continue;
|
||||
throw new OutputPathError('Output targets must use distinct paths.', {
|
||||
code: 'output/target-alias',
|
||||
message: 'Output targets must use distinct paths, including symbolic-link and future-path aliases.',
|
||||
subject: { output: outputPath, conflictingOutput: path.resolve(otherOutputPath) },
|
||||
supportedFixes: ['choose distinct paths for every generated output'],
|
||||
});
|
||||
}
|
||||
|
||||
// Keep explicit CLI directories unrestricted, but reject mistaken file types.
|
||||
// Alias checks above retain priority when a target would overwrite an input.
|
||||
if (source === 'cli') {
|
||||
const resolvedOutput = canonicalFuturePath(outputPath);
|
||||
const authoredExtension = path.extname(rawOutput).toLowerCase();
|
||||
const existingWindowsHtmlAlias = platform === 'win32'
|
||||
&& requiredExtension === '.html'
|
||||
&& authoredExtension === '.htm'
|
||||
&& path.extname(resolvedOutput).toLowerCase() === requiredExtension
|
||||
&& pathsAlias(outputPath, resolvedOutput);
|
||||
const authoredMatches = authoredExtension === requiredExtension || existingWindowsHtmlAlias;
|
||||
const resolvedMatches = path.extname(resolvedOutput).toLowerCase() === requiredExtension;
|
||||
if (!authoredMatches || !resolvedMatches) {
|
||||
const message = `CLI output must ${authoredMatches ? 'resolve to' : 'target'} a ${requiredExtension} file.`;
|
||||
throw new OutputPathError(message, {
|
||||
code: authoredMatches ? 'output/cli-resolved-extension' : 'output/cli-extension',
|
||||
message,
|
||||
subject: { output: rawOutput },
|
||||
evidence: { resolvedOutput, requiredExtension },
|
||||
supportedFixes: [`choose a path ending in ${requiredExtension} whose symbolic-link target also ends in ${requiredExtension}`],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
outputPath,
|
||||
source,
|
||||
};
|
||||
}
|
||||
+1017
File diff suppressed because it is too large
Load Diff
+408
@@ -0,0 +1,408 @@
|
||||
const PORTABLE_PATH_PROFILES = new Set(['output', 'repo', 'archive']);
|
||||
const WINDOWS_SAFE_PROFILES = new Set(['output', 'archive']);
|
||||
const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/iu;
|
||||
const WINDOWS_SHORT_NAME = /~[1-9][0-9]*(?:\.|$)/iu;
|
||||
const URI_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/u;
|
||||
const CONTROL_CHARACTER = /\p{Cc}/u;
|
||||
const WINDOWS_INVALID_CHARACTER = /[<>"|?*]/u;
|
||||
const MAX_SEMANTIC_VARIANTS = 32;
|
||||
|
||||
export class PortablePathError extends Error {
|
||||
constructor(message, {
|
||||
code,
|
||||
reason,
|
||||
value,
|
||||
profile,
|
||||
segment,
|
||||
segmentIndex,
|
||||
character,
|
||||
index,
|
||||
conflictIndex,
|
||||
conflictValue,
|
||||
collisionKind,
|
||||
semantics,
|
||||
pathPart,
|
||||
conflictPathPart,
|
||||
utf8Bytes,
|
||||
utf16CodeUnits,
|
||||
limit,
|
||||
} = {}) {
|
||||
super(message);
|
||||
this.name = 'PortablePathError';
|
||||
this.code = code;
|
||||
this.reason = reason;
|
||||
this.value = value;
|
||||
this.profile = profile;
|
||||
if (segment !== undefined) this.segment = segment;
|
||||
if (segmentIndex !== undefined) this.segmentIndex = segmentIndex;
|
||||
if (character !== undefined) this.character = character;
|
||||
if (index !== undefined) this.index = index;
|
||||
if (conflictIndex !== undefined) this.conflictIndex = conflictIndex;
|
||||
if (conflictValue !== undefined) this.conflictValue = conflictValue;
|
||||
if (collisionKind !== undefined) this.collisionKind = collisionKind;
|
||||
if (semantics !== undefined) this.semantics = semantics;
|
||||
if (pathPart !== undefined) this.pathPart = pathPart;
|
||||
if (conflictPathPart !== undefined) this.conflictPathPart = conflictPathPart;
|
||||
if (utf8Bytes !== undefined) this.utf8Bytes = utf8Bytes;
|
||||
if (utf16CodeUnits !== undefined) this.utf16CodeUnits = utf16CodeUnits;
|
||||
if (limit !== undefined) this.limit = limit;
|
||||
}
|
||||
}
|
||||
|
||||
function pathError(value, profile, reason, message, details = {}) {
|
||||
return new PortablePathError(message, {
|
||||
code: `portable-path/${reason}`,
|
||||
reason,
|
||||
value,
|
||||
profile,
|
||||
...details,
|
||||
});
|
||||
}
|
||||
|
||||
function assertProfile(profile) {
|
||||
if (PORTABLE_PATH_PROFILES.has(profile)) return;
|
||||
throw pathError(
|
||||
undefined,
|
||||
profile,
|
||||
'profile',
|
||||
`Portable path profile must be one of: ${[...PORTABLE_PATH_PROFILES].join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function validatePortablePath(value, options = {}) {
|
||||
const profile = options?.profile;
|
||||
assertProfile(profile);
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
throw pathError(value, profile, 'type', 'Portable path must be a string.');
|
||||
}
|
||||
if (value.length === 0) {
|
||||
throw pathError(value, profile, 'empty', 'Portable path must not be empty.');
|
||||
}
|
||||
if (/^[A-Za-z]:[\\/]/u.test(value) || value.startsWith('/') || value.startsWith('\\')) {
|
||||
throw pathError(value, profile, 'absolute', 'Portable path must be relative.');
|
||||
}
|
||||
if (/^[A-Za-z]:/u.test(value)) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'drive-relative',
|
||||
'Portable path must not use a drive-relative Windows path.',
|
||||
);
|
||||
}
|
||||
if (URI_SCHEME.test(value)) {
|
||||
throw pathError(value, profile, 'uri', 'Portable path must not be a URI.');
|
||||
}
|
||||
if (value.includes('\\')) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'backslash',
|
||||
'Portable path must use forward slashes as separators.',
|
||||
);
|
||||
}
|
||||
|
||||
const segments = value.split('/');
|
||||
for (const [segmentIndex, segment] of segments.entries()) {
|
||||
const segmentDetails = { segment, segmentIndex };
|
||||
if (segment.length === 0) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'empty-segment',
|
||||
'Portable path must not contain empty segments.',
|
||||
segmentDetails,
|
||||
);
|
||||
}
|
||||
if (segment === '.' || segment === '..') {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'dot-segment',
|
||||
'Portable path must not contain dot segments.',
|
||||
segmentDetails,
|
||||
);
|
||||
}
|
||||
const controlMatch = segment.match(CONTROL_CHARACTER);
|
||||
if (controlMatch) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'control',
|
||||
'Portable path must not contain control characters.',
|
||||
{ ...segmentDetails, character: controlMatch[0] },
|
||||
);
|
||||
}
|
||||
const surrogateMatch = segment.match(/[\uD800-\uDFFF]/u);
|
||||
if (surrogateMatch) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'unpaired-surrogate',
|
||||
'Portable path must not contain unpaired UTF-16 surrogates.',
|
||||
{ ...segmentDetails, character: surrogateMatch[0] },
|
||||
);
|
||||
}
|
||||
|
||||
if (!WINDOWS_SAFE_PROFILES.has(profile)) continue;
|
||||
if (segment.includes(':')) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'windows-ads',
|
||||
'Portable path must not select a Windows alternate data stream.',
|
||||
{ ...segmentDetails, character: ':' },
|
||||
);
|
||||
}
|
||||
const invalidMatch = segment.match(WINDOWS_INVALID_CHARACTER);
|
||||
if (invalidMatch) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'windows-invalid-character',
|
||||
'Portable path contains a character that is invalid in Windows file names.',
|
||||
{ ...segmentDetails, character: invalidMatch[0] },
|
||||
);
|
||||
}
|
||||
if (/[. ]$/u.test(segment)) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'windows-trailing-dot-space',
|
||||
'Portable path segments must not end with a dot or space.',
|
||||
segmentDetails,
|
||||
);
|
||||
}
|
||||
if (WINDOWS_RESERVED_NAME.test(segment)) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'windows-reserved-name',
|
||||
'Portable path must not use a reserved Windows device name.',
|
||||
segmentDetails,
|
||||
);
|
||||
}
|
||||
if (WINDOWS_SHORT_NAME.test(segment)) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'windows-short-name',
|
||||
'Portable path must not use a Windows 8.3 short-name shape.',
|
||||
segmentDetails,
|
||||
);
|
||||
}
|
||||
const utf8Bytes = Buffer.byteLength(segment, 'utf8');
|
||||
const utf16CodeUnits = segment.length;
|
||||
if (utf8Bytes > 255 || utf16CodeUnits > 255) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'component-too-long',
|
||||
'Portable path segments must fit both UTF-8 and UTF-16 filesystem component limits.',
|
||||
{
|
||||
...segmentDetails,
|
||||
utf8Bytes,
|
||||
utf16CodeUnits,
|
||||
limit: 255,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function collisionError(values, profile, index, conflictIndex, reason, details = {}) {
|
||||
return new PortablePathError(
|
||||
`Portable paths at indexes ${conflictIndex} and ${index} collide under portable filesystem semantics.`,
|
||||
{
|
||||
code: 'portable-path/collision',
|
||||
reason,
|
||||
value: values[index],
|
||||
profile,
|
||||
index,
|
||||
conflictIndex,
|
||||
conflictValue: values[conflictIndex],
|
||||
...details,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createSemanticIndex() {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function semanticEnvelope(value, profile, index) {
|
||||
const variants = [value];
|
||||
const seen = new Set(variants);
|
||||
const transforms = [
|
||||
(candidate) => candidate.normalize('NFC'),
|
||||
(candidate) => candidate.normalize('NFD'),
|
||||
// path-contract-allow: portable-logical-path -- Archive names require conservative case-collision closure.
|
||||
(candidate) => candidate.toLocaleLowerCase('en-US'),
|
||||
// path-contract-allow: portable-logical-path -- Archive names require conservative case-collision closure.
|
||||
(candidate) => candidate.toLocaleUpperCase('en-US'),
|
||||
];
|
||||
|
||||
for (let cursor = 0; cursor < variants.length; cursor += 1) {
|
||||
for (const transform of transforms) {
|
||||
const transformed = transform(variants[cursor]);
|
||||
if (seen.has(transformed)) continue;
|
||||
if (variants.length >= MAX_SEMANTIC_VARIANTS) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'semantic-expansion',
|
||||
'Portable path semantic comparison exceeded its bounded Unicode expansion.',
|
||||
{ index, limit: MAX_SEMANTIC_VARIANTS },
|
||||
);
|
||||
}
|
||||
seen.add(transformed);
|
||||
variants.push(transformed);
|
||||
}
|
||||
}
|
||||
|
||||
return variants;
|
||||
}
|
||||
|
||||
function classifySemanticCollision(value, conflictValue) {
|
||||
if (value === conflictValue) return 'exact';
|
||||
if (value.normalize('NFC') === conflictValue.normalize('NFC')) return 'normalization';
|
||||
|
||||
const directLowerMatch = value.toLocaleLowerCase('en-US')
|
||||
=== conflictValue.toLocaleLowerCase('en-US');
|
||||
const directUpperMatch = value.toLocaleUpperCase('en-US')
|
||||
=== conflictValue.toLocaleUpperCase('en-US');
|
||||
return directLowerMatch || directUpperMatch ? 'case' : 'case-and-normalization';
|
||||
}
|
||||
|
||||
function findSemanticCollision(semanticIndex, value, profile, sourceIndex) {
|
||||
const variants = semanticEnvelope(value, profile, sourceIndex);
|
||||
for (const variant of variants) {
|
||||
const entry = semanticIndex.get(variant);
|
||||
if (entry) {
|
||||
return {
|
||||
entry,
|
||||
variants,
|
||||
semantics: classifySemanticCollision(value, entry.value),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { entry: null, variants, semantics: null };
|
||||
}
|
||||
|
||||
function rememberSemanticEntry(semanticIndex, entry, variants) {
|
||||
for (const variant of variants) {
|
||||
if (!semanticIndex.has(variant)) semanticIndex.set(variant, entry);
|
||||
}
|
||||
}
|
||||
|
||||
export function validatePortablePathSet(values, options = {}) {
|
||||
const profile = options?.profile;
|
||||
assertProfile(profile);
|
||||
if (!Array.isArray(values)) {
|
||||
throw pathError(values, profile, 'set-type', 'Portable path set must be an array.');
|
||||
}
|
||||
|
||||
const leafEntries = createSemanticIndex();
|
||||
const directoryEntries = createSemanticIndex();
|
||||
|
||||
for (const [index, value] of values.entries()) {
|
||||
try {
|
||||
validatePortablePath(value, { profile });
|
||||
} catch (error) {
|
||||
if (error instanceof PortablePathError && error.index === undefined) error.index = index;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const leafCollision = findSemanticCollision(leafEntries, value, profile, index);
|
||||
if (leafCollision.entry) {
|
||||
const conflictIndex = leafCollision.entry.index;
|
||||
throw collisionError(
|
||||
values,
|
||||
profile,
|
||||
index,
|
||||
conflictIndex,
|
||||
leafCollision.semantics === 'exact' ? 'duplicate' : leafCollision.semantics,
|
||||
{
|
||||
collisionKind: 'entry',
|
||||
semantics: leafCollision.semantics,
|
||||
pathPart: value,
|
||||
conflictPathPart: leafCollision.entry.value,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const segments = value.split('/');
|
||||
const prefixes = segments.slice(0, -1).map((_, prefixIndex) => (
|
||||
segments.slice(0, prefixIndex + 1).join('/')
|
||||
));
|
||||
for (const prefix of prefixes) {
|
||||
const directoryCollision = findSemanticCollision(directoryEntries, prefix, profile, index);
|
||||
if (directoryCollision.entry && directoryCollision.semantics !== 'exact') {
|
||||
throw collisionError(
|
||||
values,
|
||||
profile,
|
||||
index,
|
||||
directoryCollision.entry.index,
|
||||
`directory-${directoryCollision.semantics}`,
|
||||
{
|
||||
collisionKind: 'directory-spelling',
|
||||
semantics: directoryCollision.semantics,
|
||||
pathPart: prefix,
|
||||
conflictPathPart: directoryCollision.entry.value,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const fileCollision = findSemanticCollision(leafEntries, prefix, profile, index);
|
||||
if (fileCollision.entry) {
|
||||
throw collisionError(
|
||||
values,
|
||||
profile,
|
||||
index,
|
||||
fileCollision.entry.index,
|
||||
fileCollision.semantics === 'exact'
|
||||
? 'tree-file'
|
||||
: `tree-file-${fileCollision.semantics}`,
|
||||
{
|
||||
collisionKind: 'tree-file',
|
||||
semantics: fileCollision.semantics,
|
||||
pathPart: prefix,
|
||||
conflictPathPart: fileCollision.entry.value,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const directoryCollision = findSemanticCollision(directoryEntries, value, profile, index);
|
||||
if (directoryCollision.entry) {
|
||||
throw collisionError(
|
||||
values,
|
||||
profile,
|
||||
index,
|
||||
directoryCollision.entry.index,
|
||||
directoryCollision.semantics === 'exact'
|
||||
? 'tree-file'
|
||||
: `tree-file-${directoryCollision.semantics}`,
|
||||
{
|
||||
collisionKind: 'tree-file',
|
||||
semantics: directoryCollision.semantics,
|
||||
pathPart: value,
|
||||
conflictPathPart: directoryCollision.entry.value,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
rememberSemanticEntry(leafEntries, { index, value }, leafCollision.variants);
|
||||
for (const prefix of prefixes) {
|
||||
rememberSemanticEntry(
|
||||
directoryEntries,
|
||||
{ index, value: prefix },
|
||||
semanticEnvelope(prefix, profile, index),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { throwDiagnosticError, withDiagnosticRecordingSuppressed } from './diagnostics.mjs';
|
||||
import { sameEntry } from './path-semantics.mjs';
|
||||
import { parseRepositoryRemote, redactRepositoryRemote, repositorySourceHref } from './repository-location.mjs';
|
||||
|
||||
const FULL_SHA_RE = /^[a-f0-9]{40}$/i;
|
||||
const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/;
|
||||
const MAX_SOURCE_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
function evidenceFailure(code, message, { subject = {}, evidence = {}, supportedFixes = [] } = {}) {
|
||||
throwDiagnosticError(message, [{
|
||||
code,
|
||||
severity: 'error',
|
||||
message,
|
||||
subject: { surface: 'repository-evidence', ...subject },
|
||||
evidence,
|
||||
supportedFixes,
|
||||
}]);
|
||||
}
|
||||
|
||||
function runGit(repoRoot, args) {
|
||||
// 固定 SHA 的来源必须读取原始对象,不能使用本地 replacement refs 的替换内容。
|
||||
const result = spawnSync('git', ['--no-replace-objects', '-C', repoRoot, ...args], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: MAX_SOURCE_BYTES,
|
||||
});
|
||||
if (result.error) evidenceFailure('repository-evidence/git-unavailable', `Could not run Git: ${result.error.message}`, {
|
||||
evidence: { reason: result.error.message },
|
||||
supportedFixes: ['install Git and ensure it is available on PATH'],
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check types in one session, then read only blobs whose cited lines need
|
||||
// verification. Path-only references never require loading the file contents.
|
||||
function prefetchBlobs(repoRoot, objectNeedsContent) {
|
||||
const blobs = readBatchObjects(repoRoot, [...objectNeedsContent.keys()], false);
|
||||
if (!blobs) return null;
|
||||
const readable = [...objectNeedsContent].filter(([object, needsContent]) => {
|
||||
const blob = blobs.get(object);
|
||||
return needsContent && blob?.type === 'blob' && blob.size <= MAX_SOURCE_BYTES;
|
||||
}).map(([object]) => object);
|
||||
const contents = readBatchObjects(repoRoot, readable, true);
|
||||
if (contents) for (const [object, blob] of contents) blobs.set(object, blob);
|
||||
// Failed or oversized reads fall back in source order to the original
|
||||
// per-file path, preserving its size limit and diagnostic behavior.
|
||||
return blobs;
|
||||
}
|
||||
|
||||
function readBatchObjects(repoRoot, objects, includeContent) {
|
||||
if (!objects.length) return new Map();
|
||||
const mode = includeContent ? '--batch' : '--batch-check';
|
||||
const result = spawnSync('git', ['--no-replace-objects', '-C', repoRoot, 'cat-file', mode], {
|
||||
input: objects.join('\n') + '\n',
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (result.error || result.status !== 0 || !Buffer.isBuffer(result.stdout)) return null;
|
||||
const buffer = result.stdout;
|
||||
const blobs = new Map();
|
||||
let cursor = 0;
|
||||
for (const object of objects) {
|
||||
const newline = buffer.indexOf(0x0a, cursor);
|
||||
if (newline < 0) return null;
|
||||
const header = buffer.toString('utf8', cursor, newline);
|
||||
cursor = newline + 1;
|
||||
if (header.endsWith(' missing')) {
|
||||
blobs.set(object, { missing: true });
|
||||
continue;
|
||||
}
|
||||
const parts = header.split(' ');
|
||||
const size = Number(parts[2]);
|
||||
if (parts.length !== 3 || !Number.isSafeInteger(size) || size < 0) return null;
|
||||
const blob = { type: parts[1], size };
|
||||
if (includeContent) {
|
||||
if (cursor + size >= buffer.length || buffer[cursor + size] !== 0x0a) return null;
|
||||
blob.content = buffer.toString('utf8', cursor, cursor + size);
|
||||
cursor += size + 1;
|
||||
}
|
||||
blobs.set(object, blob);
|
||||
}
|
||||
return blobs;
|
||||
}
|
||||
|
||||
function gitValue(repoRoot, args, failure) {
|
||||
const result = runGit(repoRoot, args);
|
||||
if (result.status !== 0) evidenceFailure('repository-evidence/git-command', failure, {
|
||||
evidence: { gitArgs: args, exitCode: result.status },
|
||||
supportedFixes: ['use the intended local Git repository and verify its origin and revision'],
|
||||
});
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function verifiedSourcePath(value, where) {
|
||||
const sourcePath = String(value || '');
|
||||
// path-contract-allow: git-path -- Git tree entries use repository-relative POSIX syntax.
|
||||
if (!sourcePath || sourcePath.startsWith('/') || sourcePath.includes('\\') || CONTROL_CHARACTER_RE.test(sourcePath)) {
|
||||
evidenceFailure('repository-evidence/path-invalid', `${where} must be a repo-relative POSIX path.`, {
|
||||
subject: { path: where },
|
||||
evidence: { authoredPath: sourcePath },
|
||||
supportedFixes: ['use a repository-relative path with forward slashes'],
|
||||
});
|
||||
}
|
||||
const segments = sourcePath.split('/');
|
||||
if (segments.some((segment) => !segment || segment === '.' || segment === '..') || segments[0] === '.git') {
|
||||
evidenceFailure('repository-evidence/path-escape', `${where} must stay inside the repository and may not address .git.`, {
|
||||
subject: { path: where },
|
||||
evidence: { authoredPath: sourcePath },
|
||||
supportedFixes: ['remove empty, dot, parent, or .git path segments'],
|
||||
});
|
||||
}
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
function sourceLineCount(content) {
|
||||
if (!content.length) return 0;
|
||||
const lines = content.split(/\r\n|\n|\r/);
|
||||
return lines.length - (/(?:\r\n|\n|\r)$/.test(content) ? 1 : 0);
|
||||
}
|
||||
|
||||
// Every diagram type carries its nodes under a different property name, and
|
||||
// source evidence is authored on those nodes. One table keeps the verification
|
||||
// below identical for all five types instead of branching per type: the only
|
||||
// per-type fact is which array to read and which JSON pointer to quote back.
|
||||
const EVIDENCE_NODE_COLLECTIONS = {
|
||||
architecture: 'components',
|
||||
workflow: 'nodes',
|
||||
sequence: 'participants',
|
||||
dataflow: 'nodes',
|
||||
lifecycle: 'states',
|
||||
};
|
||||
|
||||
function evidenceNodes(diagramType, diagram) {
|
||||
const collection = EVIDENCE_NODE_COLLECTIONS[diagramType];
|
||||
if (!collection) return null;
|
||||
return { collection, nodes: Array.isArray(diagram?.[collection]) ? diagram[collection] : [] };
|
||||
}
|
||||
|
||||
export function hasRepositoryEvidence(diagramType, diagram) {
|
||||
const authored = evidenceNodes(diagramType, diagram);
|
||||
if (!authored) return false;
|
||||
return Boolean(diagram?.meta?.repository) || authored.nodes.some((node) => Array.isArray(node?.sources) && node.sources.length);
|
||||
}
|
||||
|
||||
export function verifyRepositoryEvidence(diagramType, diagram, repoRootInput) {
|
||||
if (!hasRepositoryEvidence(diagramType, diagram)) return null;
|
||||
const { collection, nodes: authoredNodes } = evidenceNodes(diagramType, diagram);
|
||||
|
||||
const repository = diagram.meta?.repository;
|
||||
if (!repository) evidenceFailure('repository-evidence/repository-required', 'Repository evidence requires /meta/repository.', {
|
||||
subject: { path: '/meta/repository', diagramType, collection },
|
||||
supportedFixes: [`add the pinned repository metadata or remove /${collection} sources`],
|
||||
});
|
||||
if (!FULL_SHA_RE.test(repository.revision || '')) {
|
||||
evidenceFailure('repository-evidence/revision-invalid', '/meta/repository/revision must be a full 40-character commit SHA.', {
|
||||
subject: { path: '/meta/repository/revision' },
|
||||
evidence: { revision: repository.revision },
|
||||
supportedFixes: ['pin one full 40-character commit SHA'],
|
||||
});
|
||||
}
|
||||
const location = parseRepositoryRemote(repository.url, { authored: true });
|
||||
if (!location) {
|
||||
// A filesystem path is the common authoring mistake: the field carries the
|
||||
// remote origin identity, which `git remote get-url origin` reports.
|
||||
const filesystemPath = /^(?:[\\/]|~|\.{1,2}(?:[\\/]|$)|[A-Za-z]:[\\/])/.test(String(repository.url ?? ''));
|
||||
evidenceFailure('repository-evidence/url-invalid', '/meta/repository/url must be a credential-free HTTP(S) or Git SSH repository address without query, fragment, or dot segments.', {
|
||||
subject: { path: '/meta/repository/url' },
|
||||
evidence: filesystemPath ? { authoredValueLooksLike: 'local filesystem path; the expected value is the remote origin address' } : {},
|
||||
supportedFixes: ['run `git remote get-url origin` inside --repo-root and declare that credential-free address', 'use link_mode: local-only for internal repositories'],
|
||||
});
|
||||
}
|
||||
const linkMode = repository.link_mode ?? 'web';
|
||||
if (!['web', 'local-only'].includes(linkMode)) evidenceFailure('repository-evidence/link-mode-invalid', 'Repository link_mode must be web or local-only.');
|
||||
if (repository.provider !== undefined && (!['github', 'gitee'].includes(repository.provider) || repository.provider !== location.provider)) {
|
||||
evidenceFailure('repository-evidence/provider-invalid', 'Repository provider must match its supported public host (github.com or gitee.com).', {
|
||||
subject: { path: '/meta/repository/provider' },
|
||||
supportedFixes: ['use the matching provider or omit provider and select link_mode: local-only'],
|
||||
});
|
||||
}
|
||||
if (linkMode === 'web' && (!location.provider || location.protocol !== 'https:' || location.endpoint !== 'standard' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(location.path))) {
|
||||
evidenceFailure('repository-evidence/links-unsupported', 'Web source links require a canonical GitHub or Gitee HTTPS owner/repository URL.', {
|
||||
subject: { path: '/meta/repository/url' },
|
||||
supportedFixes: ['use a canonical GitHub or Gitee URL, or select link_mode: local-only to retain local verification without web links'],
|
||||
});
|
||||
}
|
||||
if (!repoRootInput) {
|
||||
evidenceFailure('repository-evidence/root-required', 'This diagram declares source evidence. Pass --repo-root <repository> so Archify can verify it before rendering.', {
|
||||
subject: { path: '/meta/repository' },
|
||||
supportedFixes: ['pass --repo-root with the matching local Git checkout'],
|
||||
});
|
||||
}
|
||||
|
||||
const requestedRoot = path.resolve(repoRootInput);
|
||||
let realRoot;
|
||||
try {
|
||||
realRoot = fs.realpathSync(requestedRoot);
|
||||
} catch (error) {
|
||||
evidenceFailure('repository-evidence/root-unreadable', `Could not resolve evidence repository root "${requestedRoot}": ${error.message}`, {
|
||||
subject: { repoRoot: requestedRoot },
|
||||
evidence: { reason: error.message },
|
||||
supportedFixes: ['pass one readable local repository directory'],
|
||||
});
|
||||
}
|
||||
const gitRoot = gitValue(realRoot, ['rev-parse', '--show-toplevel'], `Evidence root "${realRoot}" is not a Git repository.`);
|
||||
const rootIdentity = sameEntry(realRoot, gitRoot);
|
||||
if (rootIdentity.status === 'unknown') {
|
||||
evidenceFailure('repository-evidence/root-identity-indeterminate', 'Could not determine whether the evidence root is the Git top-level directory.', {
|
||||
subject: { repoRoot: realRoot },
|
||||
evidence: { gitTopLevel: gitRoot, relation: rootIdentity.reason },
|
||||
supportedFixes: ['pass the readable Git top-level directory using its canonical filesystem path'],
|
||||
});
|
||||
}
|
||||
if (rootIdentity.status === 'different') {
|
||||
evidenceFailure('repository-evidence/root-not-top-level', `Evidence root must be the Git top-level directory: ${gitRoot}`, {
|
||||
subject: { repoRoot: realRoot },
|
||||
evidence: { gitTopLevel: gitRoot },
|
||||
supportedFixes: [`pass --repo-root ${gitRoot}`],
|
||||
});
|
||||
}
|
||||
const origin = gitValue(realRoot, ['remote', 'get-url', 'origin'], 'Evidence repository must have an origin remote.');
|
||||
if (parseRepositoryRemote(origin)?.identity !== location.identity) {
|
||||
const safeOrigin = redactRepositoryRemote(origin);
|
||||
evidenceFailure('repository-evidence/origin-mismatch', `Evidence repository origin ${JSON.stringify(safeOrigin)} does not match ${JSON.stringify(repository.url)}.`, {
|
||||
subject: { repoRoot: realRoot },
|
||||
evidence: { localOrigin: safeOrigin, authoredRepository: repository.url },
|
||||
supportedFixes: ['use the matching local checkout or correct the authored repository URL'],
|
||||
});
|
||||
}
|
||||
|
||||
const revision = repository.revision.toLowerCase();
|
||||
const commit = runGit(realRoot, ['cat-file', '-e', `${revision}^{commit}`]);
|
||||
if (commit.status !== 0) {
|
||||
evidenceFailure('repository-evidence/revision-unavailable', `Evidence revision ${revision} is not available in the local repository.`, {
|
||||
subject: { repoRoot: realRoot },
|
||||
evidence: { revision },
|
||||
supportedFixes: ['fetch the pinned commit or pin an available full commit SHA'],
|
||||
});
|
||||
}
|
||||
|
||||
// The batch is an optimization only: every path, line-range, file and line
|
||||
// check still runs in source order in the verification loop below, so a
|
||||
// citation the batch cannot answer for never reorders the first diagnostic.
|
||||
const citedObjects = new Map();
|
||||
for (const [nodeIndex, node] of authoredNodes.entries()) {
|
||||
if (!Array.isArray(node.sources) || node.sources.length === 0) continue;
|
||||
for (const [sourceIndex, authored] of node.sources.entries()) {
|
||||
const at = `/${collection}/${nodeIndex}/sources/${sourceIndex}`;
|
||||
let sourcePath;
|
||||
try {
|
||||
sourcePath = withDiagnosticRecordingSuppressed(() => verifiedSourcePath(authored.path, `${at}/path`));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const object = `${revision}:${sourcePath}`;
|
||||
citedObjects.set(object, citedObjects.get(object) || Boolean(authored.line));
|
||||
}
|
||||
}
|
||||
const prefetchedBlobs = prefetchBlobs(realRoot, citedObjects);
|
||||
|
||||
const nodes = Object.create(null);
|
||||
let referenceCount = 0;
|
||||
for (const [nodeIndex, node] of authoredNodes.entries()) {
|
||||
if (!Array.isArray(node.sources) || node.sources.length === 0) continue;
|
||||
// `componentId` shipped with the architecture-only path; keep it beside the
|
||||
// type-neutral `nodeId` so existing agent handling stays valid.
|
||||
const nodeSubject = collection === 'components'
|
||||
? { diagramType, collection, nodeId: node.id, componentId: node.id }
|
||||
: { diagramType, collection, nodeId: node.id };
|
||||
const verified = [];
|
||||
for (const [sourceIndex, authored] of node.sources.entries()) {
|
||||
const at = `/${collection}/${nodeIndex}/sources/${sourceIndex}`;
|
||||
const where = `${at}/path`;
|
||||
const source = {
|
||||
path: verifiedSourcePath(authored.path, where),
|
||||
...(authored.line ? { line: authored.line } : {}),
|
||||
...(authored.end_line ? { endLine: authored.end_line } : {}),
|
||||
...(authored.label ? { label: authored.label } : {}),
|
||||
};
|
||||
if (source.endLine && !source.line) {
|
||||
evidenceFailure('repository-evidence/line-required', `${at}/end_line requires line.`, {
|
||||
subject: { path: `${at}/end_line`, ...nodeSubject },
|
||||
supportedFixes: ['add line or remove end_line'],
|
||||
});
|
||||
}
|
||||
if (source.endLine && source.endLine < source.line) {
|
||||
evidenceFailure('repository-evidence/line-range-invalid', `${at}/end_line must be greater than or equal to line.`, {
|
||||
subject: { path: at, ...nodeSubject },
|
||||
evidence: { line: source.line, endLine: source.endLine },
|
||||
supportedFixes: ['use an end_line greater than or equal to line'],
|
||||
});
|
||||
}
|
||||
const object = `${revision}:${source.path}`;
|
||||
const prefetched = prefetchedBlobs ? prefetchedBlobs.get(object) : undefined;
|
||||
const objectIsBlob = prefetched
|
||||
? !prefetched.missing && prefetched.type === 'blob'
|
||||
: (() => {
|
||||
const type = runGit(realRoot, ['cat-file', '-t', object]);
|
||||
return type.status === 0 && type.stdout.trim() === 'blob';
|
||||
})();
|
||||
if (!objectIsBlob) {
|
||||
evidenceFailure('repository-evidence/file-missing', `${where} does not identify a file at revision ${revision}.`, {
|
||||
subject: { path: where, ...nodeSubject },
|
||||
evidence: { sourcePath: source.path, revision },
|
||||
supportedFixes: ['use a file path that exists at the pinned revision'],
|
||||
});
|
||||
}
|
||||
if (source.line) {
|
||||
const content = prefetched && Object.hasOwn(prefetched, 'content')
|
||||
? { status: 0, stdout: prefetched.content }
|
||||
: runGit(realRoot, ['show', object]);
|
||||
if (content.status !== 0) evidenceFailure('repository-evidence/file-unreadable', `${where} could not be read at revision ${revision}.`, {
|
||||
subject: { path: where, ...nodeSubject },
|
||||
evidence: { sourcePath: source.path, revision },
|
||||
supportedFixes: ['verify the pinned blob is readable in the local checkout'],
|
||||
});
|
||||
const lineCount = sourceLineCount(content.stdout);
|
||||
const requestedLine = source.endLine || source.line;
|
||||
if (requestedLine > lineCount) {
|
||||
evidenceFailure('repository-evidence/line-out-of-range', `${at} requests line ${requestedLine}, but ${source.path} has ${lineCount} lines at revision ${revision}.`, {
|
||||
subject: { path: at, ...nodeSubject },
|
||||
evidence: { sourcePath: source.path, requestedLine, lineCount, revision },
|
||||
supportedFixes: ['use a line range that exists at the pinned revision'],
|
||||
});
|
||||
}
|
||||
}
|
||||
verified.push({ ...source, ...(linkMode === 'web' ? { href: repositorySourceHref(location.provider, location.url, revision, source) } : {}) });
|
||||
referenceCount += 1;
|
||||
}
|
||||
nodes[node.id] = verified;
|
||||
}
|
||||
if (referenceCount === 0) {
|
||||
evidenceFailure('repository-evidence/source-required', `/meta/repository requires at least one /${collection} source reference.`, {
|
||||
subject: { path: '/meta/repository', diagramType, collection },
|
||||
supportedFixes: [`add at least one verified /${collection} source or remove repository metadata`],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
verified: true,
|
||||
repository: {
|
||||
url: location.url,
|
||||
revision,
|
||||
shortRevision: revision.slice(0, 7),
|
||||
label: location.provider === 'github' ? location.path : location.url.replace(/^(?:https?:\/\/|ssh:\/\/git@|git@)/, ''),
|
||||
...(linkMode === 'web' ? { href: `${location.url}/tree/${revision}` } : { linkMode }),
|
||||
},
|
||||
referenceCount,
|
||||
nodes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Repository identity and forge links are independent of local Git object checks.
|
||||
// This module never contacts a remote server or reads the user's SSH config.
|
||||
export function parseRepositoryRemote(value, { authored = false } = {}) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const raw = authored ? value : value.trim();
|
||||
if (!raw || /[\s\\\u0000-\u001f\u007f?#]/.test(raw)) return null;
|
||||
const scp = raw.match(/^git@([^/:]+):(.+)$/);
|
||||
const scpAbsolute = Boolean(scp && scp[2].startsWith('/'));
|
||||
const expanded = scp ? `ssh://git@${scp[1]}/${scp[2].replace(/^\//, '')}` : raw;
|
||||
const match = expanded.match(/^(https?|ssh):\/\/([^/]+)\/(.+)$/i);
|
||||
if (!match) return null;
|
||||
// Validate the original path before URL parsing can collapse dot segments.
|
||||
let segments;
|
||||
try {
|
||||
segments = match[3].replace(/\/$/, '').split('/');
|
||||
// Git passes SCP paths literally; percent escapes are decoded only in URIs.
|
||||
if (!scp) segments = segments.map(decodeURIComponent);
|
||||
}
|
||||
catch { return null; }
|
||||
if (segments.some((part) => !part || part === '.' || part === '..' || /[/\\\s\u0000-\u001f\u007f?#]/.test(part))) return null;
|
||||
let url;
|
||||
try { url = new URL(expanded); } catch { return null; }
|
||||
const protocol = url.protocol;
|
||||
if (protocol === 'ssh:' && (url.username !== 'git' || url.password)) return null;
|
||||
if (authored && protocol !== 'ssh:' && (url.username || url.password)) return null;
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
if (!hostname) return null;
|
||||
const provider = hostname === 'github.com' ? 'github' : hostname === 'gitee.com' ? 'gitee' : null;
|
||||
const last = segments.length - 1;
|
||||
if (provider) segments[last] = segments[last].replace(provider === 'github' ? /\.git$/i : /\.git$/, '');
|
||||
if (!segments[last] || segments[last] === '.' || segments[last] === '..') return null;
|
||||
const repositoryPath = segments.join('/');
|
||||
// Only known forges map HTTPS and SSH to one repository namespace. Other
|
||||
// hosts retain transport, port and remote-relative/absolute path semantics.
|
||||
const port = url.port || (protocol === 'ssh:' ? '22' : protocol === 'https:' ? '443' : '80');
|
||||
const endpoint = provider && ((protocol === 'https:' && port === '443') || (protocol === 'ssh:' && port === '22'))
|
||||
? 'standard' : `${protocol}${port}`;
|
||||
const pathKind = provider ? 'repository' : scp && !scpAbsolute ? 'relative' : 'absolute';
|
||||
// path-contract-allow: url-path -- GitHub repository names are case-insensitive URL identities.
|
||||
const identityPath = provider === 'github' ? repositoryPath.toLowerCase() : repositoryPath;
|
||||
const encodedPath = segments.map(encodeURIComponent).join('/');
|
||||
const canonicalUrl = scp ? `git@${hostname}:${scpAbsolute ? '/' : ''}${repositoryPath}`
|
||||
: `${protocol}//${protocol === 'ssh:' ? 'git@' : ''}${url.host}/${encodedPath}`;
|
||||
return { identity: JSON.stringify([hostname, endpoint, pathKind, identityPath]), url: canonicalUrl, provider, protocol, path: repositoryPath, endpoint };
|
||||
}
|
||||
|
||||
export function redactRepositoryRemote(value) {
|
||||
return String(value || '')
|
||||
.replace(/^((?:https?|ssh):\/\/)[^/]*@/i, '$1REDACTED@')
|
||||
.replace(/[?#].*$/s, '?REDACTED');
|
||||
}
|
||||
|
||||
export function repositorySourceHref(provider, url, revision, source) {
|
||||
const encodedPath = source.path.split('/').map(encodeURIComponent).join('/');
|
||||
const end = source.endLine && source.endLine !== source.line
|
||||
? `-${provider === 'github' ? 'L' : ''}${source.endLine}` : '';
|
||||
const fragment = source.line ? `#L${source.line}${end}` : '';
|
||||
return `${url}/blob/${revision}/${encodedPath}${fragment}`;
|
||||
}
|
||||
+614
@@ -0,0 +1,614 @@
|
||||
import { recordDiagnostic } from './diagnostics.mjs';
|
||||
import {
|
||||
asArray,
|
||||
isFinitePoint,
|
||||
normalizeRoutePoints,
|
||||
properSegmentIntersection,
|
||||
segmentIntersectsRect,
|
||||
} from './geometry.mjs';
|
||||
|
||||
const DEFAULTS = Object.freeze({
|
||||
clearance: 2,
|
||||
minimumDetourRatio: 2.5,
|
||||
minimumExcessLengthPx: 200,
|
||||
minimumEmptyExcursionPx: 96,
|
||||
maximumObstacleCount: 80,
|
||||
sharedCorridorMinimumPx: 32,
|
||||
});
|
||||
|
||||
const OUTWARD = Object.freeze({
|
||||
left: [-1, 0],
|
||||
right: [1, 0],
|
||||
top: [0, -1],
|
||||
bottom: [0, 1],
|
||||
});
|
||||
|
||||
function rounded(value) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function pointKey(point) {
|
||||
return `${point[0]}\u0000${point[1]}`;
|
||||
}
|
||||
|
||||
class MinHeap {
|
||||
constructor() {
|
||||
this.entries = [];
|
||||
}
|
||||
|
||||
push(key, distance) {
|
||||
const entry = { key, distance };
|
||||
this.entries.push(entry);
|
||||
let index = this.entries.length - 1;
|
||||
while (index > 0) {
|
||||
const parent = Math.floor((index - 1) / 2);
|
||||
if (this.entries[parent].distance <= distance) break;
|
||||
this.entries[index] = this.entries[parent];
|
||||
index = parent;
|
||||
}
|
||||
this.entries[index] = entry;
|
||||
}
|
||||
|
||||
pop() {
|
||||
if (!this.entries.length) return null;
|
||||
const first = this.entries[0];
|
||||
const last = this.entries.pop();
|
||||
if (!this.entries.length) return first;
|
||||
let index = 0;
|
||||
while (true) {
|
||||
const left = index * 2 + 1;
|
||||
const right = left + 1;
|
||||
if (left >= this.entries.length) break;
|
||||
const child = right < this.entries.length
|
||||
&& this.entries[right].distance < this.entries[left].distance ? right : left;
|
||||
if (this.entries[child].distance >= last.distance) break;
|
||||
this.entries[index] = this.entries[child];
|
||||
index = child;
|
||||
}
|
||||
this.entries[index] = last;
|
||||
return first;
|
||||
}
|
||||
}
|
||||
|
||||
function orthogonalLength(points) {
|
||||
let total = 0;
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
const [x1, y1] = points[index];
|
||||
const [x2, y2] = points[index + 1];
|
||||
if (x1 !== x2 && y1 !== y2) return null;
|
||||
total += Math.abs(x2 - x1) + Math.abs(y2 - y1);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function inferredSide(points, endpoint) {
|
||||
if (points.length < 2) return null;
|
||||
const start = endpoint === 'source' ? points[0] : points.at(-2);
|
||||
const end = endpoint === 'source' ? points[1] : points.at(-1);
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
if (endpoint === 'source') {
|
||||
if (dx > 0 && dy === 0) return 'right';
|
||||
if (dx < 0 && dy === 0) return 'left';
|
||||
if (dy > 0 && dx === 0) return 'bottom';
|
||||
if (dy < 0 && dx === 0) return 'top';
|
||||
} else {
|
||||
if (dx > 0 && dy === 0) return 'left';
|
||||
if (dx < 0 && dy === 0) return 'right';
|
||||
if (dy > 0 && dx === 0) return 'top';
|
||||
if (dy < 0 && dx === 0) return 'bottom';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function moveOutward(point, side, distance) {
|
||||
const [dx, dy] = OUTWARD[side] || [0, 0];
|
||||
return [point[0] + dx * distance, point[1] + dy * distance];
|
||||
}
|
||||
|
||||
function expandedRect(rect, clearance) {
|
||||
return {
|
||||
id: rect.id,
|
||||
x: rect.x - clearance,
|
||||
y: rect.y - clearance,
|
||||
width: rect.width + clearance * 2,
|
||||
height: rect.height + clearance * 2,
|
||||
};
|
||||
}
|
||||
|
||||
function boundsForRects(rects) {
|
||||
const usable = [...rects].filter((rect) => (
|
||||
rect && isFinitePoint(rect.x, rect.y, rect.width, rect.height)
|
||||
&& rect.width >= 0 && rect.height >= 0
|
||||
));
|
||||
if (!usable.length) return null;
|
||||
const left = Math.min(...usable.map((rect) => rect.x));
|
||||
const top = Math.min(...usable.map((rect) => rect.y));
|
||||
const right = Math.max(...usable.map((rect) => rect.x + rect.width));
|
||||
const bottom = Math.max(...usable.map((rect) => rect.y + rect.height));
|
||||
return { left, top, right, bottom, width: right - left, height: bottom - top };
|
||||
}
|
||||
|
||||
function boundsForPoints(points) {
|
||||
if (!points.length) return null;
|
||||
const xs = points.map(([x]) => x);
|
||||
const ys = points.map(([, y]) => y);
|
||||
const left = Math.min(...xs);
|
||||
const top = Math.min(...ys);
|
||||
const right = Math.max(...xs);
|
||||
const bottom = Math.max(...ys);
|
||||
return { left, top, right, bottom, width: right - left, height: bottom - top };
|
||||
}
|
||||
|
||||
function outsideExcursion(routeBounds, contentBounds) {
|
||||
if (!routeBounds || !contentBounds) return null;
|
||||
const sides = {
|
||||
left: Math.max(0, contentBounds.left - routeBounds.left),
|
||||
top: Math.max(0, contentBounds.top - routeBounds.top),
|
||||
right: Math.max(0, routeBounds.right - contentBounds.right),
|
||||
bottom: Math.max(0, routeBounds.bottom - contentBounds.bottom),
|
||||
};
|
||||
return { ...sides, maximum: Math.max(...Object.values(sides)) };
|
||||
}
|
||||
|
||||
function pointDistanceFromRect(point, rect) {
|
||||
const dx = Math.max(rect.x - point[0], 0, point[0] - (rect.x + rect.width));
|
||||
const dy = Math.max(rect.y - point[1], 0, point[1] - (rect.y + rect.height));
|
||||
return dx + dy;
|
||||
}
|
||||
|
||||
function emptyControlPointClearance(points, contentRects) {
|
||||
const controls = points.slice(1, -1);
|
||||
const rects = [...contentRects].filter((rect) => (
|
||||
rect && isFinitePoint(rect.x, rect.y, rect.width, rect.height)
|
||||
&& rect.width >= 0 && rect.height >= 0
|
||||
));
|
||||
if (!controls.length || !rects.length) return null;
|
||||
const distances = controls.map((point) => Math.min(
|
||||
...rects.map((rect) => pointDistanceFromRect(point, rect)),
|
||||
));
|
||||
const maximum = Math.max(...distances);
|
||||
return { maximum, point: controls[distances.indexOf(maximum)] };
|
||||
}
|
||||
|
||||
function pointBlocked(point, obstacles) {
|
||||
return obstacles.some((rect) => (
|
||||
point[0] >= rect.x && point[0] <= rect.x + rect.width
|
||||
&& point[1] >= rect.y && point[1] <= rect.y + rect.height
|
||||
));
|
||||
}
|
||||
|
||||
function segmentBlocked(start, end, obstacles) {
|
||||
return obstacles.some((rect) => segmentIntersectsRect({ start, end }, rect));
|
||||
}
|
||||
|
||||
function segmentConflictsWithAvoided(start, end, avoidedSegments, minimumOverlapPx, allowCrossings) {
|
||||
return avoidedSegments.some((segment) => (
|
||||
(!allowCrossings && (properSegmentIntersection(start, end, segment.start, segment.end)
|
||||
|| orthogonalTouchOnAvoidedInterior(start, end, segment.start, segment.end)))
|
||||
|| collinearOverlap(start, end, segment.start, segment.end) >= minimumOverlapPx
|
||||
));
|
||||
}
|
||||
|
||||
function orthogonalTouchOnAvoidedInterior(start, end, avoidedStart, avoidedEnd) {
|
||||
const epsilon = 0.0001;
|
||||
const candidateHorizontal = Math.abs(start[1] - end[1]) <= epsilon;
|
||||
const candidateVertical = Math.abs(start[0] - end[0]) <= epsilon;
|
||||
const avoidedHorizontal = Math.abs(avoidedStart[1] - avoidedEnd[1]) <= epsilon;
|
||||
const avoidedVertical = Math.abs(avoidedStart[0] - avoidedEnd[0]) <= epsilon;
|
||||
if (candidateHorizontal && avoidedVertical) {
|
||||
const x = avoidedStart[0];
|
||||
const y = start[1];
|
||||
return x >= Math.min(start[0], end[0]) - epsilon
|
||||
&& x <= Math.max(start[0], end[0]) + epsilon
|
||||
&& y > Math.min(avoidedStart[1], avoidedEnd[1]) + epsilon
|
||||
&& y < Math.max(avoidedStart[1], avoidedEnd[1]) - epsilon;
|
||||
}
|
||||
if (candidateVertical && avoidedHorizontal) {
|
||||
const x = start[0];
|
||||
const y = avoidedStart[1];
|
||||
return y >= Math.min(start[1], end[1]) - epsilon
|
||||
&& y <= Math.max(start[1], end[1]) + epsilon
|
||||
&& x > Math.min(avoidedStart[0], avoidedEnd[0]) + epsilon
|
||||
&& x < Math.max(avoidedStart[0], avoidedEnd[0]) - epsilon;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function pointOnSegmentInterior(point, start, end) {
|
||||
const epsilon = 0.0001;
|
||||
const cross = (end[0] - start[0]) * (point[1] - start[1])
|
||||
- (end[1] - start[1]) * (point[0] - start[0]);
|
||||
if (Math.abs(cross) > epsilon) return false;
|
||||
const dot = (point[0] - start[0]) * (point[0] - end[0])
|
||||
+ (point[1] - start[1]) * (point[1] - end[1]);
|
||||
return dot < -epsilon;
|
||||
}
|
||||
|
||||
function pointOnAvoidedInterior(point, avoidedSegments) {
|
||||
return avoidedSegments.some((segment) => (
|
||||
pointOnSegmentInterior(point, segment.start, segment.end)
|
||||
));
|
||||
}
|
||||
|
||||
function writeGridMetrics(metrics, patch) {
|
||||
if (!metrics || typeof metrics !== 'object') return;
|
||||
Object.assign(metrics, patch);
|
||||
}
|
||||
|
||||
export function shortestOrthogonalGridRoute({
|
||||
start,
|
||||
end,
|
||||
points,
|
||||
obstacles,
|
||||
fromSide,
|
||||
toSide,
|
||||
clearance,
|
||||
maximumObstacleCount,
|
||||
endpointStubPx = clearance + 2,
|
||||
maximumGridNodes = Infinity,
|
||||
avoidedSegments = [],
|
||||
allowAvoidedCrossings = false,
|
||||
minimumAvoidedOverlapPx = 8,
|
||||
routeSeparationPx = 8,
|
||||
minimumSegmentPx = 8,
|
||||
borderSegments = [],
|
||||
bendPenaltyPx = 0,
|
||||
metrics,
|
||||
}) {
|
||||
writeGridMetrics(metrics, {
|
||||
status: 'initializing',
|
||||
maximumGridNodes,
|
||||
obstacleCount: 0,
|
||||
avoidedSegmentCount: 0,
|
||||
coordinateCount: 0,
|
||||
candidateNodeCount: 0,
|
||||
usableNodeCount: 0,
|
||||
graphEdgeCount: 0,
|
||||
visitedNodeCount: 0,
|
||||
});
|
||||
if (!OUTWARD[fromSide] || !OUTWARD[toSide]) {
|
||||
writeGridMetrics(metrics, { status: 'unsupported-endpoint-side' });
|
||||
return null;
|
||||
}
|
||||
const startStub = moveOutward(start, fromSide, endpointStubPx);
|
||||
const endStub = moveOutward(end, toSide, endpointStubPx);
|
||||
// The graph may legally leave the initial endpoint bounds to find a clear
|
||||
// corridor. Keep every bounded obstacle and occupied relationship visible
|
||||
// to that search; filtering them against the initial box lets a detour walk
|
||||
// straight through geometry that only becomes relevant after it leaves the
|
||||
// box. The explicit obstacle/node budgets below keep this deterministic.
|
||||
const expanded = [...obstacles]
|
||||
.filter((rect) => rect && isFinitePoint(rect.x, rect.y, rect.width, rect.height))
|
||||
.map((rect) => expandedRect(rect, clearance));
|
||||
const relevantAvoidedSegments = [...avoidedSegments]
|
||||
.filter((segment) => segment?.start && segment?.end);
|
||||
// Frame borders may be crossed perpendicularly but never borrowed as a
|
||||
// corridor: the composition gate rejects any collinear run along them.
|
||||
const relevantBorderSegments = [...borderSegments]
|
||||
.filter((segment) => segment?.start && segment?.end);
|
||||
writeGridMetrics(metrics, {
|
||||
obstacleCount: expanded.length,
|
||||
avoidedSegmentCount: relevantAvoidedSegments.length,
|
||||
});
|
||||
if (expanded.length > maximumObstacleCount) {
|
||||
writeGridMetrics(metrics, { status: 'obstacle-budget-exceeded' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const xs = new Set([startStub[0], endStub[0], ...points.map(([x]) => x)]);
|
||||
const ys = new Set([startStub[1], endStub[1], ...points.map(([, y]) => y)]);
|
||||
for (const rect of expanded) {
|
||||
xs.add(rect.x - 1);
|
||||
xs.add(rect.x + rect.width + 1);
|
||||
ys.add(rect.y - 1);
|
||||
ys.add(rect.y + rect.height + 1);
|
||||
}
|
||||
for (const segment of relevantAvoidedSegments) {
|
||||
const [segmentStart, segmentEnd] = [segment.start, segment.end];
|
||||
xs.add(segmentStart[0]);
|
||||
xs.add(segmentEnd[0]);
|
||||
ys.add(segmentStart[1]);
|
||||
ys.add(segmentEnd[1]);
|
||||
if (Math.abs(segmentStart[0] - segmentEnd[0]) <= 0.0001) {
|
||||
xs.add(segmentStart[0] - routeSeparationPx);
|
||||
xs.add(segmentStart[0] + routeSeparationPx);
|
||||
}
|
||||
if (Math.abs(segmentStart[1] - segmentEnd[1]) <= 0.0001) {
|
||||
ys.add(segmentStart[1] - routeSeparationPx);
|
||||
ys.add(segmentStart[1] + routeSeparationPx);
|
||||
}
|
||||
}
|
||||
for (const segment of relevantBorderSegments) {
|
||||
if (Math.abs(segment.start[0] - segment.end[0]) <= 0.0001) {
|
||||
xs.add(segment.start[0] - routeSeparationPx);
|
||||
xs.add(segment.start[0] + routeSeparationPx);
|
||||
}
|
||||
if (Math.abs(segment.start[1] - segment.end[1]) <= 0.0001) {
|
||||
ys.add(segment.start[1] - routeSeparationPx);
|
||||
ys.add(segment.start[1] + routeSeparationPx);
|
||||
}
|
||||
}
|
||||
// Grid lines closer than a readable segment would let the search emit a
|
||||
// micro jog between two obstacle edges; keep the endpoint stubs and coalesce
|
||||
// the rest so every turn the route can take is at least one segment long.
|
||||
const coalesce = (values, keep) => values.sort((a, b) => a - b).filter((value, index, sorted) => (
|
||||
index === 0 || keep.has(value) || value - sorted[index - 1] >= minimumSegmentPx
|
||||
));
|
||||
const orderedX = coalesce([...xs], new Set([startStub[0], endStub[0]]));
|
||||
const orderedY = coalesce([...ys], new Set([startStub[1], endStub[1]]));
|
||||
const candidateNodeCount = orderedX.length * orderedY.length;
|
||||
writeGridMetrics(metrics, {
|
||||
coordinateCount: orderedX.length + orderedY.length,
|
||||
candidateNodeCount,
|
||||
});
|
||||
if (candidateNodeCount > maximumGridNodes) {
|
||||
writeGridMetrics(metrics, { status: 'node-budget-exceeded' });
|
||||
return null;
|
||||
}
|
||||
const nodes = new Map();
|
||||
for (const x of orderedX) {
|
||||
for (const y of orderedY) {
|
||||
const point = [x, y];
|
||||
if (!pointBlocked(point, expanded)
|
||||
&& (allowAvoidedCrossings || !pointOnAvoidedInterior(point, relevantAvoidedSegments))) {
|
||||
nodes.set(pointKey(point), point);
|
||||
}
|
||||
}
|
||||
}
|
||||
writeGridMetrics(metrics, { usableNodeCount: nodes.size });
|
||||
if (!nodes.has(pointKey(startStub)) || !nodes.has(pointKey(endStub))) {
|
||||
writeGridMetrics(metrics, { status: 'endpoint-blocked' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const adjacency = new Map([...nodes.keys()].map((key) => [key, []]));
|
||||
let graphEdgeCount = 0;
|
||||
const connectLine = (line, axis) => {
|
||||
for (let index = 0; index < line.length - 1; index += 1) {
|
||||
const left = line[index];
|
||||
const right = line[index + 1];
|
||||
if (segmentBlocked(left, right, expanded)) continue;
|
||||
if (segmentConflictsWithAvoided(
|
||||
left,
|
||||
right,
|
||||
relevantAvoidedSegments,
|
||||
minimumAvoidedOverlapPx,
|
||||
allowAvoidedCrossings,
|
||||
)) continue;
|
||||
if (relevantBorderSegments.some((segment) => (
|
||||
collinearOverlap(left, right, segment.start, segment.end) > 0.0001
|
||||
))) continue;
|
||||
const distance = Math.abs(right[0] - left[0]) + Math.abs(right[1] - left[1]);
|
||||
const leftKey = pointKey(left);
|
||||
const rightKey = pointKey(right);
|
||||
adjacency.get(leftKey).push([rightKey, distance, axis === 'h' ? 'R' : 'D']);
|
||||
adjacency.get(rightKey).push([leftKey, distance, axis === 'h' ? 'L' : 'U']);
|
||||
graphEdgeCount += 1;
|
||||
}
|
||||
};
|
||||
for (const y of orderedY) {
|
||||
connectLine(orderedX.map((x) => nodes.get(pointKey([x, y]))).filter(Boolean), 'h');
|
||||
}
|
||||
for (const x of orderedX) {
|
||||
connectLine(orderedY.map((y) => nodes.get(pointKey([x, y]))).filter(Boolean), 'v');
|
||||
}
|
||||
writeGridMetrics(metrics, { graphEdgeCount });
|
||||
|
||||
// The search state carries the incoming direction so a turn can cost extra
|
||||
// and a reversal is never taken: the pure shortest path hugs every obstacle
|
||||
// corner with a staircase of short jogs, while a bend-penalised one takes
|
||||
// the same corridor in a few long strokes. The first stub already leaves
|
||||
// the endpoint along its side and the last one arrives along the end side.
|
||||
const directionOf = ([dx, dy]) => (dx > 0 ? 'R' : dx < 0 ? 'L' : dy > 0 ? 'D' : 'U');
|
||||
const opposite = { R: 'L', L: 'R', D: 'U', U: 'D' };
|
||||
const stateKey = (key, direction) => `${key}|${direction}`;
|
||||
const sourceAxis = directionOf(OUTWARD[fromSide]);
|
||||
const targetAxis = opposite[directionOf(OUTWARD[toSide])];
|
||||
const source = pointKey(startStub);
|
||||
const target = pointKey(endStub);
|
||||
const sourceState = stateKey(source, sourceAxis);
|
||||
const distances = new Map([[sourceState, 0]]);
|
||||
const previous = new Map();
|
||||
const queue = new MinHeap();
|
||||
queue.push(sourceState, 0);
|
||||
let visitedNodeCount = 0;
|
||||
let targetState = null;
|
||||
while (queue.entries.length) {
|
||||
const next = queue.pop();
|
||||
const current = next.key;
|
||||
const currentDistance = next.distance;
|
||||
if (currentDistance !== distances.get(current)) continue;
|
||||
visitedNodeCount += 1;
|
||||
const [currentNode, currentAxis] = current.split('|');
|
||||
if (currentNode === target) {
|
||||
// Arriving on the wrong axis costs one final turn onto the end stub.
|
||||
const arrival = currentDistance + (currentAxis === targetAxis ? 0 : bendPenaltyPx);
|
||||
if (targetState == null || arrival < targetState.distance) {
|
||||
targetState = { key: current, distance: arrival };
|
||||
}
|
||||
if (currentAxis === targetAxis || bendPenaltyPx === 0) break;
|
||||
continue;
|
||||
}
|
||||
if (targetState && currentDistance >= targetState.distance) break;
|
||||
for (const [neighbor, weight, axis] of adjacency.get(currentNode) || []) {
|
||||
if (axis === opposite[currentAxis]) continue;
|
||||
const candidate = currentDistance + weight + (axis === currentAxis ? 0 : bendPenaltyPx);
|
||||
const neighborState = stateKey(neighbor, axis);
|
||||
if (candidate >= (distances.get(neighborState) ?? Infinity)) continue;
|
||||
distances.set(neighborState, candidate);
|
||||
previous.set(neighborState, current);
|
||||
queue.push(neighborState, candidate);
|
||||
}
|
||||
}
|
||||
writeGridMetrics(metrics, { visitedNodeCount });
|
||||
if (!targetState) {
|
||||
writeGridMetrics(metrics, { status: 'no-route' });
|
||||
return null;
|
||||
}
|
||||
const reversed = [];
|
||||
for (let key = targetState.key; key; key = previous.get(key)) {
|
||||
reversed.push(nodes.get(key.split('|')[0]));
|
||||
if (key === sourceState) break;
|
||||
}
|
||||
if (pointKey(reversed.at(-1)) !== source) {
|
||||
writeGridMetrics(metrics, { status: 'broken-predecessor-chain' });
|
||||
return null;
|
||||
}
|
||||
const shortestPoints = normalizeRoutePoints([start, ...reversed.reverse(), end]);
|
||||
writeGridMetrics(metrics, { status: 'routed' });
|
||||
return {
|
||||
points: shortestPoints,
|
||||
length: orthogonalLength(shortestPoints),
|
||||
obstacleCount: expanded.length,
|
||||
};
|
||||
}
|
||||
|
||||
function collinearOverlap(leftStart, leftEnd, rightStart, rightEnd) {
|
||||
if (leftStart[0] === leftEnd[0] && rightStart[0] === rightEnd[0]
|
||||
&& leftStart[0] === rightStart[0]) {
|
||||
return Math.max(0, Math.min(Math.max(leftStart[1], leftEnd[1]), Math.max(rightStart[1], rightEnd[1]))
|
||||
- Math.max(Math.min(leftStart[1], leftEnd[1]), Math.min(rightStart[1], rightEnd[1])));
|
||||
}
|
||||
if (leftStart[1] === leftEnd[1] && rightStart[1] === rightEnd[1]
|
||||
&& leftStart[1] === rightStart[1]) {
|
||||
return Math.max(0, Math.min(Math.max(leftStart[0], leftEnd[0]), Math.max(rightStart[0], rightEnd[0]))
|
||||
- Math.max(Math.min(leftStart[0], leftEnd[0]), Math.min(rightStart[0], rightEnd[0])));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function segmentOutsideContent(start, end, contentBounds) {
|
||||
if (!contentBounds) return false;
|
||||
const midpoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2];
|
||||
return midpoint[0] < contentBounds.left || midpoint[0] > contentBounds.right
|
||||
|| midpoint[1] < contentBounds.top || midpoint[1] > contentBounds.bottom;
|
||||
}
|
||||
|
||||
function sharesOuterCorridor({ relation, relations, pathFor, points, contentBounds, minimumOverlap }) {
|
||||
for (const other of asArray(relations)) {
|
||||
if (!other || other === relation) continue;
|
||||
const related = relation.from === other.from || relation.from === other.to
|
||||
|| relation.to === other.from || relation.to === other.to;
|
||||
if (!related) continue;
|
||||
const otherPoints = normalizeRoutePoints(pathFor(other)?.points || []);
|
||||
for (let left = 0; left < points.length - 1; left += 1) {
|
||||
if (!segmentOutsideContent(points[left], points[left + 1], contentBounds)) continue;
|
||||
for (let right = 0; right < otherPoints.length - 1; right += 1) {
|
||||
if (collinearOverlap(points[left], points[left + 1], otherPoints[right], otherPoints[right + 1]) >= minimumOverlap) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function relationshipSubject(diagramType, relationCollection, relationIndex, relation) {
|
||||
return {
|
||||
diagramType,
|
||||
collection: relationCollection,
|
||||
index: relationIndex,
|
||||
...(relation.id ? { id: relation.id } : {}),
|
||||
from: relation.from,
|
||||
to: relation.to,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject conspicuous authored detours without penalizing routes whose length is
|
||||
* explained by opaque-node avoidance or a related shared outer corridor.
|
||||
*/
|
||||
export function cleanRouteDetourProblems({
|
||||
relations,
|
||||
obstacles,
|
||||
contentRects = obstacles,
|
||||
endpointIds,
|
||||
pathFor,
|
||||
fromSideFor,
|
||||
toSideFor,
|
||||
diagramType,
|
||||
relationCollection,
|
||||
profile,
|
||||
thresholds = {},
|
||||
}) {
|
||||
if (profile !== 'showcase') return [];
|
||||
const policy = { ...DEFAULTS, ...thresholds };
|
||||
const obstacleList = [...obstacles];
|
||||
const contentBounds = boundsForRects(contentRects);
|
||||
const problems = [];
|
||||
for (const [relationIndex, relation] of asArray(relations).entries()) {
|
||||
if (!relation || !endpointIds?.has(relation.from) || !endpointIds?.has(relation.to)) continue;
|
||||
if (!Array.isArray(relation.via) || relation.via.length === 0) continue;
|
||||
const points = normalizeRoutePoints(pathFor(relation)?.points || []);
|
||||
if (points.length < 3 || !points.every((point) => Array.isArray(point) && isFinitePoint(...point))) continue;
|
||||
const actualLength = orthogonalLength(points);
|
||||
if (!Number.isFinite(actualLength)) continue;
|
||||
const start = points[0];
|
||||
const end = points.at(-1);
|
||||
const manhattan = Math.abs(end[0] - start[0]) + Math.abs(end[1] - start[1]);
|
||||
if (actualLength < manhattan * policy.minimumDetourRatio
|
||||
|| actualLength - manhattan < policy.minimumExcessLengthPx) continue;
|
||||
const routeBounds = boundsForPoints(points);
|
||||
const excursion = outsideExcursion(routeBounds, contentBounds);
|
||||
const emptyClearance = emptyControlPointClearance(points, obstacleList);
|
||||
if (Math.max(excursion?.maximum || 0, emptyClearance?.maximum || 0)
|
||||
< policy.minimumEmptyExcursionPx) continue;
|
||||
if (sharesOuterCorridor({
|
||||
relation,
|
||||
relations,
|
||||
pathFor,
|
||||
points,
|
||||
contentBounds,
|
||||
minimumOverlap: policy.sharedCorridorMinimumPx,
|
||||
})) continue;
|
||||
|
||||
const fromSide = fromSideFor?.(relation) || inferredSide(points, 'source');
|
||||
const toSide = toSideFor?.(relation) || inferredSide(points, 'target');
|
||||
const shortest = shortestOrthogonalGridRoute({
|
||||
start,
|
||||
end,
|
||||
points,
|
||||
obstacles: obstacleList,
|
||||
fromSide,
|
||||
toSide,
|
||||
clearance: policy.clearance,
|
||||
maximumObstacleCount: policy.maximumObstacleCount,
|
||||
});
|
||||
if (!shortest || !Number.isFinite(shortest.length) || shortest.length <= 0) continue;
|
||||
const detourRatio = actualLength / shortest.length;
|
||||
const excessLength = actualLength - shortest.length;
|
||||
if (detourRatio < policy.minimumDetourRatio || excessLength < policy.minimumExcessLengthPx) continue;
|
||||
|
||||
const relationId = relation.id ? ` id "${relation.id}"` : '';
|
||||
const message = `[composition/excessive-route-detour] ${diagramType} ${relationCollection}[${relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" travels ${Math.round(actualLength)}px, ${rounded(detourRatio)}x the ${Math.round(shortest.length)}px shortest obstacle-clearing orthogonal route, and reaches ${Math.round(excursion.maximum)}px beyond the content bounds — remove the distant via corridor or move it close to the connected content.`;
|
||||
const supportedFix = 'remove the distant via points and retry automatic routing, or keep the endpoint sides and move the via corridor near the connected nodes while preserving labels and direction';
|
||||
recordDiagnostic({
|
||||
code: 'composition/excessive-route-detour',
|
||||
severity: 'error',
|
||||
message,
|
||||
subject: relationshipSubject(diagramType, relationCollection, relationIndex, relation),
|
||||
evidence: {
|
||||
points,
|
||||
actualLengthPx: rounded(actualLength),
|
||||
shortestLegalPoints: shortest.points,
|
||||
shortestLegalLengthPx: rounded(shortest.length),
|
||||
detourRatio: rounded(detourRatio),
|
||||
excessLengthPx: rounded(excessLength),
|
||||
routeBounds,
|
||||
contentBounds,
|
||||
emptyExcursionPx: excursion,
|
||||
emptyControlPointClearancePx: emptyClearance,
|
||||
obstacleCount: shortest.obstacleCount,
|
||||
thresholds: {
|
||||
minimumDetourRatio: policy.minimumDetourRatio,
|
||||
minimumExcessLengthPx: policy.minimumExcessLengthPx,
|
||||
minimumEmptyExcursionPx: policy.minimumEmptyExcursionPx,
|
||||
},
|
||||
},
|
||||
supportedFixes: [supportedFix],
|
||||
});
|
||||
problems.push(message);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const SIDECAR_STEM_NAMESPACE = /\.~archify-[0-9a-f]{64}$/iu;
|
||||
|
||||
export function sidecarStemNeedsBounding(stem, suffixes) {
|
||||
const fits = (value) => value.length <= 255 && Buffer.byteLength(value, 'utf8') <= 255;
|
||||
return !suffixes.every((suffix) => fits(`${stem}${suffix}`));
|
||||
}
|
||||
|
||||
export function boundedSidecarStem(stem, suffixes, { force = false, hashDomain } = {}) {
|
||||
if (!force && !sidecarStemNeedsBounding(stem, suffixes)
|
||||
&& !SIDECAR_STEM_NAMESPACE.test(stem)) return stem;
|
||||
const hashInput = hashDomain ? `${hashDomain}\0${stem}` : stem;
|
||||
const marker = `.~archify-${createHash('sha256').update(hashInput).digest('hex')}`;
|
||||
const codePoints = [...stem];
|
||||
while (codePoints.length
|
||||
&& sidecarStemNeedsBounding(`${codePoints.join('')}${marker}`, suffixes)) {
|
||||
codePoints.pop();
|
||||
}
|
||||
return `${codePoints.join('')}${marker}`;
|
||||
}
|
||||
|
||||
export function sidecarStemFromComponent(component) {
|
||||
if (component.endsWith('.html')) {
|
||||
return {
|
||||
stem: component.slice(0, -'.html'.length),
|
||||
options: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
stem: component,
|
||||
options: { force: true, hashDomain: 'full-component' },
|
||||
};
|
||||
}
|
||||
|
||||
export function isBoundedSidecarStem(stem) {
|
||||
return SIDECAR_STEM_NAMESPACE.test(stem);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/** Uniform spatial grid for "which items can this box reach" queries (#8). */
|
||||
|
||||
// A cell box wider than this is not worth walking: the query returns every
|
||||
// inserted item instead, which stays a superset of what the box reaches.
|
||||
const DEFAULT_MAX_CELLS = 4096;
|
||||
|
||||
export function createSpatialGrid(cellSize, { maxCells = DEFAULT_MAX_CELLS } = {}) {
|
||||
const buckets = new Map();
|
||||
const items = [];
|
||||
// Items whose own box cannot be walked are candidates for every query.
|
||||
const overflow = [];
|
||||
const keyOf = (x, y) => x + ':' + y;
|
||||
const rangeOf = (box) => ({
|
||||
x0: Math.floor(box.minX / cellSize), x1: Math.floor(box.maxX / cellSize),
|
||||
y0: Math.floor(box.minY / cellSize), y1: Math.floor(box.maxY / cellSize),
|
||||
});
|
||||
// Finite coordinates can still land outside the safe-integer range, where
|
||||
// incrementing an index no longer advances it; and a legal coordinate can
|
||||
// name more cells than the grid is worth. Both cases stay out of the buckets
|
||||
// and are answered by the item list, so they never hang or allocate.
|
||||
const walkable = (range) => Number.isSafeInteger(range.x0) && Number.isSafeInteger(range.x1)
|
||||
&& Number.isSafeInteger(range.y0) && Number.isSafeInteger(range.y1)
|
||||
&& (range.x1 - range.x0 + 1) * (range.y1 - range.y0 + 1) <= maxCells;
|
||||
|
||||
return {
|
||||
insert(box, item) {
|
||||
items.push(item);
|
||||
const range = rangeOf(box);
|
||||
if (!walkable(range)) {
|
||||
overflow.push(item);
|
||||
return;
|
||||
}
|
||||
for (let x = range.x0; x <= range.x1; x += 1) {
|
||||
for (let y = range.y0; y <= range.y1; y += 1) {
|
||||
const key = keyOf(x, y);
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) { bucket = []; buckets.set(key, bucket); }
|
||||
bucket.push(item);
|
||||
}
|
||||
}
|
||||
},
|
||||
query(box) {
|
||||
const range = rangeOf(box);
|
||||
if (!walkable(range)) return items.slice();
|
||||
const seen = new Set();
|
||||
const found = [];
|
||||
for (let x = range.x0; x <= range.x1; x += 1) {
|
||||
for (let y = range.y0; y <= range.y1; y += 1) {
|
||||
const bucket = buckets.get(keyOf(x, y));
|
||||
if (!bucket) continue;
|
||||
for (const item of bucket) {
|
||||
if (seen.has(item)) continue;
|
||||
seen.add(item);
|
||||
found.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const item of overflow) {
|
||||
if (seen.has(item)) continue;
|
||||
seen.add(item);
|
||||
found.push(item);
|
||||
}
|
||||
return found;
|
||||
},
|
||||
};
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
// Single-line node text fitting, shared by every renderer.
|
||||
//
|
||||
// Node text (`label`, `sublabel`, `tag`) renders as one <text> element with
|
||||
// text-anchor="middle" and is never wrapped. Left unmeasured, an over-long
|
||||
// value silently spills across its neighbours while validation still reports
|
||||
// a clean receipt — the failure mode this module exists to close.
|
||||
//
|
||||
// Two halves, always used together:
|
||||
// - fittedNodeFontSize shrinks the text toward a legible minimum at render
|
||||
// time, so ordinary overruns simply get smaller instead of overlapping.
|
||||
// - minimumNodeTextWidth reports the width the text still needs once it has
|
||||
// shrunk as far as it may, so validation can reject what shrinking cannot
|
||||
// save.
|
||||
//
|
||||
// The geometry constants below are shared; the per-field `preferred` and
|
||||
// `minimum` font sizes are not, because renderers set node text at different
|
||||
// sizes (architecture sublabels are 9px, the rest are 7px).
|
||||
|
||||
import { textUnits, SEMANTIC_SIGIL_INSET, SEMANTIC_SIGIL_SIZE, SEMANTIC_SIGIL_FOOTPRINT, SOURCE_BADGE_FOOTPRINT } from './utils.mjs';
|
||||
|
||||
// widthFactor: px of advance width per text unit, per px of font size.
|
||||
// horizontalPadding: total px reserved inside the box so text never touches
|
||||
// the border.
|
||||
export const nodeTextFit = {
|
||||
widthFactor: 0.6,
|
||||
horizontalPadding: 8,
|
||||
};
|
||||
|
||||
// Largest font size at or below `preferred` that fits `text` inside `width`,
|
||||
// floored at `minimum` — below that the text is no longer legible and the
|
||||
// caller should be reporting a problem instead.
|
||||
export function fittedNodeFontSize(text, width, preferred, minimum) {
|
||||
const units = Math.max(1, textUnits(text));
|
||||
const available = Math.max(1, width - nodeTextFit.horizontalPadding);
|
||||
const fitted = Math.min(preferred, available / (units * nodeTextFit.widthFactor));
|
||||
return Math.max(minimum, Math.floor(fitted * 10) / 10);
|
||||
}
|
||||
|
||||
// Width `text` occupies at its legible minimum. Compare against
|
||||
// `width - nodeTextFit.horizontalPadding` to decide whether shrink-to-fit can
|
||||
// rescue it.
|
||||
export function minimumNodeTextWidth(text, minimum) {
|
||||
return textUnits(text) * minimum * nodeTextFit.widthFactor;
|
||||
}
|
||||
|
||||
// Available text width inside a box of `width`.
|
||||
export function availableNodeTextWidth(width) {
|
||||
return width - nodeTextFit.horizontalPadding;
|
||||
}
|
||||
|
||||
// Adapted from Souptik Chakraborty's #220: shift only labels which reach a
|
||||
// corner icon. Unlike the original hard gate, a narrow valid node uses a
|
||||
// separate text row; its authored bounds and acceptance remain unchanged.
|
||||
export function nodeLabelLayout({ width, height, rows, side = 'left', brand = false, source = false, step = '' }) {
|
||||
const result = { x: width / 2, ys: rows.map(row => row.y), sigilY: SEMANTIC_SIGIL_INSET, sigilSize: SEMANTIC_SIGIL_SIZE };
|
||||
const labelWidth = minimumNodeTextWidth(rows[0].text, rows[0].font);
|
||||
const stepEnd = step ? (side === 'left' ? 23 : 10) + minimumNodeTextWidth(step, 8) + 3 : 0;
|
||||
const left = Math.max(side === 'left' ? SEMANTIC_SIGIL_FOOTPRINT + 2 : 4, stepEnd);
|
||||
const right = width - (brand ? 26 : side === 'right' ? SEMANTIC_SIGIL_FOOTPRINT + 2 : 4)
|
||||
- (source ? SOURCE_BADGE_FOOTPRINT : 0);
|
||||
if (result.x - labelWidth / 2 >= left && result.x + labelWidth / 2 <= right) return result;
|
||||
if (labelWidth <= right - left) {
|
||||
// Round away from the icon, retaining the node centre whenever possible.
|
||||
result.x = Math.min(Math.floor((right - labelWidth / 2) * 10) / 10,
|
||||
Math.max(result.x, Math.ceil((left + labelWidth / 2) * 10) / 10));
|
||||
return result;
|
||||
}
|
||||
// Keep the existing font sizes and put the text below the decoration rail.
|
||||
// Conservative ascent/descent bounds also protect CJK and fallback fonts.
|
||||
let bottom = Math.max(brand ? 22 : SEMANTIC_SIGIL_FOOTPRINT, source ? 19 : 0);
|
||||
const ys = rows.map(row => {
|
||||
const y = Math.max(row.y, Math.ceil((bottom + 2 + row.font * 1.2) * 10) / 10);
|
||||
bottom = y + row.font * 0.3;
|
||||
return y;
|
||||
});
|
||||
if (bottom <= height - 2) {
|
||||
result.ys = ys;
|
||||
return result;
|
||||
}
|
||||
if (source) {
|
||||
// A source badge adds a second decoration on the right. On short boxes,
|
||||
// restoring the original rows would put the title back under that badge.
|
||||
// Try compact leading before giving up the dedicated text rail. Retain
|
||||
// every font size and the authored box; only this crowded fallback packs
|
||||
// the rows, with a full em above each baseline and 0.3 em below it.
|
||||
let compactBottom = Math.max(brand ? 22 : SEMANTIC_SIGIL_FOOTPRINT, 19) + 1;
|
||||
const compactYs = rows.map(row => {
|
||||
const y = Math.ceil((compactBottom + 1 + row.font) * 10) / 10;
|
||||
compactBottom = y + row.font * 0.3;
|
||||
return y;
|
||||
});
|
||||
// The compact fallback may also use the otherwise reserved bottom
|
||||
// padding; the entire descent still stays inside the fixed box.
|
||||
if (compactBottom <= height - 0.5) {
|
||||
result.ys = compactYs;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
// A deliberately short fixed box may have no spare row. Preserve its text
|
||||
// and geometry, and fit only the decorative sigil in the space above it.
|
||||
result.sigilY = 1;
|
||||
result.sigilSize = Math.max(1, Math.min(SEMANTIC_SIGIL_SIZE,
|
||||
Math.floor(rows[0].y - rows[0].font * 1.2 - 3)));
|
||||
return result;
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
escapeHtml as esc,
|
||||
localizeTemplate,
|
||||
resolveLocale,
|
||||
translateMessage,
|
||||
viewerCatalog,
|
||||
} from './i18n.mjs';
|
||||
|
||||
export { esc };
|
||||
|
||||
export function renderDefinitions() {
|
||||
return ` <!-- Definitions -->
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" class="m-default" />
|
||||
</marker>
|
||||
<marker id="arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
|
||||
</marker>
|
||||
<marker id="arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" class="m-security" />
|
||||
</marker>
|
||||
<marker id="arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
|
||||
</marker>
|
||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>`;
|
||||
}
|
||||
|
||||
const SIGIL_TONE = {
|
||||
frontend: 'frontend',
|
||||
start: 'frontend',
|
||||
backend: 'backend',
|
||||
active: 'frontend',
|
||||
database: 'database',
|
||||
success: 'backend',
|
||||
cloud: 'cloud',
|
||||
waiting: 'cloud',
|
||||
security: 'security',
|
||||
decision: 'database',
|
||||
failure: 'security',
|
||||
messagebus: 'messagebus',
|
||||
external: 'external',
|
||||
neutral: 'external',
|
||||
};
|
||||
|
||||
const SIGIL_SHAPE = {
|
||||
calendar: `<rect x="2" y="3.5" width="12" height="10.5" rx="2"/><path d="M5 2v3M11 2v3M2 7h12M5 10h2M9 10h2"/>`,
|
||||
clock: `<circle cx="8" cy="8" r="6"/><path d="M8 4v4l3 2"/>`,
|
||||
person: `<circle cx="8" cy="4.5" r="2.5"/><path d="M3 14v-2a5 5 0 0 1 10 0v2"/>`,
|
||||
briefcase: `<rect x="2" y="5" width="12" height="9" rx="2"/><path d="M5 5V2h6v3M2 9h12M7 9v2h2V9"/>`,
|
||||
flag: `<path d="M3 14V2h10l-2 3 2 3H3"/>`,
|
||||
moon: `<path d="M13.5 10A6 6 0 0 1 6 2.5 6 6 0 1 0 13.5 10Z"/>`,
|
||||
frontend: `<rect x="2" y="3" width="12" height="10" rx="2"/>
|
||||
<path d="M2 6.5h12"/>
|
||||
<circle cx="4.1" cy="4.8" r=".7" class="sigil-fill"/>
|
||||
<circle cx="6.3" cy="4.8" r=".7" class="sigil-fill"/>`,
|
||||
backend: `<path d="M6 3 3 8l3 5M10 3l3 5-3 5"/>`,
|
||||
database: `<ellipse cx="8" cy="4" rx="5" ry="2"/>
|
||||
<path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2"/>`,
|
||||
cloud: `<path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z"/>`,
|
||||
security: `<path d="M8 2.2 13 4v3.5c0 3.1-1.8 5.4-5 6.5-3.2-1.1-5-3.4-5-6.5V4Z"/>
|
||||
<path d="m5.8 8 1.5 1.5 3-3"/>`,
|
||||
messagebus: `<path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/>
|
||||
<circle cx="5" cy="4.5" r="1" class="sigil-fill"/>
|
||||
<circle cx="10.5" cy="8" r="1" class="sigil-fill"/>
|
||||
<circle cx="7" cy="11.5" r="1" class="sigil-fill"/>`,
|
||||
external: `<rect x="2.5" y="5" width="8.5" height="8" rx="1.5"/>
|
||||
<path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5"/>`,
|
||||
start: `<circle cx="8" cy="8" r="5"/>
|
||||
<path d="m7 5.4 3.6 2.6L7 10.6Z" class="sigil-fill"/>`,
|
||||
active: `<path d="M2 8h3l1.5-3.5L9 12l1.6-4H14"/>`,
|
||||
waiting: `<path d="M4 2.5h8M4 13.5h8M5 3c0 2.8 2 3.2 3 5-1 1.8-3 2.2-3 5M11 3c0 2.8-2 3.2-3 5 1 1.8 3 2.2 3 5"/>`,
|
||||
success: `<circle cx="8" cy="8" r="5.3"/>
|
||||
<path d="m5.2 8 1.8 1.8 3.8-4"/>`,
|
||||
failure: `<circle cx="8" cy="8" r="5.3"/>
|
||||
<path d="m5.7 5.7 4.6 4.6m0-4.6-4.6 4.6"/>`,
|
||||
neutral: `<rect x="3" y="3" width="10" height="10" rx="2"/>
|
||||
<circle cx="8" cy="8" r="1.2" class="sigil-fill"/>`,
|
||||
};
|
||||
|
||||
// A quiet, renderer-owned corner symbol (type default or authored icon). It is
|
||||
// SVG content rather than a
|
||||
// viewer overlay, so it survives canonical export while adding no focus target,
|
||||
// accessible name, layout box, or interaction state of its own.
|
||||
// Shared with label clearance so the reserved rail matches the actual icon.
|
||||
export const SEMANTIC_SIGIL_INSET = 6;
|
||||
export const SEMANTIC_SIGIL_SIZE = 11;
|
||||
export const SEMANTIC_SIGIL_FOOTPRINT = SEMANTIC_SIGIL_INSET + SEMANTIC_SIGIL_SIZE;
|
||||
// The viewer installs a runtime "sources" beacon on the node's top-right
|
||||
// rail, just left of the brand mark. Layout must reserve the same footprint
|
||||
// so labels never sit under the badge.
|
||||
export const SOURCE_BADGE_FOOTPRINT = 38;
|
||||
|
||||
export function renderSemanticSigil(kind, { x, y, size = SEMANTIC_SIGIL_SIZE, icon } = {}) {
|
||||
if (icon === 'none') return '';
|
||||
const selected = icon ?? kind;
|
||||
const normalized = Object.hasOwn(SIGIL_SHAPE, selected) ? selected : 'neutral';
|
||||
const tone = SIGIL_TONE[kind] || 'external';
|
||||
const scale = size / 16;
|
||||
return `<g aria-hidden="true" data-semantic-sigil="${esc(normalized)}" class="semantic-sigil s-${tone}" transform="translate(${x} ${y}) scale(${scale})">
|
||||
${SIGIL_SHAPE[normalized]}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
export function renderCards(cards) {
|
||||
const list = Array.isArray(cards) ? cards : [];
|
||||
return ` <!-- Info Cards -->
|
||||
<div class="cards">
|
||||
${list.map((card) => ` <div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-dot ${esc(card.dot)}"></div>
|
||||
<h3>${esc(card.title)}</h3>
|
||||
</div>
|
||||
<ul>
|
||||
${card.items.map((item) => ` <li>${esc(item)}</li>`).join('\n')}
|
||||
</ul>
|
||||
</div>`).join('\n\n')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const SVG_SLOT_RE = / <!-- ARCHIFY:SVG_SLOT_START -->[\s\S]*? <!-- ARCHIFY:SVG_SLOT_END -->/;
|
||||
const CARDS_SLOT_RE = / <!-- ARCHIFY:CARDS_SLOT_START -->[\s\S]*? <!-- ARCHIFY:CARDS_SLOT_END -->/;
|
||||
const SUBTITLE_SLOT_RE = /^([ \t]*)<p class="subtitle">\[Subtitle description\]<\/p>[ \t]*(\r?\n)?/m;
|
||||
const SOURCE_EVIDENCE_PLACEHOLDER = ' <!-- ARCHIFY:SOURCE_EVIDENCE_DATA -->';
|
||||
const I18N_PLACEHOLDER = ' <!-- ARCHIFY:I18N_DATA -->';
|
||||
|
||||
function serializeScriptJson(value) {
|
||||
return JSON.stringify(value)
|
||||
.replaceAll('<', '\\u003c')
|
||||
.replaceAll('>', '\\u003e')
|
||||
.replaceAll('&', '\\u0026');
|
||||
}
|
||||
|
||||
const TEMPLATE_PLACEHOLDERS = [
|
||||
'<html lang="en" data-theme="dark" data-preset="[VISUAL PRESET]">',
|
||||
'<title>[PROJECT NAME] Architecture Diagram</title>',
|
||||
'<h1>[PROJECT NAME] Architecture</h1>',
|
||||
I18N_PLACEHOLDER,
|
||||
];
|
||||
|
||||
export function applyTemplate(template, {
|
||||
title,
|
||||
subtitle,
|
||||
svg,
|
||||
cards,
|
||||
locale,
|
||||
visualPreset = 'classic',
|
||||
sourceEvidence = null,
|
||||
}) {
|
||||
if (!SVG_SLOT_RE.test(template)) {
|
||||
throw new Error('applyTemplate: template missing ARCHIFY:SVG_SLOT sentinel');
|
||||
}
|
||||
if (!CARDS_SLOT_RE.test(template)) {
|
||||
throw new Error('applyTemplate: template missing ARCHIFY:CARDS_SLOT sentinel');
|
||||
}
|
||||
if (!SUBTITLE_SLOT_RE.test(template)) {
|
||||
throw new Error('applyTemplate: template missing subtitle placeholder');
|
||||
}
|
||||
for (const ph of TEMPLATE_PLACEHOLDERS) {
|
||||
if (!template.includes(ph)) {
|
||||
throw new Error(`applyTemplate: template missing placeholder ${JSON.stringify(ph)}`);
|
||||
}
|
||||
}
|
||||
// Keep existing custom templates compatible when evidence is not requested.
|
||||
// Silently dropping verified evidence would be misleading, so the new slot
|
||||
// becomes mandatory only for the opt-in evidence path.
|
||||
if (sourceEvidence && !template.includes(SOURCE_EVIDENCE_PLACEHOLDER)) {
|
||||
throw new Error(`applyTemplate: repository evidence requires placeholder ${JSON.stringify(SOURCE_EVIDENCE_PLACEHOLDER)}`);
|
||||
}
|
||||
// Function replacers: a literal `$&`, `$'`, `$\`` or `$$` in titles, labels,
|
||||
// or rendered SVG must not be interpreted as a replacement pattern.
|
||||
const sourceEvidenceJson = serializeScriptJson(sourceEvidence);
|
||||
const resolvedLocale = resolveLocale(locale);
|
||||
const i18nJson = serializeScriptJson({ locale: resolvedLocale, messages: viewerCatalog(resolvedLocale) });
|
||||
const renderedSubtitle = typeof subtitle === 'string' && subtitle.trim()
|
||||
? `<p class="subtitle">${esc(subtitle)}</p>`
|
||||
: '';
|
||||
const i18nData = ` <script id="archify-i18n-data" type="application/json">${i18nJson}</script>`;
|
||||
return localizeTemplate(template, resolvedLocale)
|
||||
.replace(I18N_PLACEHOLDER, () => i18nData)
|
||||
.replace(TEMPLATE_PLACEHOLDERS[0], () => `<html lang="${esc(resolvedLocale)}" data-theme="dark" data-preset="${esc(visualPreset)}">`)
|
||||
.replace(TEMPLATE_PLACEHOLDERS[1], () => `<title>${esc(translateMessage(resolvedLocale, 'page.title', { title }))}</title>`)
|
||||
.replace(TEMPLATE_PLACEHOLDERS[2], () => `<h1>${esc(title)}</h1>`)
|
||||
.replace(SUBTITLE_SLOT_RE, (_match, indent, newline = '') => renderedSubtitle
|
||||
? `${indent}${renderedSubtitle}${newline}`
|
||||
: '')
|
||||
.replace(SVG_SLOT_RE, () => svg)
|
||||
.replace(CARDS_SLOT_RE, () => cards)
|
||||
.replace(SOURCE_EVIDENCE_PLACEHOLDER, () => sourceEvidence
|
||||
? ` <script id="archify-source-evidence-data" type="application/json">${sourceEvidenceJson}</script>`
|
||||
: '');
|
||||
}
|
||||
|
||||
// CJK and other wide/fullwidth glyphs render at roughly twice the advance
|
||||
// width of ASCII in the monospace stacks the template uses. Keep halfwidth
|
||||
// forms (notably U+FF61–U+FF9F Katakana) out of this set. The explicit ranges
|
||||
// also cover vertical punctuation and supplementary East Asian scripts that
|
||||
// literal glyph ranges made difficult to audit.
|
||||
// Code points that take two columns of advance width: East Asian Wide and
|
||||
// Fullwidth per UAX #11, tracking Unicode 17.0. That takes in the BMP symbols
|
||||
// carrying emoji presentation (U+2705, U+2B50, U+26A1, U+231B, ...), which
|
||||
// render at the same square advance as the supplementary-plane emoji already
|
||||
// listed here, and Hangul Jamo Extended-A. Two boundary calls worth naming:
|
||||
// Unicode 16.0 reclassified the trigrams (U+2630-U+2637) and the monogram /
|
||||
// digram symbols (U+268A-U+268F) from Neutral to Wide, so both are in; and
|
||||
// Hangul Jamo Extended-A stops at U+A97C, its last assigned jamo, because
|
||||
// U+A97D-U+A97F are unassigned, and unassigned code points outside the CJK
|
||||
// ranges UAX #11 names default to Neutral rather than Wide. Spelled out as
|
||||
// ranges because V8 has no \p{East_Asian_Width=W} property escape.
|
||||
const FULLWIDTH_RE = /[\u1100-\u115F\u231A-\u231B\u2329-\u232A\u23E9-\u23EC\u23F0\u23F3\u25FD-\u25FE\u2614-\u2615\u2630-\u2637\u2648-\u2653\u267F\u268A-\u268F\u2693\u26A1\u26AA-\u26AB\u26BD-\u26BE\u26C4-\u26C5\u26CE\u26D4\u26EA\u26F2-\u26F3\u26F5\u26FA\u26FD\u2705\u270A-\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B-\u2B1C\u2B50\u2B55\u2E80-\uA4CF\uA960-\uA97C\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF01-\uFF60\uFFE0-\uFFE6\u{16FE0}-\u{18DFF}\u{1AFF0}-\u{1AFFF}\u{1B000}-\u{1B2FF}\u{1F000}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u;
|
||||
|
||||
// Variation selectors contribute no separate unit. This is a conservative
|
||||
// width estimate, not a measurement of the selected glyph: its actual advance
|
||||
// depends on the font and presentation (Unicode UAX #11).
|
||||
// VS16 requests emoji presentation, so reserve two units for the sequence.
|
||||
// VS15 retains the base's width estimate; forcing every text-presentation
|
||||
// sequence to one unit undercounts wide bases, including CJK characters whose
|
||||
// font ignores that selector. Neutral bases remain one unit. Some selected
|
||||
// text glyphs can be narrower than this estimate; prefer extra space to overflow.
|
||||
const VARIATION_SELECTOR_FIRST = 0xfe00;
|
||||
const VARIATION_SELECTOR_LAST = 0xfe0f;
|
||||
const VARIATION_SELECTOR_EMOJI = 0xfe0f;
|
||||
|
||||
// Width measurement is pure and the same labels are measured many times per
|
||||
// compile, so the unit count is memoized by its input string.
|
||||
const TEXT_UNITS_CACHE = new Map();
|
||||
const MAX_TEXT_UNITS_CACHE_ENTRIES = 4096;
|
||||
const MAX_CACHED_TEXT_LENGTH = 1024;
|
||||
|
||||
export function textUnits(text) {
|
||||
const cacheKey = String(text ?? '');
|
||||
const cachedUnits = TEXT_UNITS_CACHE.get(cacheKey);
|
||||
if (cachedUnits !== undefined) return cachedUnits;
|
||||
const chars = Array.from(cacheKey);
|
||||
let units = 0;
|
||||
for (let i = 0; i < chars.length; i += 1) {
|
||||
const codePoint = chars[i].codePointAt(0);
|
||||
if (codePoint >= VARIATION_SELECTOR_FIRST && codePoint <= VARIATION_SELECTOR_LAST) continue;
|
||||
const next = i + 1 < chars.length ? chars[i + 1].codePointAt(0) : -1;
|
||||
if (next === VARIATION_SELECTOR_EMOJI) units += 2;
|
||||
else units += FULLWIDTH_RE.test(chars[i]) ? 2 : 1;
|
||||
}
|
||||
// Keep repeated in-process compiles bounded, including unusually long labels.
|
||||
if (cacheKey.length <= MAX_CACHED_TEXT_LENGTH) {
|
||||
if (TEXT_UNITS_CACHE.size >= MAX_TEXT_UNITS_CACHE_ENTRIES) {
|
||||
TEXT_UNITS_CACHE.delete(TEXT_UNITS_CACHE.keys().next().value);
|
||||
}
|
||||
TEXT_UNITS_CACHE.set(cacheKey, units);
|
||||
}
|
||||
return units;
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import * as validators from './generated-validators.mjs';
|
||||
import { throwDiagnosticError } from './diagnostics.mjs';
|
||||
|
||||
// "/nodes/3/label" reads much better as "/nodes/3 (id: "router") /label" for the
|
||||
// LLM fixing the JSON; resolve the nearest enclosing element's id or label.
|
||||
function annotatedPath(instancePath, data) {
|
||||
if (!instancePath) return { path: '/', identity: null };
|
||||
let node = data;
|
||||
let hint = null;
|
||||
for (const seg of instancePath.split('/').slice(1)) {
|
||||
if (node == null || typeof node !== 'object') break;
|
||||
node = node[/^\d+$/.test(seg) ? Number(seg) : seg];
|
||||
if (node && typeof node === 'object' && !Array.isArray(node)) {
|
||||
const tag = node.id ?? node.label;
|
||||
if (tag != null) hint = String(tag);
|
||||
}
|
||||
}
|
||||
return { path: instancePath, identity: hint };
|
||||
}
|
||||
|
||||
function annotatePath(instancePath, data) {
|
||||
const annotated = annotatedPath(instancePath, data);
|
||||
return annotated.identity != null
|
||||
? `${annotated.path} (id/label: ${JSON.stringify(annotated.identity)})`
|
||||
: annotated.path;
|
||||
}
|
||||
|
||||
function formatErrors(errors, data) {
|
||||
return errors.map((e) => {
|
||||
const where = annotatePath(e.instancePath, data);
|
||||
const detail = e.params && Object.keys(e.params).length
|
||||
? ' ' + JSON.stringify(e.params)
|
||||
: '';
|
||||
return ` ${where} ${e.message}${detail}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
export function validateSchema(diagramType, data) {
|
||||
const validate = validators[diagramType];
|
||||
if (!validate) {
|
||||
throw new Error(`validateSchema: unknown diagram type "${diagramType}"`);
|
||||
}
|
||||
if (!validate(data)) {
|
||||
const diagnostics = validate.errors.map((error) => {
|
||||
const annotated = annotatedPath(error.instancePath, data);
|
||||
const subject = {
|
||||
diagramType,
|
||||
path: annotated.path,
|
||||
...(annotated.identity != null ? { identity: String(annotated.identity) } : {}),
|
||||
};
|
||||
const evidence = {
|
||||
keyword: error.keyword,
|
||||
expected: error.schema,
|
||||
...error.params,
|
||||
};
|
||||
const supportedFixes = {
|
||||
additionalProperties: [`remove unsupported property ${JSON.stringify(error.params?.additionalProperty)}`],
|
||||
required: [`add required property ${JSON.stringify(error.params?.missingProperty)}`],
|
||||
type: [`use ${JSON.stringify(error.params?.type)} at ${annotated.path}`],
|
||||
enum: [`choose one of ${JSON.stringify(error.params?.allowedValues || [])}`],
|
||||
pattern: [`match the required pattern ${JSON.stringify(error.params?.pattern)}`],
|
||||
minimum: [`use a value ${error.params?.comparison || '>='} ${error.params?.limit}`],
|
||||
maximum: [`use a value ${error.params?.comparison || '<='} ${error.params?.limit}`],
|
||||
minItems: [`provide at least ${error.params?.limit} item(s)`],
|
||||
maxItems: [`provide at most ${error.params?.limit} item(s)`],
|
||||
minLength: [`provide at least ${error.params?.limit} character(s)`],
|
||||
maxLength: [`provide at most ${error.params?.limit} character(s)`],
|
||||
}[error.keyword] || [];
|
||||
const detail = error.params && Object.keys(error.params).length
|
||||
? ` ${JSON.stringify(error.params)}`
|
||||
: '';
|
||||
return {
|
||||
code: `schema/${error.keyword}`,
|
||||
severity: 'error',
|
||||
message: `${annotatePath(error.instancePath, data)} ${error.message}${detail}`,
|
||||
subject,
|
||||
evidence,
|
||||
supportedFixes,
|
||||
};
|
||||
});
|
||||
throwDiagnosticError(
|
||||
`${diagramType} schema validation failed:\n${formatErrors(validate.errors, data)}`,
|
||||
diagnostics,
|
||||
);
|
||||
}
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
# Workflow Renderer
|
||||
|
||||
Render `diagram_type: "workflow"` JSON files into the standard Archify HTML
|
||||
template.
|
||||
|
||||
```bash
|
||||
node archify/renderers/workflow/render-workflow.mjs input.workflow.json output.html
|
||||
```
|
||||
|
||||
The renderer validates input against `archify/schemas/workflow.schema.json`
|
||||
with the bundled standalone validator. No dependency installation is required.
|
||||
|
||||
If `output.html` is omitted, the renderer uses the required `meta.output` value
|
||||
from the JSON file.
|
||||
|
||||
After rendering, run the artifact checker:
|
||||
|
||||
```bash
|
||||
node archify/scripts/check-render-output.mjs output.html
|
||||
```
|
||||
|
||||
It catches final-SVG issues that are easiest to see in a browser: non-finite
|
||||
SVG values, accidental two-point diagonal arrows, and arrows crossing the
|
||||
legend.
|
||||
|
||||
## Input
|
||||
|
||||
Workflow JSON files must set:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 2,
|
||||
"diagram_type": "workflow",
|
||||
"meta": {
|
||||
"title": "Agent Tool Call Workflow",
|
||||
"output": "agent-tool-call.html"
|
||||
},
|
||||
"lanes": [],
|
||||
"phases": [],
|
||||
"groups": [],
|
||||
"mainPath": [],
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"cards": []
|
||||
}
|
||||
```
|
||||
|
||||
Use `schema_version: 2` for new workflows. Its readable layout compiler treats
|
||||
every `col` as a logical rank in `0..5` and derives geometry from the measured
|
||||
document. `schema_version: 1` remains the fixed legacy contract for existing
|
||||
sources; valid v1 output is preserved byte-for-byte and never silently
|
||||
reinterpreted as v2.
|
||||
|
||||
Omit `meta.viewBox` for the common v2 case so the compiler can use intrinsic
|
||||
measured bounds. In v1, the omitted width remains fixed at 720 and height is
|
||||
derived from lane count. A complete worked example lives at
|
||||
`archify/examples/agent-tool-call.workflow.json`; its `schema_version` selects
|
||||
the applicable contract.
|
||||
|
||||
The schema lives at:
|
||||
|
||||
```text
|
||||
archify/schemas/workflow.schema.json
|
||||
```
|
||||
|
||||
## Migration and layout receipt
|
||||
|
||||
Migrate an existing v1 source into a separate v2 file:
|
||||
|
||||
```bash
|
||||
node archify/bin/archify.mjs migrate workflow old.json new.json --to-schema 2 --json
|
||||
```
|
||||
|
||||
Running the command again with its schema-v2 output as the new source is an
|
||||
idempotent verification pass: the destination bytes and geometry stay unchanged.
|
||||
|
||||
If a legacy v1 source is blocked solely because `meta.output` is missing or no
|
||||
longer portable, supply its replacement for the separate v2 destination:
|
||||
|
||||
```bash
|
||||
node archify/bin/archify.mjs migrate workflow old.json new.json --to-schema 2 --output reports/workflow.html --json
|
||||
```
|
||||
|
||||
`--output` must itself be a portable POSIX-relative `.html` path. It updates
|
||||
only the verified destination candidate; the source bytes remain unchanged and
|
||||
all non-output schema and compiler diagnostics still block migration.
|
||||
|
||||
The command never overwrites the source by default. It maps absolute
|
||||
`via[*][0]`, `labelAt[0]`, and `channelX` values from legacy to solved rank
|
||||
space, preserves y coordinates unless a reported vertical constraint needs
|
||||
author input, expands an explicit viewBox only for an unambiguous containment
|
||||
repair, and writes the destination only after v2 compilation and artifact
|
||||
checks pass. Ambiguous explicit pins fail without producing the destination.
|
||||
|
||||
Inspect the stable author-facing v2 plan with:
|
||||
|
||||
```bash
|
||||
node archify/bin/archify.mjs validate workflow input.workflow.json --layout-json
|
||||
```
|
||||
|
||||
The receipt reports the selected contract, measured `viewBox` and
|
||||
`requiredViewBox`, solved columns, nodes, edges, labels, and causal diagnostics.
|
||||
It deliberately omits solver iterations and candidate scores.
|
||||
|
||||
## Legend
|
||||
|
||||
The default legend derives component kinds from `nodes[].type`. Supported
|
||||
`meta.legend.entries` keys, in stable order, are `frontend`, `backend`,
|
||||
`security`, `messagebus`, `database`, `cloud`, and `external`. Labels and
|
||||
visibility may be overridden through the shared legend contract; only kinds
|
||||
backed by rendered nodes receive Semantic Legend controls.
|
||||
|
||||
## Layout contracts
|
||||
|
||||
### Fixed v1
|
||||
|
||||
| Constant | Value |
|
||||
|----------|-------|
|
||||
| viewBox | default `[720, auto]` — auto height = 52 + lanes×104 + (lanes−1)×20 + 124 |
|
||||
| Lane frame | x 40, width 640, height 104, gap 20; first lane top at y 52 |
|
||||
| Lane title strip | top 30px of each lane; node boxes must stay below it |
|
||||
| Column centers (`col` 0–5) | x = 88, 220, 300, 430, 500, 625 |
|
||||
| Phase headers | Optional `phases[]` render above the first lane, spanning `fromCol..toCol` |
|
||||
| Lane groups | Optional `groups[]` frame parallel work or branch work inside one lane |
|
||||
| Exception lanes | Set `lane.variant: "exception"` for retry, denial, fallback, or failure paths |
|
||||
| Main path lint | Optional `mainPath[]` checks that happy-path steps have matching edges and do not move backward |
|
||||
| Default node | 92×52 (height 68 when `tag` is set) |
|
||||
| Node spacing | ≥8px between nodes in the same lane |
|
||||
| Edge length | straight segments must span ≥28px |
|
||||
| Legend row | y = lane bottom + 44; viewBox height must be ≥ legend y + 18 |
|
||||
|
||||
Column-center gaps are 132 / 80 / 130 / 70 / 125 px: columns 1↔2 (80px) and
|
||||
3↔4 (70px) cannot both hold default-width 92px nodes in the same lane. Such an
|
||||
invalid v1 source receives one causal `workflow/column-capacity` diagnostic and
|
||||
a verified migration-to-v2 repair; v1 never falls through to adaptive layout.
|
||||
|
||||
### Readable v2
|
||||
|
||||
| Invariant | Contract |
|
||||
|----------|----------|
|
||||
| Logical columns | `col` is an integer in `0..5`; pixel centers are measured output |
|
||||
| Adjacent-rank baseline | 120px center distance before document-specific constraints |
|
||||
| Same-lane node clearance | ≥8px when vertical node intervals overlap |
|
||||
| Facing direct edge | clear gap ≥`max(28px, measured label mask width + 8px)` |
|
||||
| Automatic route rhythm | direct segment ≥28px; endpoint stub ≥8px; interior turn segment ≥16px |
|
||||
| Implicit viewBox | intrinsic content bounds plus contract padding |
|
||||
| Explicit viewBox | containment capacity; too-small input reports exact `requiredViewBox` and contributors |
|
||||
| Lane measurement | A same-column vertical stack (two or more distinct `yOffset` values) opts an implicit, unpinned workflow into per-lane measurement. Explicit `meta.viewBox`, `via`, `labelAt`, `channelX`, or `channelY`, and workflows without a stack retain shared-height v2 geometry for compatibility. |
|
||||
|
||||
The compiler applies constraints only to actual related or overlapping
|
||||
same-lane nodes, so a wide node in an unrelated lane does not expand every
|
||||
rank. Legacy centers are a soft preference after correctness constraints, not
|
||||
a geometry promise. Phase and group frames derive from the solved rank bands.
|
||||
Automatic routes are normalized once and the same final scene drives
|
||||
validation and SVG serialization. Long automatic labels compare direct-gutter
|
||||
growth with a legal channel instead of widening every downstream rank. Measured
|
||||
multi-row legends participate in intrinsic height and explicit viewBox
|
||||
capacity.
|
||||
|
||||
The Issue #250 shape already has a v2 representation without an authored lane
|
||||
size. Keep the three stages in one grouped lane, omit `meta.viewBox`, and center
|
||||
their offsets around zero:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 2,
|
||||
"diagram_type": "workflow",
|
||||
"meta": { "title": "stack", "output": "stack.html" },
|
||||
"lanes": [{ "id": "cage", "label": "one cage" }],
|
||||
"groups": [{ "id": "g", "label": "cage", "lane": "cage", "fromCol": 1, "toCol": 3 }],
|
||||
"nodes": [
|
||||
{ "id": "a", "lane": "cage", "col": 2, "type": "security", "label": "stageA", "yOffset": -90 },
|
||||
{ "id": "b", "lane": "cage", "col": 2, "type": "security", "label": "stageB", "yOffset": 0 },
|
||||
{ "id": "c", "lane": "cage", "col": 2, "type": "security", "label": "stageC", "yOffset": 90 }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "a", "to": "b", "fromSide": "bottom", "toSide": "top" },
|
||||
{ "from": "b", "to": "c", "fromSide": "bottom", "toSide": "top" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
An implicit readable-v2 vertical stack whose measured lane height exceeds the
|
||||
104px baseline opts into the desktop Viewer's height budget. This decision
|
||||
comes from compiled geometry, not an authored sizing field. The Viewer changes only
|
||||
the outer reader width so the complete lane remains on screen; canonical SVG
|
||||
geometry and explicit `meta.viewBox` workflows retain their authored contracts.
|
||||
When necessary, the Viewer may scale below the intrinsic 1:1 width only as far
|
||||
as the 6px projected node-text floor. If the complete workflow still cannot fit
|
||||
at that readable scale, `visual-check` reports the remaining viewport overflow
|
||||
instead of clipping or introducing an internal scroller.
|
||||
|
||||
Authored `via`, `labelAt`, `channelX`, and `channelY` are absolute hard pins in
|
||||
v2; an infeasible pin returns `workflow/explicit-pin-conflict` rather than being
|
||||
silently moved. `fromSide` and `toSide` remain direction constraints. A route
|
||||
preset restricts the automatic candidate family but is not itself an absolute
|
||||
coordinate pin. When either endpoint side is omitted, the v2 compiler chooses
|
||||
a feasible side; an authored side restricts that endpoint to the named port.
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Use lanes for ownership or runtime boundaries.
|
||||
- Use phase headers for high-level story beats such as Intake, Plan, Execute, and Report.
|
||||
- Use groups for parallel checks, branch handling, or bounded work within a lane; every group must contain at least one node.
|
||||
- For sequential stages stacked inside one container, use one v2 lane and one group, keep the stages in one column, and omit `meta.viewBox`. `yOffset` is relative to the center of the lane's content area, so center a three-stage stack with `-90 / 0 / 90` rather than `0 / 90 / 180`. With compiler-owned routes and canvas, the compiler expands only that lane.
|
||||
- Use `lane.variant: "exception"` for human wait, denial, retry, fallback, and failure lanes instead of mixing those paths into the happy path.
|
||||
- Set `mainPath` when the diagram has a clear happy path; the renderer validates that consecutive ids have matching edges and move left-to-right.
|
||||
- Place nodes with lane IDs and `col` indexes in `0..5`, not raw SVG coordinates.
|
||||
- Preserve semantic edge labels. Readable v2 allocates measured label clearance;
|
||||
when a label does not fit, repair the reported capacity or route constraint
|
||||
instead of deleting meaning.
|
||||
- Use labels for decisions, approvals, protocols, async traces, return paths,
|
||||
and any other relationship meaning not fully implied by its endpoints.
|
||||
- Prefer route presets — `drop` (bend between lanes; `bias` 0–1 picks where),
|
||||
`outside-right`, `return-left`, `bottom-channel`, and `up-channel` — before
|
||||
using raw `via` points. `straight` and the default `auto` cover the rest.
|
||||
- Keep workflow examples compact enough to render well in narrow chat/browser
|
||||
previews.
|
||||
|
||||
### Optional semantic checks
|
||||
|
||||
Layout validation cannot infer domain truth from labels or cards. When source
|
||||
evidence establishes roots, terminals, mandatory direct relationships, or
|
||||
mandatory directed reachability, encode those facts in `semanticChecks`:
|
||||
|
||||
```json
|
||||
"semanticChecks": {
|
||||
"allowedRoots": ["request", "resource_catalog"],
|
||||
"allowedTerminals": ["reply", "audit_log"],
|
||||
"requiredEdges": [
|
||||
{ "from": "dispatch", "to": "dispatch_ledger" }
|
||||
],
|
||||
"requiredPaths": [
|
||||
{ "from": "event_ledger", "to": "runtime_host" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
When `allowedRoots` or `allowedTerminals` is present, it is the complete allow
|
||||
list for zero-incoming or zero-outgoing nodes respectively. `requiredEdges`
|
||||
requires one exact authored direction; `requiredPaths` permits intermediate
|
||||
nodes but follows authored edge direction. These checks run before layout, do
|
||||
not alter SVG or receipt bytes, and must not be weakened merely to resolve a
|
||||
route or composition diagnostic. Omit fields whose domain facts are unknown.
|
||||
|
||||
Schema violations exit non-zero with path-prefixed messages annotated with the
|
||||
element's id or label. The renderer additionally fails when it can detect
|
||||
layout problems, including node overlap, nodes outside their lanes, invalid
|
||||
phase/group column ranges, empty groups, broken `mainPath` steps, unknown edge
|
||||
targets, labels colliding with nodes or other labels, labels wider than their
|
||||
node, legends outside the viewBox, or straight arrows that are too short to
|
||||
read cleanly. The shared Clean Flow Gate also rejects edges crossing unrelated
|
||||
nodes with 2px clearance; lanes, phases, and groups remain intentional
|
||||
pass-through containers. Text width is estimated CJK-aware: fullwidth glyphs
|
||||
count as two units.
|
||||
|
||||
Diagnostics are causal: a rank-capacity failure suppresses derivative short
|
||||
edge, endpoint-direction, and label-overlap findings. Every
|
||||
`supportedFixes[]` entry is verified by replanning the proposed edit, and a
|
||||
diagnostic never proposes removing a semantic label when label presence does
|
||||
not cause the failed invariant.
|
||||
|
||||
Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
|
||||
X crossings then fail with `composition/proper-crossing`; default `standard`
|
||||
keeps them as artifact-receipt warnings. Collinear lane corridors are outside
|
||||
the proper-X rule, but a separate gate warns in `standard` and fails in
|
||||
`showcase` when unrelated edges overlap for at least 8px. V1 keeps its authored
|
||||
shared-endpoint contract. V2 also checks shared endpoints, including explicitly
|
||||
controlled routes: long or mixed-style trunks, counterflow, proper interior X
|
||||
crossings and overlapping independent arrowheads are not exempt. Pins are
|
||||
preserved, not silently repaired; unresolved v2 corridor/arrowhead collisions
|
||||
warn in `standard` and fail in `showcase`.
|
||||
|
||||
V2 permits a same-direction shared terminal stub of at most 24 SVG units only
|
||||
when both relationships share the actual source or target port, effective
|
||||
variant, stroke width and role. A nonterminal overlap is never such a stub;
|
||||
forward-collinear waypoints do not split a long trunk into permitted pieces.
|
||||
Compatible short merges may share their terminal arrowhead. Automatic routes
|
||||
consider separate ports, reserve absolute routes at contested nodes, and prefer
|
||||
clear paths over shorter ambiguous ones. Crowded automatic corridors can use a
|
||||
bounded local adjustment without moving nodes or changing explicit coordinates.
|
||||
|
||||
The SVG carries `data-layout-contract="readable-v2"` and each edge's role so
|
||||
artifact checks apply the same classification to actual visible path geometry,
|
||||
not stale composition-point metadata. These internal output attributes do not
|
||||
add authoring schema fields. Other diagram types retain their existing rules.
|
||||
V2 proper-crossing diagnostics retain the relationship IDs, intersection point,
|
||||
and supported fixes in both compiler/layout-JSON receipts and final HTML checks;
|
||||
`standard` reports warnings while `showcase` rejects the crossing. Both analyses
|
||||
merge forward-collinear waypoints without rewriting authored paths; real bends
|
||||
and reversals remain endpoint touches rather than being merged into an X.
|
||||
|
||||
The older per-edge `data-composition-routing="workflow-v2-auto"` marker remains
|
||||
for compatibility with first-round exported HTML that has no root layout
|
||||
contract and with older artifact checkers. Marker-only artifacts retain their
|
||||
narrower automatic-pair crossing/counterflow policy; the root `readable-v2`
|
||||
contract is authoritative when present and also checks explicit routes. Do not
|
||||
remove the marker-only path as dead code without retiring that export format.
|
||||
Showcase also
|
||||
rejects any route segment below 8px and any interior turn segment below 16px;
|
||||
ordinary 8–15px endpoint stubs remain valid for fixed lane gaps.
|
||||
@@ -0,0 +1,37 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadDiagramWithBrandMarks, writeDiagram } from '../shared/cli.mjs';
|
||||
import { throwDiagnosticError } from '../shared/diagnostics.mjs';
|
||||
import { compileWorkflow } from './workflow-compiler.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const { diagram: workflow, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
|
||||
rendererDir: __dirname,
|
||||
diagramType: 'workflow',
|
||||
defaultExample: 'agent-tool-call.workflow.json'
|
||||
});
|
||||
|
||||
const compiled = compileWorkflow({
|
||||
workflow,
|
||||
qualityProfile: process.env.ARCHIFY_QUALITY_PROFILE || workflow.meta?.quality_profile,
|
||||
sourceEvidence,
|
||||
});
|
||||
|
||||
const layoutJson = process.argv.includes('--layout-json');
|
||||
|
||||
if (layoutJson) {
|
||||
process.stdout.write(`${JSON.stringify(compiled.receipt, null, 2)}\n`);
|
||||
if (!compiled.ok) process.exitCode = 1;
|
||||
} else if (!compiled.ok) {
|
||||
throwDiagnosticError(compiled.error || 'Workflow compilation failed.', compiled.diagnostics);
|
||||
} else {
|
||||
writeDiagram({
|
||||
outPath,
|
||||
template,
|
||||
diagramType: 'workflow',
|
||||
meta: workflow.meta,
|
||||
svg: compiled.svg,
|
||||
cards: workflow.cards,
|
||||
sourceEvidence,
|
||||
});
|
||||
}
|
||||
+4913
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
const TARGET_SCHEMA_VERSION = 2;
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the authored workflow as a schema-v2 document without its capacity
|
||||
* override. The compiler can use this projection to discover the intrinsic v2
|
||||
* rank plan before deciding whether an explicit viewBox needs to grow.
|
||||
*/
|
||||
export function intrinsicWorkflow(workflow) {
|
||||
const intrinsic = clone(workflow);
|
||||
intrinsic.schema_version = TARGET_SCHEMA_VERSION;
|
||||
intrinsic.meta = { ...intrinsic.meta };
|
||||
delete intrinsic.meta.viewBox;
|
||||
return intrinsic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a schema-v2 planning projection that removes authored route geometry
|
||||
* which may only become valid after its legacy X coordinates are remapped.
|
||||
* Rank-affecting automatic and straight relationships remain in the projection.
|
||||
*/
|
||||
export function planningWorkflow(workflow) {
|
||||
const planned = intrinsicWorkflow(workflow);
|
||||
planned.edges = planned.edges.flatMap((edge) => {
|
||||
const hasRoutedGeometry = Array.isArray(edge.via)
|
||||
|| (edge.route && !['auto', 'straight'].includes(edge.route))
|
||||
|| edge.channelX !== undefined
|
||||
|| edge.channelY !== undefined;
|
||||
if (hasRoutedGeometry) return [];
|
||||
|
||||
const automatic = {};
|
||||
for (const property of ['id', 'from', 'to', 'variant', 'role', 'width']) {
|
||||
if (edge[property] !== undefined) automatic[property] = edge[property];
|
||||
}
|
||||
if (edge.route === 'straight') automatic.route = 'straight';
|
||||
if (edge.labelAt === undefined && edge.label !== undefined) automatic.label = edge.label;
|
||||
return [automatic];
|
||||
});
|
||||
|
||||
if (Array.isArray(planned.mainPath)) {
|
||||
const projectedPairs = new Set(planned.edges.map((edge) => `${edge.from}\u0000${edge.to}`));
|
||||
const projectionBreaksMainPath = planned.mainPath.some((from, index) => (
|
||||
index < planned.mainPath.length - 1
|
||||
&& !projectedPairs.has(`${from}\u0000${planned.mainPath[index + 1]}`)
|
||||
));
|
||||
if (projectionBreaksMainPath) delete planned.mainPath;
|
||||
}
|
||||
|
||||
return planned;
|
||||
}
|
||||
|
||||
function mappedNumber(value) {
|
||||
return Number(value.toFixed(6));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a deterministic piecewise-linear mapping between corresponding legacy
|
||||
* and readable rank centers. Coordinates outside the rank span are extrapolated
|
||||
* using the nearest segment so explicitly authored outside corridors retain
|
||||
* their relative offset.
|
||||
*/
|
||||
export function createHorizontalRankMapper(oldColumns, newColumns) {
|
||||
if (
|
||||
!Array.isArray(oldColumns)
|
||||
|| !Array.isArray(newColumns)
|
||||
|| oldColumns.length !== newColumns.length
|
||||
|| oldColumns.length < 2
|
||||
|| !oldColumns.every(Number.isFinite)
|
||||
|| !newColumns.every(Number.isFinite)
|
||||
) {
|
||||
throw new TypeError('Horizontal rank mapping requires matching finite column arrays.');
|
||||
}
|
||||
for (let index = 1; index < oldColumns.length; index += 1) {
|
||||
if (oldColumns[index] <= oldColumns[index - 1] || newColumns[index] <= newColumns[index - 1]) {
|
||||
throw new TypeError('Horizontal rank mapping requires strictly increasing columns.');
|
||||
}
|
||||
}
|
||||
|
||||
return (x) => {
|
||||
if (!Number.isFinite(x)) throw new TypeError('Horizontal rank mapping requires a finite x coordinate.');
|
||||
let segment = oldColumns.length - 2;
|
||||
if (x <= oldColumns[0]) {
|
||||
segment = 0;
|
||||
} else {
|
||||
for (let index = 0; index < oldColumns.length - 1; index += 1) {
|
||||
if (x <= oldColumns[index + 1]) {
|
||||
segment = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const oldSpan = oldColumns[segment + 1] - oldColumns[segment];
|
||||
const newSpan = newColumns[segment + 1] - newColumns[segment];
|
||||
const ratio = (x - oldColumns[segment]) / oldSpan;
|
||||
return mappedNumber(newColumns[segment] + ratio * newSpan);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one horizontal coordinate mapping to every schema-v1 absolute X pin.
|
||||
* The caller owns the supplied workflow; this function reports an audit trail
|
||||
* for each changed coordinate in stable document order.
|
||||
*/
|
||||
export function mapExplicitCoordinates(workflow, mapX) {
|
||||
const changedCoordinates = [];
|
||||
const record = (path, owner, property) => {
|
||||
const from = owner[property];
|
||||
const to = mapX(from);
|
||||
owner[property] = to;
|
||||
if (to !== from) changedCoordinates.push({ path, from, to });
|
||||
};
|
||||
|
||||
for (const [edgeIndex, edge] of workflow.edges.entries()) {
|
||||
if (Array.isArray(edge.via)) {
|
||||
for (const [pointIndex, point] of edge.via.entries()) {
|
||||
if (Array.isArray(point) && Number.isFinite(point[0])) {
|
||||
record(`/edges/${edgeIndex}/via/${pointIndex}/0`, point, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(edge.labelAt) && Number.isFinite(edge.labelAt[0])) {
|
||||
record(`/edges/${edgeIndex}/labelAt/0`, edge.labelAt, 0);
|
||||
}
|
||||
if (Number.isFinite(edge.channelX)) {
|
||||
record(`/edges/${edgeIndex}/channelX`, edge, 'channelX');
|
||||
}
|
||||
}
|
||||
return changedCoordinates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct an independently owned schema-v2 candidate with all authored
|
||||
* absolute X pins mapped to the readable rank plan.
|
||||
*/
|
||||
export function createMappedWorkflowCandidate(workflow, oldColumns, newColumns) {
|
||||
const document = clone(workflow);
|
||||
document.schema_version = TARGET_SCHEMA_VERSION;
|
||||
const mapX = createHorizontalRankMapper(oldColumns, newColumns);
|
||||
const changedCoordinates = mapExplicitCoordinates(document, mapX);
|
||||
return { document, changedCoordinates };
|
||||
}
|
||||
Reference in New Issue
Block a user