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:
2026-09-30 12:40:55 +03:00
co-authored by Claude Opus 5.5
commit d56e784e97
119 changed files with 66708 additions and 0 deletions
+62
View File
@@ -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
View File
@@ -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
File diff suppressed because it is too large Load Diff
+105
View File
@@ -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.
+544
View File
@@ -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
View File
@@ -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
View File
@@ -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);
},
};
}
+938
View File
@@ -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
View File
@@ -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.
+526
View File
@@ -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,
});
File diff suppressed because it is too large Load Diff
+670
View File
@@ -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
View File
@@ -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}`;
}
+132
View File
@@ -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
View File
@@ -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);
});
}
+157
View File
@@ -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
File diff suppressed because it is too large Load Diff
+603
View File
@@ -0,0 +1,603 @@
export const SUPPORTED_LOCALES = ['en', 'zh-CN'];
export const DEFAULT_LOCALE = 'en';
const ESCAPE_MAP = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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];
}
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
}
File diff suppressed because it is too large Load Diff
+408
View File
@@ -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;
}
+352
View File
@@ -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,
};
}
+59
View File
@@ -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
View File
@@ -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;
}
+38
View File
@@ -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);
}
+66
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+37
View File
@@ -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,
});
}
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 };
}