feat: introduce archify skill for generating architecture diagrams
- Added a new Archify skill, enabling users to create polished architecture, workflow, sequence, data-flow, and lifecycle diagrams. - Implemented comprehensive functionality including rendering, validation, and delivery of diagrams in various formats. - Integrated a user-friendly command-line interface for generating and previewing diagrams. - Developed supporting files including package.json, LICENSE, and SKILL.md for documentation and licensing. - Added unit tests to ensure reliability and functionality of the new skill. These changes enhance the application by providing a structured approach to visualizing system architecture and workflows, improving user experience and data representation.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
# 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 `meta.output` from the JSON file
|
||||
or falls back to `dataflow.html` in the current working directory.
|
||||
|
||||
## Input
|
||||
|
||||
Data-flow JSON files must set:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "dataflow",
|
||||
"meta": {
|
||||
"title": "Product Analytics Data Flow",
|
||||
"viewBox": [940, 720]
|
||||
},
|
||||
"stages": [],
|
||||
"nodes": [],
|
||||
"flows": [],
|
||||
"cards": []
|
||||
}
|
||||
```
|
||||
|
||||
A complete worked example lives at
|
||||
`archify/examples/product-analytics.dataflow.json`.
|
||||
|
||||
The schema lives at:
|
||||
|
||||
```text
|
||||
archify/schemas/dataflow.schema.json
|
||||
```
|
||||
|
||||
## Legend
|
||||
|
||||
The default visual legend derives kinds from `flows[].variant` (omitting
|
||||
`variant` means `default`) and adds `database` only when a database node exists.
|
||||
Supported `meta.legend.entries` keys, in stable order, are `emphasis`,
|
||||
`security`, `dashed`, `database`, and `default`. Flow variants remain
|
||||
visual-only because Archify has no compiled edge-kind facts in this slice. A
|
||||
present `database` entry is different: it comes from exact
|
||||
`nodes[].type: "database"` facts, so it publishes the normal Semantic Legend
|
||||
count, accessible name, and keyboard interaction. Forcing `database` visible
|
||||
without a database node keeps it visual-only.
|
||||
|
||||
## Layout budget
|
||||
|
||||
| Constant | Value |
|
||||
|----------|-------|
|
||||
| viewBox | default `[940, 720]`; schema minimum `[360, 360]` |
|
||||
| Stages (2–5) | centers at x = 100 + stage×215; stage band 168 wide, header at y 46 |
|
||||
| Row tops (`row` 0–4) | y = 128, 242, 356, 470, 584 (plus `yOffset`) |
|
||||
| Default node | 112×58 |
|
||||
| Node area | x within `[24, width − 24]`; y within `[104, height − 74]` |
|
||||
| Node spacing | ≥10px between any two nodes (checked across stages and rows) |
|
||||
| Flow length | ≥34px between endpoints |
|
||||
| Legend row | y = height − 36 |
|
||||
|
||||
Route presets for flows: `straight`, `vertical-channel`, `bottom-channel`,
|
||||
`top-channel`, explicit `via` points, or the default `auto` (midpoint elbow).
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Use stages for data lifecycle boundaries: source, ingest, process, store,
|
||||
consume.
|
||||
- Place nodes by stage index and row index; do not hand-place raw SVG for the
|
||||
common case.
|
||||
- Use flow labels to name the data asset, not the transport primitive:
|
||||
`clickstream`, `identity map`, `normalized facts`, `feature vectors`.
|
||||
- Use `classification` for short sensitivity or governance context:
|
||||
`PII touch`, `non-PII`, `approved only`, `batch`, `read-only`.
|
||||
- Use `security` for PII, policy, consent, access-control, or restricted joins.
|
||||
- Use `emphasis` for the primary data path and `dashed` for async or batch
|
||||
derivations.
|
||||
- Keep labels short enough to fit in narrow previews.
|
||||
|
||||
Schema violations exit non-zero with path-prefixed messages annotated with the
|
||||
element's id or label. The renderer additionally fails when it can detect
|
||||
layout problems, including missing stages, duplicate node IDs, nodes outside
|
||||
the readable diagram area, node overlap, labels colliding with nodes or other
|
||||
labels, labels wider than their node, unknown flow endpoints, missing flow
|
||||
labels, unreadably short flows, flows crossing unrelated nodes (2px Clean Flow
|
||||
clearance), or stages that exceed the viewBox. Stage frames remain intentional
|
||||
pass-through containers. Text width
|
||||
is estimated CJK-aware: fullwidth glyphs count as two units.
|
||||
|
||||
Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
|
||||
X crossings then fail with `composition/proper-crossing`; default `standard`
|
||||
keeps them as artifact-receipt warnings. Collinear stage corridors are outside
|
||||
the proper-X rule, but a separate gate warns in `standard` and fails in
|
||||
`showcase` when unrelated flows overlap for at least 8px. Shared semantic
|
||||
endpoints, point touches, and shorter overlaps remain valid. Showcase also
|
||||
rejects any route segment below 8px and any interior turn segment below 16px;
|
||||
ordinary 8–15px endpoint stubs remain valid.
|
||||
@@ -0,0 +1,483 @@
|
||||
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 } from '../shared/text-fit.mjs';
|
||||
import { brandLabelFitWidth, 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,
|
||||
suggestLabelObstacleFix,
|
||||
suggestLabelPairFix,
|
||||
anchor,
|
||||
automaticPortSpread,
|
||||
defaultFromSide,
|
||||
defaultToSide,
|
||||
chosenSide,
|
||||
polylinePath,
|
||||
routePointsValue,
|
||||
labelPoint,
|
||||
componentFill,
|
||||
componentText,
|
||||
arrowClassMap,
|
||||
variantAccent
|
||||
} 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 } = 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')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
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'
|
||||
}));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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"/>
|
||||
<text x="${cx}" y="${layout.stageY + 22}" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">${String(index + 1).padStart(2, '0')} / ${esc(stage.label)}</text>`;
|
||||
}
|
||||
|
||||
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 sub = hasSub
|
||||
? `\n <text data-detail="context" x="${node.cx}" y="${node.y + 37}" class="t-muted" font-size="${fittedNodeFontSize(node.sublabel, node.width, nodeTextFit.sublabelPreferred, nodeTextFit.sublabelMinimum)}" text-anchor="middle">${esc(node.sublabel)}</text>`
|
||||
: '';
|
||||
const tag = node.tag
|
||||
? `\n <text data-detail="fine" x="${node.cx}" y="${node.y + node.height - 11}" class="${accent}" font-size="${fittedNodeFontSize(node.tag, node.width, nodeTextFit.tagPreferred, nodeTextFit.tagMinimum)}" 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 labelFontSize = fittedNodeFontSize(node.label, brandLabelFitWidth(node, node.width), 10, 8);
|
||||
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, { x: node.x + 6, y: node.y + 6 })}${brand ? `\n ${brand}` : ''}
|
||||
<text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${node.cx}" y="${node.y + 21}" 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)}" 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="${variantAccent(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() {
|
||||
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${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,
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
# 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 `meta.output` from the JSON file
|
||||
or falls back to `lifecycle.html` in the current working directory.
|
||||
|
||||
## Input
|
||||
|
||||
Lifecycle JSON files must set:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "lifecycle",
|
||||
"meta": {
|
||||
"title": "Agent Run Lifecycle",
|
||||
"viewBox": [980, 660]
|
||||
},
|
||||
"lanes": [],
|
||||
"states": [],
|
||||
"transitions": [],
|
||||
"cards": []
|
||||
}
|
||||
```
|
||||
|
||||
Lane ids are semantic and reserved: a lane with id `main` is required and maps
|
||||
to the top phase band; `terminal` maps to the bottom outcome band; every other
|
||||
lane id (up to 4 lanes total) shares the single middle event band. The three
|
||||
band headers render from your lane labels — the middle band joins the labels of
|
||||
all event lanes with ` + `. A complete worked example lives at
|
||||
`archify/examples/agent-run.lifecycle.json`.
|
||||
|
||||
The schema lives at:
|
||||
|
||||
```text
|
||||
archify/schemas/lifecycle.schema.json
|
||||
```
|
||||
|
||||
## Legend
|
||||
|
||||
The default legend derives kinds from `states[].type`. 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.
|
||||
|
||||
## Layout budget
|
||||
|
||||
| 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).
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Treat lifecycle diagrams as a phase map, not a dense state-transition graph.
|
||||
- Put the primary lifecycle on one horizontal rail using the `main` lane.
|
||||
- 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.
|
||||
- Avoid diagonal and crossing lines. Terminal exits should drop vertically from
|
||||
their source event whenever possible.
|
||||
- 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.
|
||||
@@ -0,0 +1,561 @@
|
||||
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 } 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,
|
||||
suggestLabelObstacleFix,
|
||||
suggestLabelPairFix,
|
||||
anchor,
|
||||
automaticPortSpread,
|
||||
defaultFromSide,
|
||||
defaultToSide,
|
||||
chosenSide,
|
||||
roundedPath,
|
||||
routePointsValue,
|
||||
labelPoint,
|
||||
arrowClassMap,
|
||||
variantAccent
|
||||
} from '../shared/geometry.mjs';
|
||||
|
||||
const stateTextFit = {
|
||||
sublabelPreferred: 7,
|
||||
sublabelMinimum: 6,
|
||||
tagPreferred: 7,
|
||||
tagMinimum: 6,
|
||||
};
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const { diagram: lifecycle, template, outPath } = await loadDiagramWithBrandMarks({
|
||||
rendererDir: __dirname,
|
||||
diagramType: 'lifecycle',
|
||||
defaultExample: 'agent-run.lifecycle.json'
|
||||
});
|
||||
|
||||
const viewBox = lifecycle.meta?.viewBox || [980, 660];
|
||||
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 typeClass = {
|
||||
start: 'c-frontend',
|
||||
active: 'c-backend',
|
||||
waiting: 'c-cloud',
|
||||
decision: 'c-security',
|
||||
success: 'c-database',
|
||||
failure: 'c-security',
|
||||
neutral: 'c-external',
|
||||
external: 'c-external'
|
||||
};
|
||||
|
||||
const textClass = {
|
||||
start: 't-frontend',
|
||||
active: 't-backend',
|
||||
waiting: 't-cloud',
|
||||
decision: 't-security',
|
||||
success: 't-database',
|
||||
failure: 't-security',
|
||||
neutral: 't-muted',
|
||||
external: 't-muted'
|
||||
};
|
||||
|
||||
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 viewBox[1] - 122;
|
||||
}
|
||||
|
||||
// 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).
|
||||
function bandFor(lane) {
|
||||
if (lane === 'main') return 'phase';
|
||||
if (lane === 'terminal') return 'outcome';
|
||||
return 'event';
|
||||
}
|
||||
|
||||
function measureState(state) {
|
||||
const isPhase = bandFor(state.lane) === 'phase';
|
||||
const isOutcome = bandFor(state.lane) === 'outcome';
|
||||
const width = state.width || (isPhase ? layout.phaseW : isOutcome ? layout.outcomeW : layout.eventW);
|
||||
const height = state.height || (isPhase ? layout.phaseH : isOutcome ? layout.outcomeH : layout.eventH);
|
||||
const xs = isPhase ? layout.phaseXs : isOutcome ? layout.outcomeXs : layout.eventXs;
|
||||
const cx = xs[state.col] ?? xs[xs.length - 1];
|
||||
const 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 laneLabels = new Map(asArray(lifecycle.lanes).map((lane) => [lane.id, lane.label]));
|
||||
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.
|
||||
if (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('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;
|
||||
}
|
||||
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 < 32 || state.x + state.width > viewBox[0] - 32) {
|
||||
problems.push(`State "${state.id}" exceeds the horizontal bounds of the diagram — reduce state.width or increase meta.viewBox[0].`);
|
||||
}
|
||||
if (state.y < 64 || state.y + state.height > lifecycleAreaBottom()) {
|
||||
problems.push(`State "${state.id}" exceeds the vertical lifecycle area — keep y between 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.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All non-main/non-terminal lanes share the same y band, so the overlap
|
||||
// check must run across lanes — not per-lane.
|
||||
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 (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,
|
||||
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 = [];
|
||||
for (const [transitionIndex, transition] of asArray(lifecycle.transitions).entries()) {
|
||||
if (!transition.label || !states.has(transition.from) || !states.has(transition.to)) continue;
|
||||
const [lx, ly] = labelPoint(transition, pathFor(transition).points);
|
||||
const longestLine = Math.max(textUnits(transition.label), textUnits(transition.note || ''));
|
||||
const width = Math.max(32, longestLine * 4.9 + 12);
|
||||
const height = transition.note ? 27 : 16;
|
||||
labelRects.push({ relation: transition, relationIndex: transitionIndex, label: transition.label, x: lx - width / 2, y: ly - 11, width, height, lx, ly });
|
||||
}
|
||||
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')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
}));
|
||||
|
||||
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();
|
||||
|
||||
function transitionSides(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(lifecycle.transitions, states, {
|
||||
sideFor: (transition, endpoint) => transitionSides(transition)[endpoint === 'source' ? 'fromSide' : 'toSide'],
|
||||
});
|
||||
|
||||
function pathFor(transition) {
|
||||
if (pathCache.has(transition)) return pathCache.get(transition);
|
||||
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;
|
||||
}
|
||||
|
||||
function bandTitles() {
|
||||
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');
|
||||
return [
|
||||
mainLane?.label || 'Lifecycle phases',
|
||||
eventLanes.length ? eventLanes.map((lane) => lane.label).join(' + ') : 'Interruptions + recovery',
|
||||
terminalLane?.label || 'Outcomes'
|
||||
];
|
||||
}
|
||||
|
||||
function renderBands() {
|
||||
const right = viewBox[0] - 72;
|
||||
const titles = bandTitles();
|
||||
return ` <path d="M 72 112 L ${right} 112" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
|
||||
<text x="72" y="100" class="t-dim" font-size="10" font-weight="600">01 / ${esc(titles[0])}</text>
|
||||
<path d="M 72 264 L ${right} 264" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
|
||||
<text x="72" y="252" class="t-dim" font-size="10" font-weight="600">02 / ${esc(titles[1])}</text>
|
||||
<path d="M 72 436 L ${right} 436" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
|
||||
<text x="72" y="424" class="t-dim" font-size="10" font-weight="600">03 / ${esc(titles[2])}</text>`;
|
||||
}
|
||||
|
||||
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 sub = hasSub
|
||||
? `\n <text data-detail="context" x="${state.cx}" y="${state.y + 37}" class="t-muted" font-size="${fittedNodeFontSize(state.sublabel, state.width, stateTextFit.sublabelPreferred, stateTextFit.sublabelMinimum)}" text-anchor="middle">${esc(state.sublabel)}</text>`
|
||||
: '';
|
||||
const tag = state.tag
|
||||
? `\n <text data-detail="fine" x="${state.cx}" y="${state.y + state.height - 11}" class="${accent}" font-size="${fittedNodeFontSize(state.tag, state.width, stateTextFit.tagPreferred, stateTextFit.tagMinimum)}" text-anchor="middle">${esc(state.tag)}</text>`
|
||||
: '';
|
||||
const hasBrand = Boolean(brandMarkFor(state));
|
||||
const step = state.step
|
||||
? `\n <text data-detail="fine" x="${state.x + (hasBrand ? 23 : 10)}" y="${state.y + 14}" class="${accent}" font-size="7" font-weight="700">${esc(state.step)}</text>`
|
||||
: '';
|
||||
const brand = renderBrandMark(state, { x: state.x + state.width - 22, y: state.y + 6 });
|
||||
const labelFontSize = fittedNodeFontSize(state.label, brandLabelFitWidth(state, state.width), 10, 8);
|
||||
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"/>
|
||||
${renderSemanticSigil(state.type, { x: hasBrand ? state.x + 6 : state.x + state.width - 17, y: state.y + 6 })}${brand ? `\n ${brand}` : ''}${step}
|
||||
<text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${state.cx}" y="${state.y + 21}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(state.label)}</text>${sub}${tag}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function renderTransitionPath(transition, index) {
|
||||
const [cls, marker] = arrowClassMap[transition.variant || 'default'] || arrowClassMap.default;
|
||||
const routed = pathFor(transition);
|
||||
const strokeWidth = transition.width || (transition.variant === 'emphasis' ? 2 : 1.1);
|
||||
return ` <path ${focusEdgeAttrs(transition.from, transition.to, transition.label, index, transition.id)} data-composition-points="${routePointsValue(routed.points)}" d="${routed.d}" class="${cls}"${animateAttr(lifecycle.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
|
||||
}
|
||||
|
||||
function renderTransitionLabel(transition, index) {
|
||||
if (!transition.label) return '';
|
||||
const routed = pathFor(transition);
|
||||
const [lx, ly] = labelPoint(transition, routed.points);
|
||||
const longestLine = Math.max(textUnits(transition.label), textUnits(transition.note || ''));
|
||||
const labelW = Math.max(32, longestLine * 4.9 + 12);
|
||||
const labelH = transition.note ? 27 : 16;
|
||||
const note = transition.note
|
||||
? `\n <text data-detail="fine" x="${lx}" y="${ly + 11}" class="t-dim" font-size="7" text-anchor="middle">${esc(transition.note)}</text>`
|
||||
: '';
|
||||
return ` <g data-detail="context" ${focusEdgeAttrs(transition.from, transition.to, transition.label, index, transition.id)}>
|
||||
<rect x="${lx - labelW / 2}" y="${ly - 11}" width="${labelW}" height="${labelH}" rx="4" class="c-mask"/>
|
||||
<text x="${lx}" y="${ly}" class="${variantAccent(transition.variant)}" font-size="8" text-anchor="middle">${esc(transition.label)}</text>${note}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
const LEGEND_CATALOG = [
|
||||
'start',
|
||||
'active',
|
||||
'waiting',
|
||||
'decision',
|
||||
'success',
|
||||
'failure',
|
||||
'neutral',
|
||||
'external',
|
||||
].map((kind) => ({ kind, label: i18nText(lifecycle.meta.locale, `legend.lifecycle.${kind}`) }));
|
||||
|
||||
function renderLegend() {
|
||||
const presentKinds = new Set([...states.values()].map((state) => state.type));
|
||||
const entries = resolveLegend(lifecycle.meta?.legend, LEGEND_CATALOG, presentKinds);
|
||||
return renderResolvedLegend({
|
||||
entries,
|
||||
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: (entry) => `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="${typeClass[entry.kind] || 'c-external'}" stroke-width="1"/>`,
|
||||
});
|
||||
}
|
||||
|
||||
function renderLifecycleRail() {
|
||||
const mainCols = [...states.values()]
|
||||
.filter((state) => bandFor(state.lane) === 'phase')
|
||||
.map((state) => state.col);
|
||||
if (!mainCols.length) return '';
|
||||
const railEnd = layout.phaseXs[Math.max(...mainCols)] + 38;
|
||||
return ` <path 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() {
|
||||
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${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,
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
# 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 `meta.output` from the JSON file
|
||||
or falls back to `sequence.html` in the current working directory.
|
||||
|
||||
## Input
|
||||
|
||||
Sequence JSON files must set:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "sequence",
|
||||
"meta": {
|
||||
"title": "Cache Miss Request Sequence",
|
||||
"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.
|
||||
|
||||
## Layout budget
|
||||
|
||||
| Constant | Value |
|
||||
|----------|-------|
|
||||
| viewBox | default `[920, 760]`; 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; 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 row | y = height − 54 |
|
||||
|
||||
`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.
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Put participants across the top, ordered by the story the reader should
|
||||
follow.
|
||||
- Time moves downward.
|
||||
- Use `emphasis` for the main request path.
|
||||
- Use `security` for auth, consent, permission, and policy calls.
|
||||
- Use `return` for quiet response messages.
|
||||
- Use `dashed` for async trace, event, logging, and non-blocking work.
|
||||
- Use segments as light background guides; keep segment labels short.
|
||||
- Keep labels concise, but try `meta.column_fit: "spread"` before shortening a
|
||||
meaningful participant label just to fit the fixed boxes.
|
||||
|
||||
Schema violations exit non-zero with path-prefixed messages annotated with the
|
||||
element's id or label. The renderer additionally fails when it can detect
|
||||
layout problems, including missing participants, duplicate participant IDs,
|
||||
participant labels wider than their box, unknown message endpoints, messages
|
||||
outside the readable timeline, overly tight vertical spacing between messages
|
||||
that overlap horizontally, invalid segment or activation ranges, or
|
||||
participants that exceed the viewBox. The shared Clean Flow contract treats
|
||||
participant headers as semantic boxes while explicitly allowing messages to
|
||||
cross intermediate lifelines, activation bars, and segment frames. Text width is estimated CJK-aware:
|
||||
fullwidth glyphs count as two units.
|
||||
|
||||
Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
|
||||
message X crossings then fail with `composition/proper-crossing`; default
|
||||
`standard` keeps them as artifact-receipt warnings. Messages may still cross
|
||||
intermediate lifelines. Collinear corridors remain outside the proper-X rule,
|
||||
but a separate gate warns in `standard` and fails in `showcase` when unrelated
|
||||
messages overlap for at least 8px. Shared semantic endpoints, point touches,
|
||||
and shorter overlaps remain valid. Showcase also rejects any route segment
|
||||
below 8px and any interior turn segment below 16px; ordinary 8–15px endpoint
|
||||
stubs remain valid.
|
||||
@@ -0,0 +1,453 @@
|
||||
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 { componentFill, arrowClassMap, rectsOverlap, cleanFlowProblems, cleanCrossingProblems, cleanAmbiguousCorridorProblems, cleanBorderRunProblems, cleanRouteRhythmProblems, cleanLabelRouteClearanceProblems, routePointsValue, asArray, isFinitePoint } 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 } = await loadDiagramWithBrandMarks({
|
||||
rendererDir: __dirname,
|
||||
diagramType: 'sequence',
|
||||
defaultExample: 'cache-miss-request.sequence.json'
|
||||
});
|
||||
|
||||
const viewBox = sequence.meta?.viewBox || [920, 760];
|
||||
// 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;
|
||||
|
||||
const layout = {
|
||||
topY: 72,
|
||||
participantW,
|
||||
participantH: 54,
|
||||
lifelineTop: 142,
|
||||
lifelineBottom: viewBox[1] - 65,
|
||||
legendY: viewBox[1] - 54,
|
||||
leftX: columnFit === 'spread' ? sideMargin + participantW / 2 : sideMargin,
|
||||
colGap,
|
||||
labelH: 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) * 5.2 + 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,
|
||||
};
|
||||
}
|
||||
|
||||
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'
|
||||
}));
|
||||
|
||||
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}.`);
|
||||
}
|
||||
}
|
||||
|
||||
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.`);
|
||||
}
|
||||
|
||||
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 + 39}" 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, { 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 + 22}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(participant.label)}</text>${sub}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function renderLifeline(participant) {
|
||||
return ` <path d="M ${participant.cx} ${layout.lifelineTop} L ${participant.cx} ${layout.lifelineBottom}" 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 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 ` <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) * 5.2 + 12);
|
||||
const accent = message.variant === 'security'
|
||||
? 't-security'
|
||||
: message.variant === 'dashed'
|
||||
? 't-messagebus'
|
||||
: message.variant === 'return'
|
||||
? 't-muted'
|
||||
: 't-backend';
|
||||
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="9" 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>`;
|
||||
}
|
||||
|
||||
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 renderLegend() {
|
||||
const presentKinds = new Set(asArray(sequence.messages).map((message) => message.variant || 'default'));
|
||||
const entries = resolveLegend(sequence.meta?.legend, LEGEND_CATALOG, presentKinds);
|
||||
return renderResolvedLegend({
|
||||
entries,
|
||||
locale: sequence.meta.locale,
|
||||
layout: {
|
||||
x: 40,
|
||||
baselineY: layout.legendY,
|
||||
width: viewBox[0] - 80,
|
||||
minTitleY: layout.legendY - 30,
|
||||
unfit: sequence.meta?.legend === undefined ? 'hide' : 'error',
|
||||
diagramType: 'sequence',
|
||||
},
|
||||
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()];
|
||||
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${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(renderLifeline).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,
|
||||
});
|
||||
@@ -0,0 +1,563 @@
|
||||
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, '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}`);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
function attribute(tag, name) {
|
||||
const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
|
||||
return match ? (match[1] ?? match[2] ?? match[3] ?? '') : '';
|
||||
}
|
||||
|
||||
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 = 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 readLimited(page.response, MAX_HTML_BYTES)).toString('utf8');
|
||||
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/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>`;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
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, throwDiagnosticProblems } from './diagnostics.mjs';
|
||||
import { validateEngineeringProfile } from './engineering-profiles.mjs';
|
||||
import { resolveOutputPath } from './output-path.mjs';
|
||||
import { prepareDiagramBrandMarks } from './brand-marks.mjs';
|
||||
import { resolveLocale, translateMessage } from './i18n.mjs';
|
||||
|
||||
installRendererDiagnosticBoundary();
|
||||
|
||||
const outputPathGuards = new Map();
|
||||
|
||||
// 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 }) {
|
||||
const skillRoot = path.resolve(rendererDir, '../..');
|
||||
const inputPath = path.resolve(argv[2] || path.join(skillRoot, 'examples', defaultExample));
|
||||
const diagram = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
|
||||
validateSchema(diagramType, diagram);
|
||||
validateGuidedViews(diagramType, diagram);
|
||||
validateRelationshipIds(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(),
|
||||
};
|
||||
const { outputPath: outPath } = resolveOutputPath(outputRequest);
|
||||
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']);
|
||||
|
||||
// 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);
|
||||
if (outputGuard) resolveOutputPath(outputGuard);
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
fs.writeFileSync(outPath, applyTemplate(template, {
|
||||
title: meta.title,
|
||||
subtitle: meta.subtitle,
|
||||
svg,
|
||||
cards: renderCards(cards),
|
||||
locale: meta.locale,
|
||||
visualPreset: meta.visual_preset || 'classic',
|
||||
guidedViews: meta.views || [],
|
||||
sourceEvidence,
|
||||
}));
|
||||
outputPathGuards.delete(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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// JSON Schema keeps the view object bounded; this pass checks facts that span
|
||||
// collections. Keeping it here makes the same contract apply to all five
|
||||
// renderers, including the zero-install standalone-validator path.
|
||||
export function validateGuidedViews(diagramType, diagram) {
|
||||
const views = diagram.meta?.views;
|
||||
if (!Array.isArray(views) || views.length === 0) return;
|
||||
const collection = SEMANTIC_COLLECTIONS[diagramType];
|
||||
const semanticIds = new Set((diagram[collection] || []).map((item) => item.id));
|
||||
const seen = new Set();
|
||||
const problems = [];
|
||||
|
||||
views.forEach((view, index) => {
|
||||
if (seen.has(view.id)) problems.push(`/meta/views/${index}/id duplicates view id ${JSON.stringify(view.id)}`);
|
||||
seen.add(view.id);
|
||||
const seenFocus = new Set();
|
||||
(view.focus || []).forEach((id, focusIndex) => {
|
||||
if (seenFocus.has(id)) {
|
||||
problems.push(`/meta/views/${index}/focus/${focusIndex} duplicates semantic id ${JSON.stringify(id)}`);
|
||||
}
|
||||
seenFocus.add(id);
|
||||
if (!semanticIds.has(id)) {
|
||||
problems.push(`/meta/views/${index}/focus/${focusIndex} references unknown semantic id ${JSON.stringify(id)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (problems.length) {
|
||||
throwDiagnosticProblems('Guided view validation failed', problems, {
|
||||
code: 'guided-view/invalid',
|
||||
subject: { diagramType, collection: 'meta.views' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Accessible name for the generated diagram SVG.
|
||||
export function svgRootAttrs(meta) {
|
||||
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 = 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, Story, and relationship contracts.
|
||||
const safeStep = Number.isFinite(step) && step >= 0 ? Math.min(12, Math.floor(step)) : 0;
|
||||
return ` data-animate="${kind}" style="--step:${safeStep}"`;
|
||||
}
|
||||
|
||||
// Stable semantic hooks for the standalone HTML explorer. IDs already pass
|
||||
// the schema's conservative identifier pattern; escape again at the markup
|
||||
// boundary so these helpers remain safe if that contract expands later.
|
||||
export function focusNodeAttrs(id, label, metadata = {}, locale) {
|
||||
const optional = [
|
||||
['data-node-kind', metadata.kind],
|
||||
['data-node-sublabel', metadata.sublabel],
|
||||
['data-node-tag', metadata.tag],
|
||||
['data-node-context', metadata.context],
|
||||
['data-node-brand', metadata.brand],
|
||||
['data-node-brand-id', metadata.brandId],
|
||||
['data-node-brand-status', metadata.brandStatus],
|
||||
['data-node-brand-source', metadata.brandSource],
|
||||
].filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '')
|
||||
.map(([name, value]) => ` ${name}="${esc(String(value))}"`)
|
||||
.join('');
|
||||
const detail = [metadata.sublabel, metadata.context, metadata.brand]
|
||||
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '')
|
||||
.join(', ');
|
||||
const aria = detail
|
||||
? translateMessage(locale, 'node.focus.detail', { label, detail })
|
||||
: translateMessage(locale, 'node.focus', { label });
|
||||
return `id="node-${esc(id)}" data-node-id="${esc(id)}" data-node-label="${esc(label)}" tabindex="0" role="button" aria-label="${esc(aria)}" aria-pressed="false"${optional}`;
|
||||
}
|
||||
|
||||
// Native SVG titles preserve a compact details-on-demand fallback when the
|
||||
// canonical SVG is embedded inline outside the full Archify viewer.
|
||||
export function focusNodeTitle(label, metadata = {}) {
|
||||
const parts = [label, metadata.sublabel, metadata.context, metadata.tag, metadata.brand]
|
||||
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '');
|
||||
return `<title>${esc(parts.join(' · '))}</title>`;
|
||||
}
|
||||
|
||||
export function focusEdgeAttrs(from, to, label, key, id) {
|
||||
const named = label ? ` data-edge-label="${esc(label)}"` : '';
|
||||
const keyed = key !== undefined && key !== null ? ` data-edge-key="${esc(String(key))}"` : '';
|
||||
const identified = id !== undefined && id !== null && String(id).trim() !== ''
|
||||
? ` data-edge-id="${esc(String(id))}"`
|
||||
: '';
|
||||
return `data-edge-from="${esc(from)}" data-edge-to="${esc(to)}"${named}${keyed}${identified}`;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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 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);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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 = {} } = {}) {
|
||||
const messages = (problems || []).map((problem) => String(problem));
|
||||
const diagnostics = messages.map((message) => normalizedDiagnostic({
|
||||
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;
|
||||
if (error instanceof SyntaxError) {
|
||||
return normalizedDiagnostic({
|
||||
code: 'input/json-parse',
|
||||
severity: 'error',
|
||||
message: `Input JSON could not be parsed: ${error.message}`,
|
||||
subject: { input },
|
||||
evidence: { reason: error.message },
|
||||
supportedFixes: ['repair the JSON syntax and run validation again'],
|
||||
});
|
||||
}
|
||||
if (error?.code === 'ENOENT' || error?.code === 'EACCES' || error?.code === 'EISDIR') {
|
||||
return normalizedDiagnostic({
|
||||
code: 'input/read',
|
||||
severity: 'error',
|
||||
message: `Input could not be read: ${error.message}`,
|
||||
subject: { input },
|
||||
evidence: { systemCode: error.code, reason: error.message },
|
||||
supportedFixes: ['provide one readable JSON input file'],
|
||||
});
|
||||
}
|
||||
return normalizedDiagnostic({
|
||||
code: 'internal/unclassified',
|
||||
severity: 'error',
|
||||
message: error?.message || 'Renderer failed without a diagnostic.',
|
||||
subject: { input },
|
||||
evidence: { errorName: error?.name || 'Error' },
|
||||
supportedFixes: [],
|
||||
});
|
||||
}
|
||||
function rendererFailure(error) {
|
||||
const attached = Array.isArray(error?.archifyDiagnostics)
|
||||
? error.archifyDiagnostics.map(normalizedDiagnostic)
|
||||
: [];
|
||||
const diagnostics = recorded.length ? recorded : (attached.length ? attached : [fallbackDiagnostic(error)]);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
ok: false,
|
||||
source: 'renderer',
|
||||
error: error?.message || 'Renderer failed without a diagnostic.',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
export function installRendererDiagnosticBoundary() {
|
||||
if (!DIAGNOSTIC_MODE || globalThis[boundaryKey]) return;
|
||||
globalThis[boundaryKey] = true;
|
||||
process.on('uncaughtException', (error) => {
|
||||
const payload = `${JSON.stringify(rendererFailure(error))}\n`;
|
||||
try {
|
||||
fs.writeSync(process.stderr.fd, payload);
|
||||
} catch {
|
||||
// The renderer is already failing. Avoid replacing its real error with a
|
||||
// secondary stream failure; the parent CLI still has the exit status.
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { throwDiagnosticError } from './diagnostics.mjs';
|
||||
|
||||
const DEPLOYMENT_PROFILE = 'deployment-ownership';
|
||||
const DEPLOYMENT_BOUNDARY_KINDS = new Set(['region', 'security-group']);
|
||||
const PRIVATE_STATE_TYPES = new Set(['database']);
|
||||
|
||||
function subject(collection, index, item = {}) {
|
||||
return {
|
||||
diagramType: 'architecture',
|
||||
profile: DEPLOYMENT_PROFILE,
|
||||
collection,
|
||||
index,
|
||||
...(item.id ? { id: item.id } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function membership(boundaries, componentId, kind) {
|
||||
return boundaries
|
||||
.map((boundary, index) => ({ boundary, index }))
|
||||
.filter(({ boundary }) => boundary.kind === kind && boundary.wraps.includes(componentId));
|
||||
}
|
||||
|
||||
export function deploymentOwnershipDiagnostics(diagram) {
|
||||
const components = Array.isArray(diagram.components) ? diagram.components : [];
|
||||
const boundaries = (Array.isArray(diagram.boundaries) ? diagram.boundaries : [])
|
||||
.map((boundary) => ({ ...boundary, wraps: Array.isArray(boundary.wraps) ? boundary.wraps : [] }));
|
||||
const connections = Array.isArray(diagram.connections) ? diagram.connections : [];
|
||||
const diagnostics = [];
|
||||
|
||||
for (const kind of DEPLOYMENT_BOUNDARY_KINDS) {
|
||||
const count = boundaries.filter((boundary) => boundary.kind === kind).length;
|
||||
if (count > 0) continue;
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-boundary-kind',
|
||||
severity: 'error',
|
||||
message: `Deployment ownership requires at least one ${kind} boundary.`,
|
||||
subject: subject('boundaries', -1),
|
||||
evidence: { requiredKind: kind, found: count },
|
||||
supportedFixes: [`add one ${kind} boundary with an explicit wraps list`],
|
||||
});
|
||||
}
|
||||
|
||||
components.forEach((component, index) => {
|
||||
if (component.type === 'external') return;
|
||||
if (typeof component.tag !== 'string' || component.tag.trim() === '') {
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-owner-missing',
|
||||
severity: 'error',
|
||||
message: `Deployment component ${JSON.stringify(component.id)} does not name its owner in tag.`,
|
||||
subject: subject('components', index, component),
|
||||
evidence: { componentType: component.type, ownerField: 'tag' },
|
||||
supportedFixes: [`set /components/${index}/tag to the responsible team or owner`],
|
||||
});
|
||||
}
|
||||
|
||||
const regions = membership(boundaries, component.id, 'region');
|
||||
if (regions.length === 0) {
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-region-scope',
|
||||
severity: 'error',
|
||||
message: `Deployment component ${JSON.stringify(component.id)} is not assigned to a region boundary.`,
|
||||
subject: subject('components', index, component),
|
||||
evidence: { componentType: component.type, regionMemberships: 0 },
|
||||
supportedFixes: ['add the component id to the real region boundary wraps list'],
|
||||
});
|
||||
} else if (regions.length > 1) {
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-region-ambiguous',
|
||||
severity: 'error',
|
||||
message: `Deployment component ${JSON.stringify(component.id)} belongs to more than one region boundary.`,
|
||||
subject: subject('components', index, component),
|
||||
evidence: {
|
||||
componentType: component.type,
|
||||
regions: regions.map(({ boundary, index: boundaryIndex }) => ({ boundaryIndex, label: boundary.label })),
|
||||
},
|
||||
supportedFixes: ['keep the component id in exactly one real region boundary wraps list'],
|
||||
});
|
||||
}
|
||||
|
||||
if (PRIVATE_STATE_TYPES.has(component.type)) {
|
||||
const privateScopes = membership(boundaries, component.id, 'security-group');
|
||||
if (privateScopes.length === 0) {
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-private-state',
|
||||
severity: 'error',
|
||||
message: `Stateful component ${JSON.stringify(component.id)} is not assigned to a private security-group boundary.`,
|
||||
subject: subject('components', index, component),
|
||||
evidence: { componentType: component.type, privateMemberships: 0 },
|
||||
supportedFixes: ['add the component id to the real private security-group boundary wraps list'],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
boundaries.forEach((boundary, index) => {
|
||||
if (boundary.kind !== 'security-group') return;
|
||||
const members = boundary.wraps.map((id) => ({
|
||||
id,
|
||||
regions: membership(boundaries, id, 'region').map(({ boundary: region, index: boundaryIndex }) => ({
|
||||
boundaryIndex,
|
||||
label: region.label,
|
||||
})),
|
||||
}));
|
||||
const regionIndexes = new Set(members.flatMap((member) => member.regions.map((region) => region.boundaryIndex)));
|
||||
const consistent = members.length > 0
|
||||
&& members.every((member) => member.regions.length === 1)
|
||||
&& regionIndexes.size === 1;
|
||||
if (consistent) return;
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-private-region-consistency',
|
||||
severity: 'error',
|
||||
message: `Private boundary ${JSON.stringify(boundary.label)} must contain components from exactly one shared region.`,
|
||||
subject: subject('boundaries', index, boundary),
|
||||
evidence: { boundaryKind: boundary.kind, members },
|
||||
supportedFixes: ['assign every private-boundary component to exactly one shared region boundary'],
|
||||
});
|
||||
});
|
||||
|
||||
connections.forEach((connection, index) => {
|
||||
const crossedBoundaries = boundaries
|
||||
.map((boundary, boundaryIndex) => ({
|
||||
boundaryIndex,
|
||||
kind: boundary.kind,
|
||||
label: boundary.label,
|
||||
fromInside: boundary.wraps.includes(connection.from),
|
||||
toInside: boundary.wraps.includes(connection.to),
|
||||
}))
|
||||
.filter((boundary) => DEPLOYMENT_BOUNDARY_KINDS.has(boundary.kind) && boundary.fromInside !== boundary.toInside);
|
||||
if (crossedBoundaries.length === 0 || (typeof connection.label === 'string' && connection.label.trim() !== '')) return;
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-crossing-mechanism',
|
||||
severity: 'error',
|
||||
message: `Cross-boundary connection ${JSON.stringify(connection.id || `${connection.from}->${connection.to}`)} does not name its mechanism.`,
|
||||
subject: subject('connections', index, connection),
|
||||
evidence: {
|
||||
from: connection.from,
|
||||
to: connection.to,
|
||||
crossedBoundaries: crossedBoundaries.map(({ boundaryIndex, kind, label }) => ({ boundaryIndex, kind, label })),
|
||||
},
|
||||
supportedFixes: [`set /connections/${index}/label to the real cross-boundary mechanism`],
|
||||
});
|
||||
});
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export function validateEngineeringProfile(diagramType, diagram) {
|
||||
const profile = diagram.meta?.engineering_profile;
|
||||
if (!profile) return;
|
||||
if (diagramType !== 'architecture' || profile !== DEPLOYMENT_PROFILE) return;
|
||||
const diagnostics = deploymentOwnershipDiagnostics(diagram);
|
||||
if (!diagnostics.length) return;
|
||||
throwDiagnosticError(
|
||||
`Engineering profile ${JSON.stringify(profile)} failed:\n${diagnostics.map((entry) => `- ${entry.message}`).join('\n')}`,
|
||||
diagnostics,
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,594 @@
|
||||
export const SUPPORTED_LOCALES = ['en', 'zh-CN'];
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
|
||||
const ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (character) => ESCAPE_MAP[character]);
|
||||
}
|
||||
|
||||
// One catalog feeds renderer-time SVG/HTML copy and the selected runtime
|
||||
// catalog embedded in each standalone artifact. Keeping every locale 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': ['request', '请求'],
|
||||
'legend.sequence.return': ['return', '返回'],
|
||||
'legend.sequence.security': ['security', '安全'],
|
||||
'legend.sequence.dashed': ['async trace', '异步追踪'],
|
||||
'legend.sequence.default': ['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': ['start', '开始'],
|
||||
'legend.lifecycle.active': ['active state', '活动状态'],
|
||||
'legend.lifecycle.waiting': ['waiting', '等待'],
|
||||
'legend.lifecycle.decision': ['decision', '决策'],
|
||||
'legend.lifecycle.success': ['terminal success', '成功终态'],
|
||||
'legend.lifecycle.failure': ['failure / exit', '失败 / 退出'],
|
||||
'legend.lifecycle.neutral': ['neutral', '中性状态'],
|
||||
'legend.lifecycle.external': ['external', '外部状态'],
|
||||
|
||||
'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': ['S cycles', 'S 循环切换'],
|
||||
'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.story': ['the guided story', '引导故事'],
|
||||
'viewer.owner.chapter': ['the active chapter', '当前章节'],
|
||||
'viewer.owner.chapterPreview': ['the chapter delta preview', '章节差异预览'],
|
||||
'viewer.owner.handoff': ['the chapter handoff', '章节交接'],
|
||||
'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.copyShareCard': ['Copy 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.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.period': ['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.copiedShare': ['Copied Share Card', '已复制分享卡片'],
|
||||
'viewer.export.downloadedShare': ['Downloaded Share Card', '已下载分享卡片'],
|
||||
'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': ['Route: {source} → {target} · {count} directed hop', '路径:{source} → {target} · {count} 个有向跳转'],
|
||||
'viewer.export.card.routeSummary.other': ['Route: {source} → {target} · {count} directed hops', '路径:{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.card.defaultBadge': ['ARCHIFY · {preset} · {theme}', 'ARCHIFY · {preset} · {theme}'],
|
||||
'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.guided.region': ['Guided diagram views', '图表引导视图'],
|
||||
'viewer.guided.previous': ['Previous guided view', '上一个引导视图'],
|
||||
'viewer.guided.previous.title': ['Previous guided view ([)', '上一个引导视图([)'],
|
||||
'viewer.guided.next': ['Next guided view', '下一个引导视图'],
|
||||
'viewer.guided.next.title': ['Next guided view (])', '下一个引导视图(])'],
|
||||
'viewer.guided.views': ['Guided views', '引导视图'],
|
||||
'viewer.guided.explore': ['Explore this system', '探索此系统'],
|
||||
'viewer.guided.intro': ['Step through curated paths without changing the source diagram.', '沿精选路径逐步查看,而不改变源图表。'],
|
||||
'viewer.guided.trail': ['Story trail', '故事轨迹'],
|
||||
'viewer.guided.beat': ['Beat', '节点'],
|
||||
'viewer.guided.nextBeat': ['Next', '下一步'],
|
||||
'viewer.guided.play': ['Play guided story', '播放引导故事'],
|
||||
'viewer.guided.play.title': ['Play guided story (P)', '播放引导故事(P)'],
|
||||
'viewer.guided.pause': ['Pause guided story', '暂停引导故事'],
|
||||
'viewer.guided.pause.title': ['Pause guided story (P)', '暂停引导故事(P)'],
|
||||
'viewer.guided.replay': ['Replay guided story', '重播引导故事'],
|
||||
'viewer.guided.replay.title': ['Replay guided story (P)', '重播引导故事(P)'],
|
||||
'viewer.guided.playStory': ['Play story', '播放故事'],
|
||||
'viewer.guided.pauseStory': ['Pause', '暂停'],
|
||||
'viewer.guided.replayStory': ['Replay story', '重播故事'],
|
||||
'viewer.guided.motionUnavailable': ['Story playback unavailable while motion is Still', '静态模式下无法播放故事'],
|
||||
'viewer.guided.enableMotion': ['Switch motion to Live to play the guided story', '切换为动态模式以播放引导故事'],
|
||||
'viewer.guided.selectBeatLink': ['Select a Story Beat to copy its exact link', '选择故事节点以复制其精确链接'],
|
||||
'viewer.guided.copyMoment': ['Copy moment', '复制此刻'],
|
||||
'viewer.guided.momentCopied': ['Moment link copied', '已复制时刻链接'],
|
||||
'viewer.guided.momentCopyFailed': ['Could not copy story moment link', '无法复制故事时刻链接'],
|
||||
'viewer.guided.copied': ['Copied', '已复制'],
|
||||
'viewer.guided.copyFailed': ['Copy failed', '复制失败'],
|
||||
'viewer.guided.showAll': ['Show all', '显示全部'],
|
||||
'viewer.guided.showAll.aria': ['Show entire diagram', '显示完整图表'],
|
||||
'viewer.guided.chapters': ['Story chapters', '故事章节'],
|
||||
'viewer.guided.storyTrail': ['Story trail for {label}: {count} beats', '{label}的故事轨迹:{count} 个节点'],
|
||||
'viewer.guided.chapter.open': ['Open chapter {index} of {total}: {label}, {count} stops', '打开第 {index}/{total} 章:{label},{count} 个停靠点'],
|
||||
'viewer.guided.chapter.current': ['Current chapter {index} of {total}: {label}, {count} stops', '当前第 {index}/{total} 章:{label},{count} 个停靠点'],
|
||||
'viewer.guided.chapter.selectedNodes': ['{count} selected nodes', '已选择 {count} 个节点'],
|
||||
'viewer.guided.chapter.stops': ['{count} stops', '{count} 个停靠点'],
|
||||
'viewer.guided.chapter.stop.one': ['{count} stop', '{count} 个停靠点'],
|
||||
'viewer.guided.chapter.stop.other': ['{count} stops', '{count} 个停靠点'],
|
||||
'viewer.guided.chapter.current.title': ['{label} — current chapter, {count} stops', '{label} — 当前章节,{count} 个停靠点'],
|
||||
'viewer.guided.chapter.delta.expanded': ['{stay} stay, {enter} enter, {leave} leave', '{stay} 个保留,{enter} 个进入,{leave} 个离开'],
|
||||
'viewer.guided.chapter.delta.aria': ['Open chapter {index} of {total}: {label}. Chapter focus delta: {delta}', '打开第 {index}/{total} 章:{label}。章节聚焦差异:{delta}'],
|
||||
'viewer.guided.chapter.delta.title': ['{label} — {delta} chapter focus', '{label} — 章节聚焦 {delta}'],
|
||||
'viewer.guided.handoff': ['{from} → {to} · via {label}', '{from} → {to} · 经由{label}'],
|
||||
'viewer.guided.share.chapter': ['Chapter {index} / {total}', '章节 {index} / {total}'],
|
||||
'viewer.guided.share.initial': ['Chapter 01 / 01', '章节 01 / 01'],
|
||||
'viewer.guided.share.default': ['Guided chapter', '引导章节'],
|
||||
'viewer.guided.state.ready': ['Ready', '就绪'],
|
||||
'viewer.guided.state.playing': ['Playing', '播放中'],
|
||||
'viewer.guided.state.settled': ['Settled', '已完成'],
|
||||
'viewer.guided.state.paused': ['Paused', '已暂停'],
|
||||
'viewer.guided.state.pinned': ['Pinned', '已固定'],
|
||||
'viewer.guided.state.still': ['Still', '静态'],
|
||||
'viewer.guided.share.step': ['Step {index} / {total} · {label}', '步骤 {index} / {total} · {label}'],
|
||||
'viewer.guided.share.staticMoment': ['{step} · Static moment', '{step} · 静态时刻'],
|
||||
'viewer.guided.share.complete': ['{count} steps complete · {note}', '{count} 个步骤已完成 · {note}'],
|
||||
'viewer.guided.share.settled': ['Path settled for reading.', '路径已稳定,可供阅读。'],
|
||||
'viewer.guided.share.staticPath': ['{count} steps · Static path', '{count} 个步骤 · 静态路径'],
|
||||
'viewer.guided.share.ready': ['{count} steps · Ready', '{count} 个步骤 · 就绪'],
|
||||
'viewer.guided.share.aria': ['{state} chapter {index} of {total}: {label}. {beat}. {route}', '{state},第 {index}/{total} 章:{label}。{beat}。{route}'],
|
||||
'viewer.guided.beat.start': ['Beat {index} / {total} · {label} · starting point', '节点 {index} / {total} · {label} · 起点'],
|
||||
'viewer.guided.beat.forward': ['Beat {index} / {total} · {from} → {to}', '节点 {index} / {total} · {from} → {to}'],
|
||||
'viewer.guided.beat.reverse': ['Beat {index} / {total} · {from} → {to} · reverse authored link', '节点 {index} / {total} · {from} → {to} · 反向编写连接'],
|
||||
'viewer.guided.beat.multiple': ['Beat {index} / {total} · {from} ⇄ {to} · {count} authored links', '节点 {index} / {total} · {from} ⇄ {to} · {count} 条编写连接'],
|
||||
'viewer.guided.beat.group': ['Beat {index} / {total} · {from} · {to} · grouped · no direct link', '节点 {index} / {total} · {from} · {to} · 分组 · 无直接连接'],
|
||||
'viewer.guided.beat.aria.prefix': ['Story beat {index} of {total}: {label}. ', '故事节点 {index}/{total}:{label}。'],
|
||||
'viewer.guided.beat.aria.start': ['Starting point.', '起点。'],
|
||||
'viewer.guided.beat.aria.forward': ['From {from} through one authored forward relationship.', '从{from}经一条正向编写关系到达。'],
|
||||
'viewer.guided.beat.aria.reverse': ['From {from}; the authored relationship points from {to} to {from}.', '从{from}出发;编写关系实际由{to}指向{from}。'],
|
||||
'viewer.guided.beat.aria.multiple': ['From {from} through {count} authored relationships; shown without arbitrary motion.', '从{from}经 {count} 条编写关系到达;不使用任意动效。'],
|
||||
'viewer.guided.beat.aria.group': ['Grouped from {from} with no direct authored relationship.', '与{from}分组展示,没有直接编写关系。'],
|
||||
'viewer.guided.caption.start': ['Starting point', '起点'],
|
||||
'viewer.guided.caption.grouped': ['Grouped transition · no direct authored link', '分组过渡 · 无直接编写连接'],
|
||||
'viewer.guided.caption.more': [' +{count} more', ' +另外 {count} 条'],
|
||||
'viewer.guided.caption.reverse': ['Reverse authored relationship', '反向编写关系'],
|
||||
'viewer.guided.caption.relationships': ['{count} authored relationships', '{count} 条编写关系'],
|
||||
'viewer.guided.caption.relationship': ['Authored relationship', '编写关系'],
|
||||
'viewer.guided.caption.direction': ['authored direction: {from} → {to}', '编写方向:{from} → {to}'],
|
||||
'viewer.guided.caption.starting': ['Authored starting point', '编写起点'],
|
||||
'viewer.guided.beatLink': ['Copy link to current story moment: Beat {index} of {total}: {label}', '复制当前故事时刻链接:第 {index}/{total} 个节点:{label}'],
|
||||
'viewer.guided.noStory': ['This diagram has no authored guided story.', '此图表没有编写引导故事。'],
|
||||
|
||||
'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.story': ['Play the guided story', '播放引导故事'],
|
||||
'viewer.guide.story.hint': ['Walk the authored chapters and real relationships.', '浏览已编写的章节和真实关系。'],
|
||||
'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} · {views}', '{nodes} · {relationships} · {views}'],
|
||||
'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.fact.view.one': ['{count} guided view', '{count} 个引导视图'],
|
||||
'viewer.guide.fact.view.other': ['{count} guided views', '{count} 个引导视图'],
|
||||
'viewer.guide.story.available.one': ['Walk {count} authored chapter and its real relationships.', '浏览 {count} 个已编写章节及其真实关系。'],
|
||||
'viewer.guide.story.available.other': ['Walk {count} authored chapters and their real relationships.', '浏览 {count} 个已编写章节及其真实关系。'],
|
||||
'viewer.guide.story.unavailable': ['No authored guided story in this diagram.', '此图表没有编写引导故事。'],
|
||||
'viewer.guide.open': ['Open diagram guide', '打开图表指南'],
|
||||
'viewer.guide.noStory': ['This diagram has no authored guided story.', '此图表没有编写引导故事。'],
|
||||
|
||||
'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.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.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': ['Route probe', '路径探测'],
|
||||
'viewer.route.start': ['Choose a start node', '选择起点节点'],
|
||||
'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': ['Traced route', '已追踪路径'],
|
||||
'viewer.route.pickTwo': ['Pick two semantic nodes on the diagram', '在图表中选择两个语义节点'],
|
||||
'viewer.route.pickOne': ['Pick a semantic 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': ['Choose the source, then the destination. Direction matters.', '先选择来源,再选择目标;方向很重要。'],
|
||||
'viewer.route.destination': ['Choose a destination from {label}', '选择从{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': ['A route needs two distinct semantic nodes.', '一条路径需要两个不同的语义节点。'],
|
||||
'viewer.route.unreachable': ['No directed route to {label}', '没有通往{label}的有向路径'],
|
||||
'viewer.route.unreachable.detail': ['{target} is not reachable from {source}. Pick a highlighted destination.', '从{source}无法到达{target}。请选择高亮的目标。'],
|
||||
'viewer.route.start.instructions': ['Select the source. The next step will reveal only directed destinations.', '选择来源。下一步只会显示有向可达的目标。'],
|
||||
'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 authored route', '{nodes} · {hops} · 最短编写路径'],
|
||||
'viewer.route.overview.node.one': ['{count} node', '{count} 个节点'],
|
||||
'viewer.route.overview.node.other': ['{count} nodes', '{count} 个节点'],
|
||||
'viewer.route.overview.hop.one': ['{count} directed hop', '{count} 个有向跳转'],
|
||||
'viewer.route.overview.hop.other': ['{count} directed hops', '{count} 个有向跳转'],
|
||||
'viewer.route.phase.playing': ['Playing', '播放中'],
|
||||
'viewer.route.phase.complete': ['Complete', '已完成'],
|
||||
'viewer.route.phase.inspecting': ['Inspecting', '检查中'],
|
||||
'viewer.route.destination.count.one': ['{count} directed destination available. Pick a highlighted node.', '有 {count} 个有向目标可用。请选择高亮节点。'],
|
||||
'viewer.route.destination.count.other': ['{count} directed destinations available. Pick a highlighted node.', '有 {count} 个有向目标可用。请选择高亮节点。'],
|
||||
'viewer.route.noOutgoing': ['No outgoing route starts here. Clear and choose another source.', '此处没有可用的出向路径。请清除后选择其他来源。'],
|
||||
'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.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 CATALOGS = Object.fromEntries(SUPPORTED_LOCALES.map((locale, index) => [
|
||||
locale,
|
||||
Object.fromEntries(Object.entries(MESSAGE_PAIRS).map(([key, pair]) => [key, pair[index]])),
|
||||
]));
|
||||
|
||||
export function resolveLocale(locale) {
|
||||
return SUPPORTED_LOCALES.includes(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);
|
||||
if (!Object.hasOwn(CATALOGS[resolved], key)) {
|
||||
throw new Error(`Missing Archify i18n message ${JSON.stringify(key)} for ${resolved}`);
|
||||
}
|
||||
return formatMessage(CATALOGS[resolved][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(CATALOGS[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 Object.keys(MESSAGE_PAIRS);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/** 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',
|
||||
points: routed.points.map(([x, y]) => [Math.round(x), Math.round(y)]),
|
||||
...(labelAt ? { labelAt: labelAt.map(Math.round) } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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: Math.max(...measured.map((entry) => entry.width)),
|
||||
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');
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const MAX_SYMLINK_DEPTH = 64;
|
||||
const directorySemanticsCache = new Map();
|
||||
let semanticsProbeSequence = 0;
|
||||
|
||||
function splitAbsolute(absolutePath) {
|
||||
const root = path.parse(absolutePath).root;
|
||||
return {
|
||||
root,
|
||||
segments: absolutePath.slice(root.length).split(path.sep).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalize(targetPath, depth) {
|
||||
const absolutePath = path.resolve(targetPath);
|
||||
const { root, segments } = splitAbsolute(absolutePath);
|
||||
let current = root;
|
||||
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
const candidate = path.join(current, segments[index]);
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(candidate);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
||||
return path.resolve(current, ...segments.slice(index));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
if (depth >= MAX_SYMLINK_DEPTH) {
|
||||
const error = new Error(`Could not resolve path because a symbolic-link cycle includes "${candidate}".`);
|
||||
error.code = 'ELOOP';
|
||||
error.path = candidate;
|
||||
throw error;
|
||||
}
|
||||
const link = fs.readlinkSync(candidate);
|
||||
const linkTarget = path.isAbsolute(link) ? link : path.resolve(path.dirname(candidate), link);
|
||||
return canonicalize(path.join(linkTarget, ...segments.slice(index + 1)), depth + 1);
|
||||
}
|
||||
|
||||
current = fs.realpathSync.native(candidate);
|
||||
}
|
||||
|
||||
return path.normalize(current);
|
||||
}
|
||||
|
||||
export function canonicalFuturePath(targetPath) {
|
||||
try {
|
||||
return canonicalize(targetPath, 0);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ELOOP') throw error;
|
||||
const output = path.resolve(targetPath);
|
||||
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: {
|
||||
systemCode: 'ELOOP',
|
||||
...(error.path ? { cycleAt: path.resolve(error.path) } : {}),
|
||||
},
|
||||
supportedFixes: ['remove the symbolic-link cycle or choose an output path outside it'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function hasFileIdentity(stat) {
|
||||
return stat.ino !== 0 && stat.ino !== 0n;
|
||||
}
|
||||
|
||||
function sameFileIdentity(left, right) {
|
||||
return hasFileIdentity(left)
|
||||
&& hasFileIdentity(right)
|
||||
&& left.dev === right.dev
|
||||
&& left.ino === right.ino;
|
||||
}
|
||||
|
||||
function nearestExistingDirectory(targetPath) {
|
||||
let directory = path.dirname(targetPath);
|
||||
while (true) {
|
||||
try {
|
||||
const stat = fs.statSync(directory);
|
||||
if (stat.isDirectory()) {
|
||||
return {
|
||||
path: fs.realpathSync.native(directory),
|
||||
stat,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') return null;
|
||||
}
|
||||
const parent = path.dirname(directory);
|
||||
if (parent === directory) return null;
|
||||
directory = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function directoryIdentityKey(directory) {
|
||||
if (!hasFileIdentity(directory.stat)) return null;
|
||||
return `${directory.stat.dev}:${directory.stat.ino}`;
|
||||
}
|
||||
|
||||
function probeNamesAlias(directoryPath, authoredName, lookupName) {
|
||||
let fileDescriptor;
|
||||
let created = false;
|
||||
let result = null;
|
||||
let cleaned = true;
|
||||
const authoredPath = path.join(directoryPath, authoredName);
|
||||
const lookupPath = path.join(directoryPath, lookupName);
|
||||
try {
|
||||
fileDescriptor = fs.openSync(authoredPath, 'wx', 0o600);
|
||||
created = true;
|
||||
fs.closeSync(fileDescriptor);
|
||||
fileDescriptor = undefined;
|
||||
|
||||
let authored;
|
||||
let lookup;
|
||||
try {
|
||||
authored = fs.statSync(authoredPath);
|
||||
lookup = fs.statSync(lookupPath);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') result = false;
|
||||
}
|
||||
if (authored && lookup) {
|
||||
if (sameFileIdentity(authored, lookup)) {
|
||||
result = true;
|
||||
} else {
|
||||
try {
|
||||
result = fs.realpathSync.native(authoredPath) === fs.realpathSync.native(lookupPath);
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
result = null;
|
||||
} finally {
|
||||
if (fileDescriptor !== undefined) {
|
||||
try {
|
||||
fs.closeSync(fileDescriptor);
|
||||
} catch {
|
||||
cleaned = false;
|
||||
}
|
||||
}
|
||||
if (created) {
|
||||
try {
|
||||
fs.unlinkSync(authoredPath);
|
||||
} catch {
|
||||
cleaned = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cleaned ? result : null;
|
||||
}
|
||||
|
||||
function probeDirectorySemantics(directory) {
|
||||
const cacheKey = directoryIdentityKey(directory);
|
||||
if (cacheKey && directorySemanticsCache.has(cacheKey)) {
|
||||
return directorySemanticsCache.get(cacheKey);
|
||||
}
|
||||
|
||||
semanticsProbeSequence += 1;
|
||||
const suffix = `${process.pid}-${Date.now().toString(36)}-${semanticsProbeSequence}`;
|
||||
const caseAuthored = `.archify-Case-Probe-${suffix}`;
|
||||
const normalizationAuthored = `.archify-norm-\u00e9-probe-${suffix}`;
|
||||
const semantics = {
|
||||
caseInsensitive: probeNamesAlias(
|
||||
directory.path,
|
||||
caseAuthored,
|
||||
caseAuthored.toLowerCase(),
|
||||
),
|
||||
normalizationInsensitive: probeNamesAlias(
|
||||
directory.path,
|
||||
normalizationAuthored,
|
||||
normalizationAuthored.normalize('NFD'),
|
||||
),
|
||||
};
|
||||
if (
|
||||
cacheKey
|
||||
&& semantics.caseInsensitive !== null
|
||||
&& semantics.normalizationInsensitive !== null
|
||||
) {
|
||||
directorySemanticsCache.set(cacheKey, semantics);
|
||||
}
|
||||
return semantics;
|
||||
}
|
||||
|
||||
function sameDirectory(left, right) {
|
||||
return left.path === right.path || sameFileIdentity(left.stat, right.stat);
|
||||
}
|
||||
|
||||
function futurePathsAlias(leftPath, rightPath) {
|
||||
const left = canonicalFuturePath(leftPath);
|
||||
const right = canonicalFuturePath(rightPath);
|
||||
if (left === right) return true;
|
||||
|
||||
const leftDirectory = nearestExistingDirectory(left);
|
||||
const rightDirectory = nearestExistingDirectory(right);
|
||||
if (!leftDirectory || !rightDirectory || !sameDirectory(leftDirectory, rightDirectory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const semantics = probeDirectorySemantics(leftDirectory);
|
||||
let comparableLeft = path.relative(leftDirectory.path, left);
|
||||
let comparableRight = path.relative(rightDirectory.path, right);
|
||||
if (semantics.normalizationInsensitive !== false) {
|
||||
comparableLeft = comparableLeft.normalize('NFC');
|
||||
comparableRight = comparableRight.normalize('NFC');
|
||||
}
|
||||
if (semantics.caseInsensitive !== false) {
|
||||
comparableLeft = comparableLeft.toLowerCase();
|
||||
comparableRight = comparableRight.toLowerCase();
|
||||
}
|
||||
return comparableLeft === comparableRight;
|
||||
}
|
||||
|
||||
export function pathsAlias(leftPath, rightPath) {
|
||||
if (futurePathsAlias(leftPath, rightPath)) return true;
|
||||
try {
|
||||
const left = fs.statSync(leftPath);
|
||||
const right = fs.statSync(rightPath);
|
||||
return sameFileIdentity(left, right);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pathIsInside(directoryPath, targetPath) {
|
||||
const relative = path.relative(canonicalFuturePath(directoryPath), canonicalFuturePath(targetPath));
|
||||
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
|
||||
}
|
||||
|
||||
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(),
|
||||
}) {
|
||||
const rawOutput = requestedOutput || authoredOutput || defaultOutput;
|
||||
const source = requestedOutput ? 'cli' : (authoredOutput ? 'meta' : 'default');
|
||||
if (
|
||||
source === 'meta'
|
||||
&& (path.isAbsolute(rawOutput) || path.posix.isAbsolute(rawOutput) || path.win32.isAbsolute(rawOutput))
|
||||
) {
|
||||
throw new OutputPathError('meta.output must be a relative path.', {
|
||||
code: 'output/meta-absolute',
|
||||
message: 'meta.output must be a relative path resolved from the current working directory.',
|
||||
subject: { output: rawOutput },
|
||||
supportedFixes: ['set meta.output to a relative .html path inside the current working directory'],
|
||||
});
|
||||
}
|
||||
if (source === 'meta' && path.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 },
|
||||
supportedFixes: ['change meta.output to a path ending in .html'],
|
||||
});
|
||||
}
|
||||
const outputPath = path.resolve(cwd, rawOutput);
|
||||
if (source === 'meta' && 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 (source === 'meta' && !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'],
|
||||
});
|
||||
}
|
||||
|
||||
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'],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
outputPath,
|
||||
source,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { throwDiagnosticError } from './diagnostics.mjs';
|
||||
|
||||
const FULL_SHA_RE = /^[a-f0-9]{40}$/i;
|
||||
const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
function evidenceFailure(code, message, { subject = {}, evidence = {}, supportedFixes = [] } = {}) {
|
||||
throwDiagnosticError(message, [{
|
||||
code,
|
||||
severity: 'error',
|
||||
message,
|
||||
subject: { surface: 'repository-evidence', ...subject },
|
||||
evidence,
|
||||
supportedFixes,
|
||||
}]);
|
||||
}
|
||||
|
||||
function runGit(repoRoot, args) {
|
||||
const result = spawnSync('git', ['-C', repoRoot, ...args], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
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 githubSlug(value) {
|
||||
const raw = String(value || '').trim();
|
||||
const match = raw.match(/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
|
||||
return match ? `${match[1]}/${match[2]}`.toLowerCase() : null;
|
||||
}
|
||||
|
||||
function verifiedSourcePath(value, where) {
|
||||
const sourcePath = String(value || '');
|
||||
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 sourceHref(repositoryUrl, revision, source) {
|
||||
const encodedPath = source.path.split('/').map(encodeURIComponent).join('/');
|
||||
const lineFragment = source.line
|
||||
? `#L${source.line}${source.endLine && source.endLine !== source.line ? `-L${source.endLine}` : ''}`
|
||||
: '';
|
||||
return `${repositoryUrl}/blob/${revision}/${encodedPath}${lineFragment}`;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export function hasRepositoryEvidence(diagramType, diagram) {
|
||||
if (diagramType !== 'architecture') return false;
|
||||
const components = Array.isArray(diagram?.components) ? diagram.components : [];
|
||||
return Boolean(diagram?.meta?.repository) || components.some((component) => Array.isArray(component?.sources) && component.sources.length);
|
||||
}
|
||||
|
||||
export function verifyRepositoryEvidence(diagramType, diagram, repoRootInput) {
|
||||
if (!hasRepositoryEvidence(diagramType, diagram)) return null;
|
||||
if (diagramType !== 'architecture') evidenceFailure('repository-evidence/type-unsupported', 'Repository evidence is currently supported for architecture diagrams only.', {
|
||||
subject: { diagramType },
|
||||
supportedFixes: ['use architecture mode or remove repository evidence'],
|
||||
});
|
||||
|
||||
const repository = diagram.meta?.repository;
|
||||
if (!repository) evidenceFailure('repository-evidence/repository-required', 'Repository evidence requires /meta/repository.', {
|
||||
subject: { path: '/meta/repository' },
|
||||
supportedFixes: ['add the pinned public repository metadata or remove component 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 authoredSlug = githubSlug(repository.url);
|
||||
if (!authoredSlug || !String(repository.url).startsWith('https://github.com/')) {
|
||||
evidenceFailure('repository-evidence/url-invalid', '/meta/repository/url must be a public https://github.com owner/repository URL.', {
|
||||
subject: { path: '/meta/repository/url' },
|
||||
evidence: { repositoryUrl: repository.url },
|
||||
supportedFixes: ['use the canonical public GitHub HTTPS repository URL'],
|
||||
});
|
||||
}
|
||||
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.`);
|
||||
if (fs.realpathSync(gitRoot) !== realRoot) {
|
||||
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 (githubSlug(origin) !== authoredSlug) {
|
||||
evidenceFailure('repository-evidence/origin-mismatch', `Evidence repository origin ${JSON.stringify(origin)} does not match ${JSON.stringify(repository.url)}.`, {
|
||||
subject: { repoRoot: realRoot },
|
||||
evidence: { localOrigin: origin, 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'],
|
||||
});
|
||||
}
|
||||
|
||||
const nodes = Object.create(null);
|
||||
let referenceCount = 0;
|
||||
const components = Array.isArray(diagram.components) ? diagram.components : [];
|
||||
for (const [componentIndex, component] of components.entries()) {
|
||||
if (!Array.isArray(component.sources) || component.sources.length === 0) continue;
|
||||
const verified = [];
|
||||
for (const [sourceIndex, authored] of component.sources.entries()) {
|
||||
const where = `/components/${componentIndex}/sources/${sourceIndex}/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', `/components/${componentIndex}/sources/${sourceIndex}/end_line requires line.`, {
|
||||
subject: { path: `/components/${componentIndex}/sources/${sourceIndex}/end_line`, componentId: component.id },
|
||||
supportedFixes: ['add line or remove end_line'],
|
||||
});
|
||||
}
|
||||
if (source.endLine && source.endLine < source.line) {
|
||||
evidenceFailure('repository-evidence/line-range-invalid', `/components/${componentIndex}/sources/${sourceIndex}/end_line must be greater than or equal to line.`, {
|
||||
subject: { path: `/components/${componentIndex}/sources/${sourceIndex}`, componentId: component.id },
|
||||
evidence: { line: source.line, endLine: source.endLine },
|
||||
supportedFixes: ['use an end_line greater than or equal to line'],
|
||||
});
|
||||
}
|
||||
const object = `${revision}:${source.path}`;
|
||||
const type = runGit(realRoot, ['cat-file', '-t', object]);
|
||||
if (type.status !== 0 || type.stdout.trim() !== 'blob') {
|
||||
evidenceFailure('repository-evidence/file-missing', `${where} does not identify a file at revision ${revision}.`, {
|
||||
subject: { path: where, componentId: component.id },
|
||||
evidence: { sourcePath: source.path, revision },
|
||||
supportedFixes: ['use a file path that exists at the pinned revision'],
|
||||
});
|
||||
}
|
||||
if (source.line) {
|
||||
const 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, componentId: component.id },
|
||||
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', `/components/${componentIndex}/sources/${sourceIndex} requests line ${requestedLine}, but ${source.path} has ${lineCount} lines at revision ${revision}.`, {
|
||||
subject: { path: `/components/${componentIndex}/sources/${sourceIndex}`, componentId: component.id },
|
||||
evidence: { sourcePath: source.path, requestedLine, lineCount, revision },
|
||||
supportedFixes: ['use a line range that exists at the pinned revision'],
|
||||
});
|
||||
}
|
||||
}
|
||||
verified.push({ ...source, href: sourceHref(repository.url.replace(/\.git\/?$/i, '').replace(/\/$/, ''), revision, source) });
|
||||
referenceCount += 1;
|
||||
}
|
||||
nodes[component.id] = verified;
|
||||
}
|
||||
if (referenceCount === 0) {
|
||||
evidenceFailure('repository-evidence/source-required', '/meta/repository requires at least one component source reference.', {
|
||||
subject: { path: '/meta/repository' },
|
||||
supportedFixes: ['add at least one verified component source or remove repository metadata'],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
verified: true,
|
||||
repository: {
|
||||
url: repository.url.replace(/\.git\/?$/i, '').replace(/\/$/, ''),
|
||||
revision,
|
||||
shortRevision: revision.slice(0, 7),
|
||||
},
|
||||
referenceCount,
|
||||
nodes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// 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 } 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;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
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: 'backend',
|
||||
database: 'database',
|
||||
success: 'database',
|
||||
cloud: 'cloud',
|
||||
waiting: 'cloud',
|
||||
security: 'security',
|
||||
failure: 'security',
|
||||
messagebus: 'messagebus',
|
||||
external: 'external',
|
||||
neutral: 'external',
|
||||
};
|
||||
|
||||
const SIGIL_SHAPE = {
|
||||
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 role stamp. It is authored 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.
|
||||
export function renderSemanticSigil(kind, { x, y, size = 11 } = {}) {
|
||||
const normalized = Object.hasOwn(SIGIL_SHAPE, kind) ? kind : 'neutral';
|
||||
const tone = SIGIL_TONE[normalized] || '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 GUIDED_VIEWS_PLACEHOLDER = '<!-- ARCHIFY:GUIDED_VIEWS_DATA -->';
|
||||
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>',
|
||||
GUIDED_VIEWS_PLACEHOLDER,
|
||||
];
|
||||
|
||||
export function applyTemplate(template, {
|
||||
title,
|
||||
subtitle,
|
||||
svg,
|
||||
cards,
|
||||
locale,
|
||||
visualPreset = 'classic',
|
||||
guidedViews = [],
|
||||
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 guidedViewsJson = serializeScriptJson(guidedViews);
|
||||
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>`;
|
||||
const localizedTemplate = localizeTemplate(template, resolvedLocale);
|
||||
const templateWithI18n = localizedTemplate.includes(I18N_PLACEHOLDER)
|
||||
? localizedTemplate.replace(I18N_PLACEHOLDER, () => i18nData)
|
||||
: localizedTemplate.replace(GUIDED_VIEWS_PLACEHOLDER, () => `${i18nData}\n ${GUIDED_VIEWS_PLACEHOLDER}`);
|
||||
return templateWithI18n
|
||||
.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(GUIDED_VIEWS_PLACEHOLDER, () => `<script id="archify-guided-views-data" type="application/json">${guidedViewsJson}</script>`)
|
||||
.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;
|
||||
|
||||
// A variation selector (U+FE00-U+FE0F) carries no advance of its own: it
|
||||
// re-presents the character before it. VS15 (U+FE0E) asks for text
|
||||
// presentation, which renders narrow; VS16 (U+FE0F) asks for emoji
|
||||
// presentation, which renders at the square emoji advance. So a base plus a
|
||||
// selector is measured from the selector, not from the base -- otherwise
|
||||
// widening the emoji-presentation bases above turns U+2B50 U+FE0F from two
|
||||
// units into three while the glyph on screen stays one square, and leaves
|
||||
// U+2708 U+FE0F at two only because its base happens to be narrow.
|
||||
//
|
||||
// A selector following a base that cannot take emoji presentation is
|
||||
// malformed input; measuring it wide is the safe direction here, since
|
||||
// over-measuring pads a box while under-measuring spills the label out of it.
|
||||
const VARIATION_SELECTOR_FIRST = 0xfe00;
|
||||
const VARIATION_SELECTOR_LAST = 0xfe0f;
|
||||
const VARIATION_SELECTOR_TEXT = 0xfe0e;
|
||||
const VARIATION_SELECTOR_EMOJI = 0xfe0f;
|
||||
|
||||
export function textUnits(text) {
|
||||
const chars = Array.from(String(text ?? ''));
|
||||
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 if (next === VARIATION_SELECTOR_TEXT) units += 1;
|
||||
else units += FULLWIDTH_RE.test(chars[i]) ? 2 : 1;
|
||||
}
|
||||
return units;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
# 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 `meta.output` from the JSON file
|
||||
or falls back to `workflow.html` in the current working directory.
|
||||
|
||||
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"
|
||||
},
|
||||
"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.
|
||||
|
||||
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 |
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
- 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. 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 for fixed lane gaps.
|
||||
@@ -0,0 +1,35 @@
|
||||
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 } = 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,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
const TARGET_SCHEMA_VERSION = 2;
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the authored workflow as a schema-v2 document without its capacity
|
||||
* override. The compiler can use this projection to discover the intrinsic v2
|
||||
* rank plan before deciding whether an explicit viewBox needs to grow.
|
||||
*/
|
||||
export function intrinsicWorkflow(workflow) {
|
||||
const intrinsic = clone(workflow);
|
||||
intrinsic.schema_version = TARGET_SCHEMA_VERSION;
|
||||
intrinsic.meta = { ...intrinsic.meta };
|
||||
delete intrinsic.meta.viewBox;
|
||||
return intrinsic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a schema-v2 planning projection that removes authored route geometry
|
||||
* which may only become valid after its legacy X coordinates are remapped.
|
||||
* Rank-affecting automatic and straight relationships remain in the projection.
|
||||
*/
|
||||
export function planningWorkflow(workflow) {
|
||||
const planned = intrinsicWorkflow(workflow);
|
||||
planned.edges = planned.edges.flatMap((edge) => {
|
||||
const hasRoutedGeometry = Array.isArray(edge.via)
|
||||
|| (edge.route && !['auto', 'straight'].includes(edge.route))
|
||||
|| edge.channelX !== undefined
|
||||
|| edge.channelY !== undefined;
|
||||
if (hasRoutedGeometry) return [];
|
||||
|
||||
const automatic = {};
|
||||
for (const property of ['id', 'from', 'to', 'variant', 'role', 'width']) {
|
||||
if (edge[property] !== undefined) automatic[property] = edge[property];
|
||||
}
|
||||
if (edge.route === 'straight') automatic.route = 'straight';
|
||||
if (edge.labelAt === undefined && edge.label !== undefined) automatic.label = edge.label;
|
||||
return [automatic];
|
||||
});
|
||||
|
||||
if (Array.isArray(planned.mainPath)) {
|
||||
const projectedPairs = new Set(planned.edges.map((edge) => `${edge.from}\u0000${edge.to}`));
|
||||
const projectionBreaksMainPath = planned.mainPath.some((from, index) => (
|
||||
index < planned.mainPath.length - 1
|
||||
&& !projectedPairs.has(`${from}\u0000${planned.mainPath[index + 1]}`)
|
||||
));
|
||||
if (projectionBreaksMainPath) delete planned.mainPath;
|
||||
}
|
||||
|
||||
return planned;
|
||||
}
|
||||
|
||||
function mappedNumber(value) {
|
||||
return Number(value.toFixed(6));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a deterministic piecewise-linear mapping between corresponding legacy
|
||||
* and readable rank centers. Coordinates outside the rank span are extrapolated
|
||||
* using the nearest segment so explicitly authored outside corridors retain
|
||||
* their relative offset.
|
||||
*/
|
||||
export function createHorizontalRankMapper(oldColumns, newColumns) {
|
||||
if (
|
||||
!Array.isArray(oldColumns)
|
||||
|| !Array.isArray(newColumns)
|
||||
|| oldColumns.length !== newColumns.length
|
||||
|| oldColumns.length < 2
|
||||
|| !oldColumns.every(Number.isFinite)
|
||||
|| !newColumns.every(Number.isFinite)
|
||||
) {
|
||||
throw new TypeError('Horizontal rank mapping requires matching finite column arrays.');
|
||||
}
|
||||
for (let index = 1; index < oldColumns.length; index += 1) {
|
||||
if (oldColumns[index] <= oldColumns[index - 1] || newColumns[index] <= newColumns[index - 1]) {
|
||||
throw new TypeError('Horizontal rank mapping requires strictly increasing columns.');
|
||||
}
|
||||
}
|
||||
|
||||
return (x) => {
|
||||
if (!Number.isFinite(x)) throw new TypeError('Horizontal rank mapping requires a finite x coordinate.');
|
||||
let segment = oldColumns.length - 2;
|
||||
if (x <= oldColumns[0]) {
|
||||
segment = 0;
|
||||
} else {
|
||||
for (let index = 0; index < oldColumns.length - 1; index += 1) {
|
||||
if (x <= oldColumns[index + 1]) {
|
||||
segment = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const oldSpan = oldColumns[segment + 1] - oldColumns[segment];
|
||||
const newSpan = newColumns[segment + 1] - newColumns[segment];
|
||||
const ratio = (x - oldColumns[segment]) / oldSpan;
|
||||
return mappedNumber(newColumns[segment] + ratio * newSpan);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one horizontal coordinate mapping to every schema-v1 absolute X pin.
|
||||
* The caller owns the supplied workflow; this function reports an audit trail
|
||||
* for each changed coordinate in stable document order.
|
||||
*/
|
||||
export function mapExplicitCoordinates(workflow, mapX) {
|
||||
const changedCoordinates = [];
|
||||
const record = (path, owner, property) => {
|
||||
const from = owner[property];
|
||||
const to = mapX(from);
|
||||
owner[property] = to;
|
||||
if (to !== from) changedCoordinates.push({ path, from, to });
|
||||
};
|
||||
|
||||
for (const [edgeIndex, edge] of workflow.edges.entries()) {
|
||||
if (Array.isArray(edge.via)) {
|
||||
for (const [pointIndex, point] of edge.via.entries()) {
|
||||
if (Array.isArray(point) && Number.isFinite(point[0])) {
|
||||
record(`/edges/${edgeIndex}/via/${pointIndex}/0`, point, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(edge.labelAt) && Number.isFinite(edge.labelAt[0])) {
|
||||
record(`/edges/${edgeIndex}/labelAt/0`, edge.labelAt, 0);
|
||||
}
|
||||
if (Number.isFinite(edge.channelX)) {
|
||||
record(`/edges/${edgeIndex}/channelX`, edge, 'channelX');
|
||||
}
|
||||
}
|
||||
return changedCoordinates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct an independently owned schema-v2 candidate with all authored
|
||||
* absolute X pins mapped to the readable rank plan.
|
||||
*/
|
||||
export function createMappedWorkflowCandidate(workflow, oldColumns, newColumns) {
|
||||
const document = clone(workflow);
|
||||
document.schema_version = TARGET_SCHEMA_VERSION;
|
||||
const mapX = createHorizontalRankMapper(oldColumns, newColumns);
|
||||
const changedCoordinates = mapExplicitCoordinates(document, mapX);
|
||||
return { document, changedCoordinates };
|
||||
}
|
||||
Reference in New Issue
Block a user