Archify Diagram Viewer 0.1.0
VS Code extension that previews Archify diagrams from their JSON sources (live, as you type) and opens rendered Archify HTML in a viewer tab. Bundles the Archify 3.0.1 renderer and runs it on VS Code's Node runtime. Adds validation diagnostics, JSON schema help, source-link navigation, export saving, render-to-file and open-in-browser commands. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
out/
|
||||
*.vsix
|
||||
.DS_Store
|
||||
.vscode-test/
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Run Extension",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": ["--extensionDevelopmentPath=${workspaceFolder}", "${workspaceFolder}/test/fixtures"],
|
||||
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
|
||||
"preLaunchTask": "npm: build"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "build",
|
||||
"group": "build",
|
||||
"problemMatcher": "$esbuild"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
src/**
|
||||
test/**
|
||||
out/**
|
||||
scripts/**
|
||||
node_modules/**
|
||||
.vscode/**
|
||||
.gitignore
|
||||
esbuild.mjs
|
||||
tsconfig.json
|
||||
**/*.map
|
||||
**/*.ts
|
||||
.vscode-test/**
|
||||
@@ -0,0 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0 — 2026-09-30
|
||||
|
||||
- Live preview for Archify JSON sources (architecture, workflow, sequence, dataflow, lifecycle), re-rendered as you type.
|
||||
- Read-only Archify viewer for rendered `.html` files, reloaded when the file changes.
|
||||
- Problems panel diagnostics from `archify validate`, mapped to the JSON location.
|
||||
- JSON schema completion and hover for `*.architecture.json`, `*.workflow.json`, `*.sequence.json`, `*.dataflow.json` and `*.lifecycle.json`.
|
||||
- Source-evidence links open the referenced file and lines in the editor.
|
||||
- Viewer exports (PNG, SVG, share card, …) save through a VS Code save dialog.
|
||||
- Render to HTML file and open in browser commands.
|
||||
- Bundles the Archify 3.0.1 renderer; runs on VS Code's own Node runtime.
|
||||
@@ -0,0 +1,28 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 VSCode Extentions (gitea.lego-cloud.eu/vscode-extensions)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
vendor/archify/ contains the Archify renderer, Copyright (c) 2026 tt-a1i
|
||||
(Archify), MIT License. See vendor/archify/LICENSE and
|
||||
vendor/archify/THIRD_PARTY_NOTICES.md. schemas/ is derived from the same
|
||||
project.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Archify Diagram Viewer
|
||||
|
||||
View [Archify](https://github.com/tt-a1i/archify) diagrams inside VS Code. Open a diagram's JSON source and see the interactive diagram next to it, updated as you type. Open rendered Archify HTML files in a viewer tab instead of a browser.
|
||||
|
||||
This is an unofficial extension. It bundles the Archify renderer (MIT, © tt-a1i) and runs it on VS Code's own Node runtime, so nothing else needs to be installed.
|
||||
|
||||
## Features
|
||||
|
||||
- **Live preview**: for `*.architecture.json`, `*.workflow.json`, `*.sequence.json`, `*.dataflow.json` and `*.lifecycle.json` files, or any JSON with a `diagram_type`. Use the preview button in the editor title, <kbd>Cmd/Ctrl+K V</kbd> (to the side) or <kbd>Cmd/Ctrl+Shift+V</kbd>. The preview keeps the focus and route you selected (`#focus=…`, `#route=…`) when it re-renders.
|
||||
- **HTML viewer**: right-click a rendered `.html` file and choose **Open in Archify Viewer**, or use *Reopen Editor With… → Archify Diagram Viewer*. For Archify HTML already open as text, a button appears in the editor title. The viewer reloads when the file changes on disk.
|
||||
- **Problems**: `archify validate` runs on the source and reports schema, layout and evidence problems at the matching JSON location.
|
||||
- **Schema help**: completion and hover documentation come from Archify's own JSON schemas.
|
||||
- **Source links**: in source-backed diagrams, clicking a source reference opens that file and line range in the editor. If the file is not in the workspace, the web link opens instead.
|
||||
- **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog.
|
||||
- **Commands**: *Archify: Render to HTML File…*, *Archify: Open in Browser*, *Archify: Refresh Preview*, *Archify: Show Source*.
|
||||
|
||||
## Source evidence
|
||||
|
||||
A diagram that declares `meta.repository` and `sources` is verified against a local Git checkout whose `origin` and pinned commit match. The extension uses the Git top-level directory of the diagram file, or `archify.repoRoot` when set.
|
||||
|
||||
When verification fails, the default (`archify.sourceEvidence: "fallback"`) still shows the diagram without source links, explains why in a notice, and reports the reason as a warning. Set it to `"strict"` to treat this as an error, as the Archify CLI does. *Render to HTML File…* always verifies strictly and asks before rendering without source links.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---|---|---|
|
||||
| `archify.theme` | `vscode` | `vscode` follows the color theme; `dark` or `light` force one; `diagram` keeps the diagram's own choice. |
|
||||
| `archify.preview.liveUpdate` | `true` | Re-render while typing; when off, re-render on save. |
|
||||
| `archify.preview.debounceMs` | `400` | Delay before re-rendering after an edit. |
|
||||
| `archify.validation.enabled` | `true` | Report validation results in Problems. |
|
||||
| `archify.quality` | `""` | Force the `standard` or `showcase` quality profile. |
|
||||
| `archify.sourceEvidence` | `fallback` | `fallback` or `strict` handling of unverifiable source evidence. |
|
||||
| `archify.repoRoot` | `""` | Checkout used to verify source evidence. |
|
||||
| `archify.nodePath` | `""` | Node.js 18+ executable to use instead of VS Code's runtime. |
|
||||
|
||||
## Security
|
||||
|
||||
Diagrams run in a webview with a strict content security policy. Only the page's own inline scripts run (via a per-render nonce), there is no network access for scripts or fetches, and links open through VS Code.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # bundle to dist/
|
||||
npm test # unit tests + renderer tests (Node)
|
||||
npm run test:integration # runs the suite inside VS Code
|
||||
npm run package # build archify-vscode-ext-<version>.vsix
|
||||
```
|
||||
|
||||
Press <kbd>F5</kbd> to launch an Extension Development Host with the test fixtures.
|
||||
|
||||
### Updating the bundled renderer
|
||||
|
||||
`vendor/archify/` and `schemas/` are generated from an Archify checkout:
|
||||
|
||||
```bash
|
||||
node scripts/sync-archify.mjs /path/to/archify # default: ../../ws-storm/archify
|
||||
npm test
|
||||
```
|
||||
|
||||
`vendor/archify.json` records the upstream version and commit.
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE). The bundled renderer is MIT-licensed by tt-a1i; see `vendor/archify/LICENSE` and `vendor/archify/THIRD_PARTY_NOTICES.md`.
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import * as esbuild from 'esbuild';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const production = process.argv.includes('--production');
|
||||
const watch = process.argv.includes('--watch');
|
||||
const tests = process.argv.includes('--tests');
|
||||
|
||||
const common = {
|
||||
bundle: true,
|
||||
format: 'cjs',
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
sourcemap: !production,
|
||||
minify: production,
|
||||
logLevel: 'info',
|
||||
// jsonc-parser's UMD entry uses dynamic requires esbuild cannot follow.
|
||||
mainFields: ['module', 'main'],
|
||||
};
|
||||
|
||||
if (tests) {
|
||||
// Unit tests cover the modules that do not import 'vscode'; the
|
||||
// integration suite runs inside VS Code, which provides it.
|
||||
const entryPoints = fs.readdirSync('test')
|
||||
.filter((file) => file.endsWith('.test.ts'))
|
||||
.map((file) => path.join('test', file));
|
||||
await esbuild.build({ ...common, entryPoints, outdir: 'out/test', sourcemap: true });
|
||||
await esbuild.build({
|
||||
...common,
|
||||
entryPoints: ['test/integration/suite.ts'],
|
||||
outfile: 'out/test/integration/suite.js',
|
||||
external: ['vscode'],
|
||||
sourcemap: true,
|
||||
});
|
||||
} else {
|
||||
const ctx = await esbuild.context({
|
||||
...common,
|
||||
entryPoints: ['src/extension.ts'],
|
||||
outfile: 'dist/extension.js',
|
||||
external: ['vscode'],
|
||||
});
|
||||
if (watch) {
|
||||
await ctx.watch();
|
||||
} else {
|
||||
await ctx.rebuild();
|
||||
await ctx.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="#7aa2f7" stroke-width="1.3" stroke-linejoin="round"><rect x="1.5" y="2" width="4.5" height="3.5" rx="0.8"/><rect x="10" y="2" width="4.5" height="3.5" rx="0.8"/><rect x="5.75" y="10.5" width="4.5" height="3.5" rx="0.8"/><path d="M3.75 5.5v2.5h8.5V5.5M8 8v2.5"/></svg>
|
||||
|
After Width: | Height: | Size: 371 B |
Generated
+4710
File diff suppressed because it is too large
Load Diff
+315
@@ -0,0 +1,315 @@
|
||||
{
|
||||
"name": "archify-vscode-ext",
|
||||
"displayName": "Archify Diagram Viewer",
|
||||
"description": "Preview Archify diagrams (architecture, workflow, sequence, dataflow, lifecycle) from their JSON sources and open rendered Archify HTML inside VS Code.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "vscode-extensions",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext.git"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.100.0"
|
||||
},
|
||||
"categories": [
|
||||
"Visualization",
|
||||
"Programming Languages"
|
||||
],
|
||||
"keywords": [
|
||||
"archify",
|
||||
"diagram",
|
||||
"architecture",
|
||||
"sequence",
|
||||
"workflow"
|
||||
],
|
||||
"main": "./dist/extension.js",
|
||||
"activationEvents": [
|
||||
"onLanguage:json",
|
||||
"onLanguage:jsonc",
|
||||
"onLanguage:html",
|
||||
"workspaceContains:**/*.{architecture,workflow,sequence,dataflow,lifecycle}.json",
|
||||
"onWebviewPanel:archify.preview"
|
||||
],
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "archify.showPreview",
|
||||
"title": "Open Preview",
|
||||
"category": "Archify",
|
||||
"icon": "$(open-preview)"
|
||||
},
|
||||
{
|
||||
"command": "archify.showPreviewToSide",
|
||||
"title": "Open Preview to the Side",
|
||||
"category": "Archify",
|
||||
"icon": "$(open-preview)"
|
||||
},
|
||||
{
|
||||
"command": "archify.openHtmlViewer",
|
||||
"title": "Open in Archify Viewer",
|
||||
"category": "Archify",
|
||||
"icon": "$(open-preview)"
|
||||
},
|
||||
{
|
||||
"command": "archify.renderToHtml",
|
||||
"title": "Render to HTML File\u2026",
|
||||
"category": "Archify",
|
||||
"icon": "$(file-code)"
|
||||
},
|
||||
{
|
||||
"command": "archify.openInBrowser",
|
||||
"title": "Open in Browser",
|
||||
"category": "Archify",
|
||||
"icon": "$(globe)"
|
||||
},
|
||||
{
|
||||
"command": "archify.refreshPreview",
|
||||
"title": "Refresh Preview",
|
||||
"category": "Archify",
|
||||
"icon": "$(refresh)"
|
||||
},
|
||||
{
|
||||
"command": "archify.showSource",
|
||||
"title": "Show Source",
|
||||
"category": "Archify",
|
||||
"icon": "$(go-to-file)"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
"commandPalette": [
|
||||
{
|
||||
"command": "archify.showPreview",
|
||||
"when": "archify.isDiagramSource || archify.isArchifyHtml"
|
||||
},
|
||||
{
|
||||
"command": "archify.showPreviewToSide",
|
||||
"when": "archify.isDiagramSource"
|
||||
},
|
||||
{
|
||||
"command": "archify.openHtmlViewer",
|
||||
"when": "resourceExtname == .html"
|
||||
},
|
||||
{
|
||||
"command": "archify.renderToHtml",
|
||||
"when": "archify.isDiagramSource || archifyPreviewFocus"
|
||||
},
|
||||
{
|
||||
"command": "archify.openInBrowser",
|
||||
"when": "archify.isDiagramSource || archify.isArchifyHtml || archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer'"
|
||||
},
|
||||
{
|
||||
"command": "archify.refreshPreview",
|
||||
"when": "archifyPreviewFocus"
|
||||
},
|
||||
{
|
||||
"command": "archify.showSource",
|
||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer'"
|
||||
}
|
||||
],
|
||||
"editor/title": [
|
||||
{
|
||||
"command": "archify.showPreviewToSide",
|
||||
"when": "archify.isDiagramSource && !notebookEditorFocused",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
"command": "archify.openHtmlViewer",
|
||||
"when": "archify.isArchifyHtml && activeCustomEditorId != 'archify.htmlViewer'",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
"command": "archify.refreshPreview",
|
||||
"when": "archifyPreviewFocus",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
"command": "archify.showSource",
|
||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer'",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
"command": "archify.openInBrowser",
|
||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer'",
|
||||
"group": "1_archify"
|
||||
},
|
||||
{
|
||||
"command": "archify.renderToHtml",
|
||||
"when": "archify.isDiagramSource || archifyPreviewFocus",
|
||||
"group": "1_archify"
|
||||
}
|
||||
],
|
||||
"explorer/context": [
|
||||
{
|
||||
"command": "archify.showPreview",
|
||||
"when": "resourceFilename =~ /\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json$/",
|
||||
"group": "navigation@20"
|
||||
},
|
||||
{
|
||||
"command": "archify.renderToHtml",
|
||||
"when": "resourceFilename =~ /\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json$/",
|
||||
"group": "navigation@21"
|
||||
},
|
||||
{
|
||||
"command": "archify.openHtmlViewer",
|
||||
"when": "resourceExtname == .html",
|
||||
"group": "navigation@20"
|
||||
}
|
||||
],
|
||||
"editor/title/context": [
|
||||
{
|
||||
"command": "archify.showPreview",
|
||||
"when": "resourceFilename =~ /\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json$/",
|
||||
"group": "1_open"
|
||||
}
|
||||
]
|
||||
},
|
||||
"keybindings": [
|
||||
{
|
||||
"command": "archify.showPreviewToSide",
|
||||
"key": "ctrl+k v",
|
||||
"mac": "cmd+k v",
|
||||
"when": "editorTextFocus && archify.isDiagramSource"
|
||||
},
|
||||
{
|
||||
"command": "archify.showPreview",
|
||||
"key": "ctrl+shift+v",
|
||||
"mac": "cmd+shift+v",
|
||||
"when": "editorTextFocus && archify.isDiagramSource"
|
||||
}
|
||||
],
|
||||
"customEditors": [
|
||||
{
|
||||
"viewType": "archify.htmlViewer",
|
||||
"displayName": "Archify Diagram Viewer",
|
||||
"selector": [
|
||||
{
|
||||
"filenamePattern": "*.html"
|
||||
}
|
||||
],
|
||||
"priority": "option"
|
||||
}
|
||||
],
|
||||
"jsonValidation": [
|
||||
{
|
||||
"fileMatch": "*.architecture.json",
|
||||
"url": "./schemas/architecture.schema.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": "*.workflow.json",
|
||||
"url": "./schemas/workflow.schema.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": "*.sequence.json",
|
||||
"url": "./schemas/sequence.schema.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": "*.dataflow.json",
|
||||
"url": "./schemas/dataflow.schema.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": "*.lifecycle.json",
|
||||
"url": "./schemas/lifecycle.schema.json"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
"title": "Archify",
|
||||
"properties": {
|
||||
"archify.theme": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"vscode",
|
||||
"dark",
|
||||
"light",
|
||||
"diagram"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"Follow the VS Code color theme.",
|
||||
"Always use the dark diagram theme.",
|
||||
"Always use the light diagram theme.",
|
||||
"Keep whatever the diagram or its toolbar chooses."
|
||||
],
|
||||
"default": "vscode",
|
||||
"description": "Theme used when showing Archify diagrams."
|
||||
},
|
||||
"archify.preview.liveUpdate": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Re-render the preview while you type. When off, the preview updates on save."
|
||||
},
|
||||
"archify.preview.debounceMs": {
|
||||
"type": "number",
|
||||
"default": 400,
|
||||
"minimum": 50,
|
||||
"description": "Delay in milliseconds before re-rendering after an edit."
|
||||
},
|
||||
"archify.validation.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Report Archify validation results (schema, layout and source evidence) in the Problems panel."
|
||||
},
|
||||
"archify.quality": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"",
|
||||
"standard",
|
||||
"showcase"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"Use the quality profile declared in the diagram.",
|
||||
"Force the standard quality gate.",
|
||||
"Force the showcase quality gate."
|
||||
],
|
||||
"default": "",
|
||||
"description": "Quality profile passed to the Archify renderer."
|
||||
},
|
||||
"archify.sourceEvidence": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"fallback",
|
||||
"strict"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"If source evidence cannot be verified, still show the diagram without source links and explain why.",
|
||||
"Treat unverifiable source evidence as a render error, exactly like the Archify CLI."
|
||||
],
|
||||
"default": "fallback",
|
||||
"description": "How to handle diagrams whose repository source evidence cannot be verified locally."
|
||||
},
|
||||
"archify.repoRoot": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Git checkout used to verify source evidence. Empty means the Git top-level directory of the diagram file."
|
||||
},
|
||||
"archify.nodePath": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Node.js executable (18 or newer) used to run the renderer. Empty means the runtime bundled with VS Code."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"sync:archify": "node scripts/sync-archify.mjs",
|
||||
"build": "node esbuild.mjs",
|
||||
"watch": "node esbuild.mjs --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node esbuild.mjs --tests && node --test \"out/test/*.test.js\"",
|
||||
"vscode:prepublish": "node esbuild.mjs --production",
|
||||
"package": "vsce package --no-dependencies --baseContentUrl https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext/src/branch/main --baseImagesUrl https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext/raw/branch/main",
|
||||
"test:integration": "node esbuild.mjs && node esbuild.mjs --tests && node test/integration/run.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/vscode": "~1.100.0",
|
||||
"@vscode/test-electron": "^3.1.0",
|
||||
"@vscode/vsce": "^3.0.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"typescript": "^5.8.0"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext/issues"
|
||||
},
|
||||
"homepage": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext#readme"
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Archify Architecture Diagram",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"diagram_type",
|
||||
"meta",
|
||||
"components"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": 1
|
||||
},
|
||||
"diagram_type": {
|
||||
"const": "architecture"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"title",
|
||||
"output"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"locale": {
|
||||
"$ref": "common.schema.json#/$defs/locale"
|
||||
},
|
||||
"translations": {
|
||||
"$ref": "common.schema.json#/$defs/translations"
|
||||
},
|
||||
"subtitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"output": {
|
||||
"$ref": "common.schema.json#/$defs/portableOutputPath"
|
||||
},
|
||||
"animation": {
|
||||
"$ref": "common.schema.json#/$defs/animation"
|
||||
},
|
||||
"visual_preset": {
|
||||
"$ref": "common.schema.json#/$defs/visualPreset"
|
||||
},
|
||||
"quality_profile": {
|
||||
"$ref": "common.schema.json#/$defs/qualityProfile"
|
||||
},
|
||||
"engineering_profile": {
|
||||
"enum": [
|
||||
"deployment-ownership"
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
"$ref": "common.schema.json#/$defs/repository"
|
||||
},
|
||||
"views": {
|
||||
"$ref": "common.schema.json#/$defs/guidedViews"
|
||||
},
|
||||
"legend": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": {
|
||||
"$ref": "common.schema.json#/$defs/legendMode"
|
||||
},
|
||||
"entries": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"frontend": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"backend": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"database": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"cloud": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"security": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"messagebus": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"external": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewBox": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 320
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 240
|
||||
}
|
||||
],
|
||||
"items": false,
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"mode"
|
||||
],
|
||||
"properties": {
|
||||
"mode": {
|
||||
"enum": [
|
||||
"grid"
|
||||
]
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "common.schema.json#/$defs/point"
|
||||
},
|
||||
"cols": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 12
|
||||
},
|
||||
"gapX": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"gapY": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"cellW": {
|
||||
"type": "number",
|
||||
"minimum": 40
|
||||
},
|
||||
"cellH": {
|
||||
"type": "number",
|
||||
"minimum": 24
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "common.schema.json#/$defs/componentType"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sublabel": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string"
|
||||
},
|
||||
"icon": {
|
||||
"$ref": "common.schema.json#/$defs/nodeIcon"
|
||||
},
|
||||
"brand": {
|
||||
"$ref": "common.schema.json#/$defs/brandMark"
|
||||
},
|
||||
"sources": {
|
||||
"$ref": "common.schema.json#/$defs/sourceReferences"
|
||||
},
|
||||
"row": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"col": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"pos": {
|
||||
"$ref": "common.schema.json#/$defs/point"
|
||||
},
|
||||
"size": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0
|
||||
}
|
||||
],
|
||||
"items": false,
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"boundaries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"kind",
|
||||
"label",
|
||||
"wraps"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": [
|
||||
"region",
|
||||
"security-group"
|
||||
]
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"wraps": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
}
|
||||
},
|
||||
"pad": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"from",
|
||||
"to"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"from": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"to": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"$ref": "common.schema.json#/$defs/variant"
|
||||
},
|
||||
"fromSide": {
|
||||
"$ref": "common.schema.json#/$defs/side"
|
||||
},
|
||||
"toSide": {
|
||||
"$ref": "common.schema.json#/$defs/side"
|
||||
},
|
||||
"route": {
|
||||
"enum": [
|
||||
"auto",
|
||||
"straight",
|
||||
"orthogonal-h",
|
||||
"orthogonal-v"
|
||||
]
|
||||
},
|
||||
"via": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/point"
|
||||
}
|
||||
},
|
||||
"labelAt": {
|
||||
"$ref": "common.schema.json#/$defs/point"
|
||||
},
|
||||
"labelDx": {
|
||||
"type": "number"
|
||||
},
|
||||
"labelDy": {
|
||||
"type": "number"
|
||||
},
|
||||
"labelSegment": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"width": {
|
||||
"$ref": "common.schema.json#/$defs/relationshipWidth"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cards": {
|
||||
"$ref": "common.schema.json#/$defs/cards"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Archify Shared Definitions",
|
||||
"$defs": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$"
|
||||
},
|
||||
"locale": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$",
|
||||
"maxLength": 35
|
||||
},
|
||||
"translations": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"pattern": "^[a-z][a-zA-Z0-9]*(\\.[a-z][a-zA-Z0-9]*)*$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 400
|
||||
},
|
||||
"maxProperties": 600
|
||||
},
|
||||
"animation": {
|
||||
"enum": [
|
||||
"trace",
|
||||
"none"
|
||||
]
|
||||
},
|
||||
"visualPreset": {
|
||||
"enum": [
|
||||
"classic",
|
||||
"signal-flow",
|
||||
"blueprint",
|
||||
"editorial"
|
||||
]
|
||||
},
|
||||
"qualityProfile": {
|
||||
"enum": [
|
||||
"standard",
|
||||
"showcase"
|
||||
]
|
||||
},
|
||||
"portableOutputPath": {
|
||||
"description": "A portable POSIX-relative HTML output path. It must be safe to interpret identically on every supported host filesystem.",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[^/]+(?:/[^/]+)*$",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "(?:^|/)[^/]+[.][Hh][Tt][Mm][Ll]$"
|
||||
},
|
||||
{
|
||||
"not": {
|
||||
"pattern": "[\\\\<>:\"|?*\\u0000-\\u001F\\u007F-\\u009F]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"not": {
|
||||
"pattern": "[\\uD800-\\uDFFF]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"not": {
|
||||
"pattern": "(^|/)[.]{1,2}(/|$)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"not": {
|
||||
"pattern": "[. ](/|$)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"not": {
|
||||
"pattern": "(^|/)[^/]{256}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"not": {
|
||||
"pattern": "(^|/)(?:[Cc][Oo][Nn]|[Pp][Rr][Nn]|[Aa][Uu][Xx]|[Nn][Uu][Ll]|[Cc][Oo][Nn][Ii][Nn][$]|[Cc][Oo][Nn][Oo][Uu][Tt][$]|[Cc][Oo][Mm][1-9¹²³]|[Ll][Pp][Tt][1-9¹²³])(?:[.]|/|$)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"not": {
|
||||
"pattern": "(^|/)[^/]*~[1-9][0-9]*(?:[.]|/|$)"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"side": {
|
||||
"enum": [
|
||||
"left",
|
||||
"right",
|
||||
"top",
|
||||
"bottom"
|
||||
]
|
||||
},
|
||||
"relationshipWidth": {
|
||||
"type": "number",
|
||||
"minimum": 0.5
|
||||
},
|
||||
"point": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
}
|
||||
],
|
||||
"items": false,
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
},
|
||||
"componentType": {
|
||||
"enum": [
|
||||
"frontend",
|
||||
"backend",
|
||||
"database",
|
||||
"cloud",
|
||||
"security",
|
||||
"messagebus",
|
||||
"external"
|
||||
]
|
||||
},
|
||||
"nodeIcon": {
|
||||
"description": "Decorative corner icon. Omit the icon field for the type default; none hides it. Does not change type, color, or legend grouping.",
|
||||
"enum": [
|
||||
"calendar",
|
||||
"clock",
|
||||
"person",
|
||||
"briefcase",
|
||||
"flag",
|
||||
"moon",
|
||||
"frontend",
|
||||
"backend",
|
||||
"database",
|
||||
"cloud",
|
||||
"security",
|
||||
"messagebus",
|
||||
"external",
|
||||
"start",
|
||||
"active",
|
||||
"waiting",
|
||||
"success",
|
||||
"failure",
|
||||
"neutral",
|
||||
"none"
|
||||
]
|
||||
},
|
||||
"brandMark": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 2048,
|
||||
"anyOf": [
|
||||
{
|
||||
"maxLength": 80,
|
||||
"pattern": "^[^\\r\\n]+$"
|
||||
},
|
||||
{
|
||||
"pattern": "^https?://"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"url",
|
||||
"sha256"
|
||||
],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 8,
|
||||
"maxLength": 2048,
|
||||
"pattern": "^https?://"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"variant": {
|
||||
"enum": [
|
||||
"default",
|
||||
"emphasis",
|
||||
"security",
|
||||
"dashed"
|
||||
]
|
||||
},
|
||||
"legendMode": {
|
||||
"enum": [
|
||||
"auto",
|
||||
"all",
|
||||
"hidden"
|
||||
]
|
||||
},
|
||||
"legendEntry": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
},
|
||||
"visible": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"guidedViews": {
|
||||
"type": "array",
|
||||
"maxItems": 5,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"label",
|
||||
"focus"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 48
|
||||
},
|
||||
"focus": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/id"
|
||||
}
|
||||
},
|
||||
"note": {
|
||||
"type": "string",
|
||||
"maxLength": 140
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"url",
|
||||
"revision"
|
||||
],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"provider": {
|
||||
"enum": [
|
||||
"github",
|
||||
"gitee"
|
||||
]
|
||||
},
|
||||
"link_mode": {
|
||||
"enum": [
|
||||
"web",
|
||||
"local-only"
|
||||
]
|
||||
},
|
||||
"revision": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-fA-F0-9]{40}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sourceReferences": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 3,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"path"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"line": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"end_line": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 48
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cards": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"dot",
|
||||
"title",
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"dot": {
|
||||
"enum": [
|
||||
"cyan",
|
||||
"emerald",
|
||||
"violet",
|
||||
"amber",
|
||||
"rose",
|
||||
"orange",
|
||||
"slate"
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Archify Data Flow Diagram",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"diagram_type",
|
||||
"meta",
|
||||
"stages",
|
||||
"nodes",
|
||||
"flows"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": 1
|
||||
},
|
||||
"diagram_type": {
|
||||
"const": "dataflow"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"title",
|
||||
"output"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"locale": {
|
||||
"$ref": "common.schema.json#/$defs/locale"
|
||||
},
|
||||
"translations": {
|
||||
"$ref": "common.schema.json#/$defs/translations"
|
||||
},
|
||||
"subtitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"output": {
|
||||
"$ref": "common.schema.json#/$defs/portableOutputPath"
|
||||
},
|
||||
"animation": {
|
||||
"$ref": "common.schema.json#/$defs/animation"
|
||||
},
|
||||
"visual_preset": {
|
||||
"$ref": "common.schema.json#/$defs/visualPreset"
|
||||
},
|
||||
"quality_profile": {
|
||||
"$ref": "common.schema.json#/$defs/qualityProfile"
|
||||
},
|
||||
"repository": {
|
||||
"$ref": "common.schema.json#/$defs/repository"
|
||||
},
|
||||
"views": {
|
||||
"$ref": "common.schema.json#/$defs/guidedViews"
|
||||
},
|
||||
"legend": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": {
|
||||
"$ref": "common.schema.json#/$defs/legendMode"
|
||||
},
|
||||
"entries": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"default": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"emphasis": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"security": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"dashed": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"database": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewBox": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 360
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 360
|
||||
}
|
||||
],
|
||||
"items": false,
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"stages": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"maxItems": 5,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"label",
|
||||
"stage",
|
||||
"row"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "common.schema.json#/$defs/componentType"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sublabel": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string"
|
||||
},
|
||||
"icon": {
|
||||
"$ref": "common.schema.json#/$defs/nodeIcon"
|
||||
},
|
||||
"brand": {
|
||||
"$ref": "common.schema.json#/$defs/brandMark"
|
||||
},
|
||||
"sources": {
|
||||
"$ref": "common.schema.json#/$defs/sourceReferences"
|
||||
},
|
||||
"stage": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"row": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"width": {
|
||||
"type": "number",
|
||||
"minimum": 48
|
||||
},
|
||||
"height": {
|
||||
"type": "number",
|
||||
"minimum": 36
|
||||
},
|
||||
"yOffset": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"flows": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"from",
|
||||
"to",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"from": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"to": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"classification": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"$ref": "common.schema.json#/$defs/variant"
|
||||
},
|
||||
"route": {
|
||||
"enum": [
|
||||
"auto",
|
||||
"straight",
|
||||
"vertical-channel",
|
||||
"bottom-channel",
|
||||
"top-channel"
|
||||
]
|
||||
},
|
||||
"fromSide": {
|
||||
"$ref": "common.schema.json#/$defs/side"
|
||||
},
|
||||
"toSide": {
|
||||
"$ref": "common.schema.json#/$defs/side"
|
||||
},
|
||||
"channelX": {
|
||||
"type": "number"
|
||||
},
|
||||
"channelY": {
|
||||
"type": "number"
|
||||
},
|
||||
"labelAt": {
|
||||
"$ref": "common.schema.json#/$defs/point"
|
||||
},
|
||||
"labelDx": {
|
||||
"type": "number"
|
||||
},
|
||||
"labelDy": {
|
||||
"type": "number"
|
||||
},
|
||||
"labelSegment": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"via": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/point"
|
||||
}
|
||||
},
|
||||
"width": {
|
||||
"$ref": "common.schema.json#/$defs/relationshipWidth"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cards": {
|
||||
"$ref": "common.schema.json#/$defs/cards"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Archify Lifecycle Diagram",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"diagram_type",
|
||||
"meta",
|
||||
"lanes",
|
||||
"states",
|
||||
"transitions"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"enum": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
"diagram_type": {
|
||||
"const": "lifecycle"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"title",
|
||||
"output"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"locale": {
|
||||
"$ref": "common.schema.json#/$defs/locale"
|
||||
},
|
||||
"translations": {
|
||||
"$ref": "common.schema.json#/$defs/translations"
|
||||
},
|
||||
"subtitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"output": {
|
||||
"$ref": "common.schema.json#/$defs/portableOutputPath"
|
||||
},
|
||||
"animation": {
|
||||
"$ref": "common.schema.json#/$defs/animation"
|
||||
},
|
||||
"visual_preset": {
|
||||
"$ref": "common.schema.json#/$defs/visualPreset"
|
||||
},
|
||||
"quality_profile": {
|
||||
"$ref": "common.schema.json#/$defs/qualityProfile"
|
||||
},
|
||||
"repository": {
|
||||
"$ref": "common.schema.json#/$defs/repository"
|
||||
},
|
||||
"views": {
|
||||
"$ref": "common.schema.json#/$defs/guidedViews"
|
||||
},
|
||||
"legend": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": {
|
||||
"$ref": "common.schema.json#/$defs/legendMode"
|
||||
},
|
||||
"entries": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"start": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"active": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"waiting": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"decision": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"success": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"failure": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"neutral": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"external": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewBox": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 420
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 300
|
||||
}
|
||||
],
|
||||
"items": false,
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"lanes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 4,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"states": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"label",
|
||||
"lane",
|
||||
"col"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"start",
|
||||
"active",
|
||||
"waiting",
|
||||
"decision",
|
||||
"success",
|
||||
"failure",
|
||||
"neutral",
|
||||
"external"
|
||||
]
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sublabel": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string"
|
||||
},
|
||||
"icon": {
|
||||
"$ref": "common.schema.json#/$defs/nodeIcon"
|
||||
},
|
||||
"brand": {
|
||||
"$ref": "common.schema.json#/$defs/brandMark"
|
||||
},
|
||||
"sources": {
|
||||
"$ref": "common.schema.json#/$defs/sourceReferences"
|
||||
},
|
||||
"step": {
|
||||
"type": "string"
|
||||
},
|
||||
"lane": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"col": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 4
|
||||
},
|
||||
"width": {
|
||||
"type": "number",
|
||||
"minimum": 48
|
||||
},
|
||||
"height": {
|
||||
"type": "number",
|
||||
"minimum": 36
|
||||
},
|
||||
"yOffset": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"transitions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"from",
|
||||
"to"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"from": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"to": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"note": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"$ref": "common.schema.json#/$defs/variant"
|
||||
},
|
||||
"route": {
|
||||
"enum": [
|
||||
"auto",
|
||||
"straight",
|
||||
"drop",
|
||||
"bottom-channel",
|
||||
"top-channel",
|
||||
"right-channel",
|
||||
"left-channel"
|
||||
]
|
||||
},
|
||||
"fromSide": {
|
||||
"$ref": "common.schema.json#/$defs/side"
|
||||
},
|
||||
"toSide": {
|
||||
"$ref": "common.schema.json#/$defs/side"
|
||||
},
|
||||
"channelX": {
|
||||
"type": "number"
|
||||
},
|
||||
"channelY": {
|
||||
"type": "number"
|
||||
},
|
||||
"cornerRadius": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"labelAt": {
|
||||
"$ref": "common.schema.json#/$defs/point"
|
||||
},
|
||||
"labelDx": {
|
||||
"type": "number"
|
||||
},
|
||||
"labelDy": {
|
||||
"type": "number"
|
||||
},
|
||||
"labelSegment": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"via": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/point"
|
||||
}
|
||||
},
|
||||
"width": {
|
||||
"$ref": "common.schema.json#/$defs/relationshipWidth"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cards": {
|
||||
"$ref": "common.schema.json#/$defs/cards"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Archify Sequence Diagram",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"diagram_type",
|
||||
"meta",
|
||||
"participants",
|
||||
"messages"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": 1
|
||||
},
|
||||
"diagram_type": {
|
||||
"const": "sequence"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"title",
|
||||
"output"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"locale": {
|
||||
"$ref": "common.schema.json#/$defs/locale"
|
||||
},
|
||||
"translations": {
|
||||
"$ref": "common.schema.json#/$defs/translations"
|
||||
},
|
||||
"subtitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"output": {
|
||||
"$ref": "common.schema.json#/$defs/portableOutputPath"
|
||||
},
|
||||
"animation": {
|
||||
"$ref": "common.schema.json#/$defs/animation"
|
||||
},
|
||||
"visual_preset": {
|
||||
"$ref": "common.schema.json#/$defs/visualPreset"
|
||||
},
|
||||
"quality_profile": {
|
||||
"$ref": "common.schema.json#/$defs/qualityProfile"
|
||||
},
|
||||
"column_fit": {
|
||||
"description": "Horizontal participant layout. Omit this field or use fixed for the stable 86px boxes and 108px gap. Use spread when a wide viewBox would leave unused horizontal space or meaningful participant labels do not fit the fixed boxes; spread derives wider boxes and gaps from the viewBox without changing participant order or message semantics.",
|
||||
"enum": [
|
||||
"fixed",
|
||||
"spread"
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
"$ref": "common.schema.json#/$defs/repository"
|
||||
},
|
||||
"views": {
|
||||
"$ref": "common.schema.json#/$defs/guidedViews"
|
||||
},
|
||||
"legend": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": {
|
||||
"$ref": "common.schema.json#/$defs/legendMode"
|
||||
},
|
||||
"entries": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"default": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"emphasis": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"security": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"dashed": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"return": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewBox": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 480
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 480
|
||||
}
|
||||
],
|
||||
"items": false,
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"participants": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "common.schema.json#/$defs/componentType"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sublabel": {
|
||||
"type": "string"
|
||||
},
|
||||
"icon": {
|
||||
"$ref": "common.schema.json#/$defs/nodeIcon"
|
||||
},
|
||||
"brand": {
|
||||
"$ref": "common.schema.json#/$defs/brandMark"
|
||||
},
|
||||
"sources": {
|
||||
"$ref": "common.schema.json#/$defs/sourceReferences"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"segments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"from",
|
||||
"to",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"from": {
|
||||
"type": "number"
|
||||
},
|
||||
"to": {
|
||||
"type": "number"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"from",
|
||||
"to",
|
||||
"y",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"from": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"to": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": 160
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"variant": {
|
||||
"enum": [
|
||||
"default",
|
||||
"emphasis",
|
||||
"security",
|
||||
"dashed",
|
||||
"return"
|
||||
]
|
||||
},
|
||||
"note": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"activations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"participant",
|
||||
"from",
|
||||
"to"
|
||||
],
|
||||
"properties": {
|
||||
"participant": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"from": {
|
||||
"type": "number"
|
||||
},
|
||||
"to": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "common.schema.json#/$defs/componentType"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cards": {
|
||||
"$ref": "common.schema.json#/$defs/cards"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Archify Workflow Diagram",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"diagram_type",
|
||||
"meta",
|
||||
"lanes",
|
||||
"nodes",
|
||||
"edges"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"enum": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
"diagram_type": {
|
||||
"const": "workflow"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"title",
|
||||
"output"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"locale": {
|
||||
"$ref": "common.schema.json#/$defs/locale"
|
||||
},
|
||||
"translations": {
|
||||
"$ref": "common.schema.json#/$defs/translations"
|
||||
},
|
||||
"subtitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"output": {
|
||||
"$ref": "common.schema.json#/$defs/portableOutputPath"
|
||||
},
|
||||
"animation": {
|
||||
"enum": [
|
||||
"trace",
|
||||
"none"
|
||||
]
|
||||
},
|
||||
"visual_preset": {
|
||||
"enum": [
|
||||
"classic",
|
||||
"signal-flow",
|
||||
"blueprint",
|
||||
"editorial"
|
||||
]
|
||||
},
|
||||
"quality_profile": {
|
||||
"enum": [
|
||||
"standard",
|
||||
"showcase"
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
"$ref": "common.schema.json#/$defs/repository"
|
||||
},
|
||||
"views": {
|
||||
"$ref": "common.schema.json#/$defs/guidedViews"
|
||||
},
|
||||
"legend": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": {
|
||||
"$ref": "common.schema.json#/$defs/legendMode"
|
||||
},
|
||||
"entries": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"frontend": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"backend": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"database": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"cloud": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"security": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"messagebus": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
},
|
||||
"external": {
|
||||
"$ref": "common.schema.json#/$defs/legendEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewBox": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 700
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 240
|
||||
}
|
||||
],
|
||||
"items": false,
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"lanes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"variant": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"exception"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"phases": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"label",
|
||||
"fromCol",
|
||||
"toCol"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"fromCol": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 5
|
||||
},
|
||||
"toCol": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 5
|
||||
},
|
||||
"variant": {
|
||||
"enum": [
|
||||
"default",
|
||||
"emphasis",
|
||||
"security",
|
||||
"dashed"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"label",
|
||||
"lane",
|
||||
"fromCol",
|
||||
"toCol"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"lane": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"fromCol": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 5
|
||||
},
|
||||
"toCol": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 5
|
||||
},
|
||||
"variant": {
|
||||
"enum": [
|
||||
"default",
|
||||
"emphasis",
|
||||
"security",
|
||||
"dashed"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mainPath": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
}
|
||||
},
|
||||
"semanticChecks": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"allowedRoots": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
}
|
||||
},
|
||||
"allowedTerminals": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
}
|
||||
},
|
||||
"requiredEdges": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/semanticRelation"
|
||||
}
|
||||
},
|
||||
"requiredPaths": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/semanticRelation"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"lane",
|
||||
"col",
|
||||
"type",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"lane": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"col": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 5
|
||||
},
|
||||
"type": {
|
||||
"$ref": "common.schema.json#/$defs/componentType"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sublabel": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string"
|
||||
},
|
||||
"icon": {
|
||||
"$ref": "common.schema.json#/$defs/nodeIcon"
|
||||
},
|
||||
"brand": {
|
||||
"$ref": "common.schema.json#/$defs/brandMark"
|
||||
},
|
||||
"sources": {
|
||||
"$ref": "common.schema.json#/$defs/sourceReferences"
|
||||
},
|
||||
"width": {
|
||||
"type": "number",
|
||||
"minimum": 32
|
||||
},
|
||||
"height": {
|
||||
"type": "number",
|
||||
"minimum": 32
|
||||
},
|
||||
"yOffset": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"edges": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"from",
|
||||
"to"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"from": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"to": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"$ref": "common.schema.json#/$defs/variant"
|
||||
},
|
||||
"role": {
|
||||
"enum": [
|
||||
"main",
|
||||
"branch",
|
||||
"async",
|
||||
"return",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"fromSide": {
|
||||
"$ref": "#/$defs/side"
|
||||
},
|
||||
"toSide": {
|
||||
"$ref": "#/$defs/side"
|
||||
},
|
||||
"route": {
|
||||
"enum": [
|
||||
"auto",
|
||||
"straight",
|
||||
"drop",
|
||||
"outside-right",
|
||||
"return-left",
|
||||
"bottom-channel",
|
||||
"up-channel"
|
||||
]
|
||||
},
|
||||
"via": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/point"
|
||||
}
|
||||
},
|
||||
"labelAt": {
|
||||
"$ref": "common.schema.json#/$defs/point"
|
||||
},
|
||||
"labelDx": {
|
||||
"type": "number"
|
||||
},
|
||||
"labelDy": {
|
||||
"type": "number"
|
||||
},
|
||||
"labelSegment": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"channelX": {
|
||||
"type": "number"
|
||||
},
|
||||
"channelY": {
|
||||
"type": "number"
|
||||
},
|
||||
"bias": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"width": {
|
||||
"type": "number",
|
||||
"minimum": 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cards": {
|
||||
"$ref": "common.schema.json#/$defs/cards"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"semanticRelation": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"from",
|
||||
"to"
|
||||
],
|
||||
"properties": {
|
||||
"from": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"to": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
}
|
||||
}
|
||||
},
|
||||
"side": {
|
||||
"enum": [
|
||||
"left",
|
||||
"right",
|
||||
"top",
|
||||
"bottom"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
// Copies the Archify renderer runtime into vendor/archify and writes
|
||||
// VS Code-friendly copies of its JSON schemas into schemas/.
|
||||
//
|
||||
// Usage: node scripts/sync-archify.mjs [path-to-archify-checkout]
|
||||
// Default source: ../../ws-storm/archify (or $ARCHIFY_SRC).
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const source = path.resolve(
|
||||
process.argv[2] || process.env.ARCHIFY_SRC || path.join(root, '..', '..', 'ws-storm', 'archify'),
|
||||
);
|
||||
const skill = path.join(source, 'archify');
|
||||
|
||||
// Runtime pieces the CLI reads at render/validate time. test/ and examples/
|
||||
// are development-only and large, so they are left out.
|
||||
const RUNTIME = [
|
||||
'LICENSE',
|
||||
'THIRD_PARTY_NOTICES.md',
|
||||
'package.json',
|
||||
'skill-release.json',
|
||||
'assets',
|
||||
'bin',
|
||||
'brand-marks',
|
||||
'delta',
|
||||
'migrations',
|
||||
'recipes',
|
||||
'references',
|
||||
'renderers',
|
||||
'schemas',
|
||||
'scripts',
|
||||
];
|
||||
|
||||
if (!fs.existsSync(path.join(skill, 'bin', 'archify.mjs'))) {
|
||||
console.error(`Archify checkout not found at ${source}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const vendor = path.join(root, 'vendor', 'archify');
|
||||
fs.rmSync(vendor, { recursive: true, force: true });
|
||||
fs.mkdirSync(vendor, { recursive: true });
|
||||
for (const entry of RUNTIME) {
|
||||
fs.cpSync(path.join(skill, entry), path.join(vendor, entry), { recursive: true });
|
||||
}
|
||||
|
||||
// The upstream schemas carry absolute GitHub $ids, which would make VS Code
|
||||
// resolve "common.schema.json" refs over the network. Dropping $id lets refs
|
||||
// resolve next to the bundled file instead.
|
||||
const schemaOut = path.join(root, 'schemas');
|
||||
fs.rmSync(schemaOut, { recursive: true, force: true });
|
||||
fs.mkdirSync(schemaOut, { recursive: true });
|
||||
for (const file of fs.readdirSync(path.join(skill, 'schemas'))) {
|
||||
if (!file.endsWith('.schema.json')) continue;
|
||||
const schema = JSON.parse(fs.readFileSync(path.join(skill, 'schemas', file), 'utf8'));
|
||||
delete schema.$id;
|
||||
fs.writeFileSync(path.join(schemaOut, file), `${JSON.stringify(schema, null, 2)}\n`);
|
||||
}
|
||||
|
||||
let revision = 'unknown';
|
||||
try {
|
||||
revision = execFileSync('git', ['-C', source, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
|
||||
} catch {}
|
||||
const { version } = JSON.parse(fs.readFileSync(path.join(skill, 'package.json'), 'utf8'));
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'vendor', 'archify.json'),
|
||||
`${JSON.stringify({ upstream: 'https://github.com/tt-a1i/archify', version, revision }, null, 2)}\n`,
|
||||
);
|
||||
console.log(`Synced archify ${version} (${revision.slice(0, 12)}) from ${source}`);
|
||||
@@ -0,0 +1,77 @@
|
||||
import { parse, ParseError } from 'jsonc-parser';
|
||||
|
||||
export const DIAGRAM_TYPES = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'] as const;
|
||||
export type DiagramType = (typeof DIAGRAM_TYPES)[number];
|
||||
|
||||
/** Node collection that may carry `sources` evidence, per diagram type. */
|
||||
export const EVIDENCE_COLLECTIONS: Record<DiagramType, string> = {
|
||||
architecture: 'components',
|
||||
workflow: 'nodes',
|
||||
sequence: 'participants',
|
||||
dataflow: 'nodes',
|
||||
lifecycle: 'states',
|
||||
};
|
||||
|
||||
const FILENAME_RE = /\.(architecture|workflow|sequence|dataflow|lifecycle)\.json$/i;
|
||||
const MAX_SNIFF_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
export function isDiagramType(value: unknown): value is DiagramType {
|
||||
return typeof value === 'string' && (DIAGRAM_TYPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function typeFromFilename(fileName: string): DiagramType | undefined {
|
||||
const match = FILENAME_RE.exec(fileName);
|
||||
return match ? (match[1].toLowerCase() as DiagramType) : undefined;
|
||||
}
|
||||
|
||||
export interface DiagramSource {
|
||||
type: DiagramType;
|
||||
diagram: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses JSON text and returns the diagram when it declares an Archify
|
||||
* `diagram_type`. Falls back to the file name for documents that do not
|
||||
* parse yet, so a half-typed source still counts as a diagram.
|
||||
*/
|
||||
export function readDiagramSource(text: string, fileName = ''): DiagramSource | undefined {
|
||||
if (text.length > MAX_SNIFF_BYTES) return undefined;
|
||||
const errors: ParseError[] = [];
|
||||
const value = parse(text, errors, { allowTrailingComma: true });
|
||||
if (value && typeof value === 'object' && !Array.isArray(value) && isDiagramType(value.diagram_type)) {
|
||||
return { type: value.diagram_type, diagram: value };
|
||||
}
|
||||
const byName = typeFromFilename(fileName);
|
||||
if (byName) {
|
||||
const diagram = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
return { type: byName, diagram };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** True when the HTML was produced by the Archify renderer. */
|
||||
export function isArchifyHtml(text: string): boolean {
|
||||
const head = text.slice(0, 4096);
|
||||
return /<meta\s+name=["']generator["']\s+content=["']archify\b/i.test(head);
|
||||
}
|
||||
|
||||
export function hasSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): boolean {
|
||||
const meta = diagram.meta as Record<string, unknown> | undefined;
|
||||
if (meta && meta.repository) return true;
|
||||
const nodes = diagram[EVIDENCE_COLLECTIONS[type]];
|
||||
return Array.isArray(nodes) && nodes.some((node) => Array.isArray(node?.sources) && node.sources.length > 0);
|
||||
}
|
||||
|
||||
/** Returns a copy of the diagram with repository evidence removed. */
|
||||
export function stripSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): Record<string, unknown> {
|
||||
const copy = structuredClone(diagram);
|
||||
const meta = copy.meta as Record<string, unknown> | undefined;
|
||||
if (meta) delete meta.repository;
|
||||
const nodes = copy[EVIDENCE_COLLECTIONS[type]];
|
||||
if (Array.isArray(nodes)) {
|
||||
for (const node of nodes) {
|
||||
if (node && typeof node === 'object') delete node.sources;
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { parse, ParseError } from 'jsonc-parser';
|
||||
import * as vscode from 'vscode';
|
||||
import { readDiagramSource } from './detect';
|
||||
import { rangeForPointer } from './jsonPointer';
|
||||
import { ArchifyDiagnostic, isEvidenceError, validate } from './renderer';
|
||||
import { evidenceMode, log, runtimeFor } from './runtime';
|
||||
|
||||
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
|
||||
|
||||
function severity(diagnostic: ArchifyDiagnostic, downgradeEvidence: boolean): vscode.DiagnosticSeverity {
|
||||
if (downgradeEvidence && isEvidenceError(`${diagnostic.code ?? ''} ${diagnostic.message}`)) {
|
||||
return vscode.DiagnosticSeverity.Warning;
|
||||
}
|
||||
switch (diagnostic.severity) {
|
||||
case 'warning':
|
||||
return vscode.DiagnosticSeverity.Warning;
|
||||
case 'info':
|
||||
return vscode.DiagnosticSeverity.Information;
|
||||
default:
|
||||
return vscode.DiagnosticSeverity.Error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs `archify validate` on diagram sources and reports into Problems. */
|
||||
export class DiagnosticsController implements vscode.Disposable {
|
||||
private readonly collection = vscode.languages.createDiagnosticCollection('archify');
|
||||
private readonly timers = new Map<string, NodeJS.Timeout>();
|
||||
private readonly sequence = new Map<string, number>();
|
||||
private readonly disposables: vscode.Disposable[];
|
||||
|
||||
constructor(private readonly context: vscode.ExtensionContext) {
|
||||
this.disposables = [
|
||||
this.collection,
|
||||
vscode.workspace.onDidOpenTextDocument((doc) => this.schedule(doc, 0)),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
if (event.contentChanges.length) this.schedule(event.document, 700);
|
||||
}),
|
||||
vscode.workspace.onDidSaveTextDocument((doc) => this.schedule(doc, 0)),
|
||||
vscode.workspace.onDidCloseTextDocument((doc) => this.clear(doc.uri)),
|
||||
vscode.workspace.onDidChangeConfiguration((event) => {
|
||||
if (event.affectsConfiguration('archify')) this.revalidateAll();
|
||||
}),
|
||||
];
|
||||
this.revalidateAll();
|
||||
}
|
||||
|
||||
private enabled(uri: vscode.Uri): boolean {
|
||||
return vscode.workspace.getConfiguration('archify', uri).get<boolean>('validation.enabled', true);
|
||||
}
|
||||
|
||||
revalidateAll(): void {
|
||||
for (const doc of vscode.workspace.textDocuments) this.schedule(doc, 0);
|
||||
}
|
||||
|
||||
private clear(uri: vscode.Uri): void {
|
||||
const key = uri.toString();
|
||||
clearTimeout(this.timers.get(key));
|
||||
this.timers.delete(key);
|
||||
this.sequence.set(key, (this.sequence.get(key) ?? 0) + 1);
|
||||
this.collection.delete(uri);
|
||||
}
|
||||
|
||||
private schedule(doc: vscode.TextDocument, delay: number): void {
|
||||
if (!JSON_LANGUAGES.has(doc.languageId)) return;
|
||||
const key = doc.uri.toString();
|
||||
clearTimeout(this.timers.get(key));
|
||||
this.timers.set(key, setTimeout(() => void this.validate(doc), delay));
|
||||
}
|
||||
|
||||
private async validate(doc: vscode.TextDocument): Promise<void> {
|
||||
const key = doc.uri.toString();
|
||||
this.timers.delete(key);
|
||||
const seq = (this.sequence.get(key) ?? 0) + 1;
|
||||
this.sequence.set(key, seq);
|
||||
|
||||
const text = doc.getText();
|
||||
const source = readDiagramSource(text, doc.fileName);
|
||||
if (!source || !this.enabled(doc.uri) || doc.isClosed) {
|
||||
this.collection.delete(doc.uri);
|
||||
return;
|
||||
}
|
||||
// Leave syntax errors to the JSON language service.
|
||||
const syntax: ParseError[] = [];
|
||||
parse(text, syntax, { allowTrailingComma: false });
|
||||
if (syntax.length) {
|
||||
this.collection.delete(doc.uri);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const runtime = await runtimeFor(this.context, doc.uri);
|
||||
const result = await validate(runtime, source.type, text);
|
||||
if (seq !== this.sequence.get(key) || doc.isClosed) return;
|
||||
|
||||
if (result.crash) {
|
||||
const diagnostic = new vscode.Diagnostic(
|
||||
new vscode.Range(0, 0, 0, 0),
|
||||
`Archify validation did not complete: ${result.crash.split('\n')[0]}`,
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
);
|
||||
diagnostic.source = 'archify';
|
||||
this.collection.set(doc.uri, [diagnostic]);
|
||||
return;
|
||||
}
|
||||
|
||||
const downgradeEvidence = evidenceMode(doc.uri) === 'fallback';
|
||||
this.collection.set(
|
||||
doc.uri,
|
||||
result.diagnostics.map((item) => {
|
||||
const offsets = rangeForPointer(text, item.subject?.path);
|
||||
const range = new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end));
|
||||
const fix = item.supportedFixes?.[0];
|
||||
const diagnostic = new vscode.Diagnostic(
|
||||
range,
|
||||
fix ? `${item.message}\nFix: ${fix}` : item.message,
|
||||
severity(item, downgradeEvidence),
|
||||
);
|
||||
diagnostic.source = 'archify';
|
||||
if (item.code) diagnostic.code = item.code;
|
||||
return diagnostic;
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
log().error(`Validation failed for ${doc.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const timer of this.timers.values()) clearTimeout(timer);
|
||||
for (const disposable of this.disposables.splice(0)) disposable.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { isArchifyHtml, readDiagramSource } from './detect';
|
||||
import { DiagnosticsController } from './diagnostics';
|
||||
import { HTML_VIEW_TYPE, HtmlViewerProvider } from './htmlViewer';
|
||||
import { PreviewManager } from './preview';
|
||||
import { isEvidenceError, renderDiagram, renderToFile } from './renderer';
|
||||
import { clearGitRootCache, evidenceMode, log, readText, runtimeFor } from './runtime';
|
||||
|
||||
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
|
||||
|
||||
function isHtmlUri(uri: vscode.Uri): boolean {
|
||||
return /\.html?$/i.test(uri.path);
|
||||
}
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): void {
|
||||
const previews = new PreviewManager(context);
|
||||
const htmlViewer = new HtmlViewerProvider();
|
||||
context.subscriptions.push(previews, htmlViewer, new DiagnosticsController(context), log());
|
||||
|
||||
/** The file a command applies to: explicit argument, focused preview or viewer, then the text editor. */
|
||||
function targetUri(arg?: unknown): vscode.Uri | undefined {
|
||||
if (arg instanceof vscode.Uri) return arg;
|
||||
return previews.activePreview?.uri ?? htmlViewer.activeUri ?? vscode.window.activeTextEditor?.document.uri;
|
||||
}
|
||||
|
||||
function sourceUri(arg?: unknown): vscode.Uri | undefined {
|
||||
const uri = targetUri(arg);
|
||||
if (!uri) vscode.window.showWarningMessage('Archify: open a diagram JSON source first.');
|
||||
return uri;
|
||||
}
|
||||
|
||||
// Context keys for editor title buttons and keybindings.
|
||||
let contextTimer: NodeJS.Timeout | undefined;
|
||||
const updateContext = () => {
|
||||
const doc = vscode.window.activeTextEditor?.document;
|
||||
const diagram = Boolean(doc && JSON_LANGUAGES.has(doc.languageId) && readDiagramSource(doc.getText(), doc.fileName));
|
||||
const html = Boolean(doc && doc.languageId === 'html' && isArchifyHtml(doc.getText()));
|
||||
void vscode.commands.executeCommand('setContext', 'archify.isDiagramSource', diagram);
|
||||
void vscode.commands.executeCommand('setContext', 'archify.isArchifyHtml', html);
|
||||
};
|
||||
context.subscriptions.push(
|
||||
vscode.window.onDidChangeActiveTextEditor(updateContext),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
if (event.document !== vscode.window.activeTextEditor?.document) return;
|
||||
clearTimeout(contextTimer);
|
||||
contextTimer = setTimeout(updateContext, 300);
|
||||
}),
|
||||
vscode.workspace.onDidChangeConfiguration((event) => {
|
||||
if (event.affectsConfiguration('archify.repoRoot')) clearGitRootCache();
|
||||
}),
|
||||
{ dispose: () => clearTimeout(contextTimer) },
|
||||
);
|
||||
updateContext();
|
||||
|
||||
const openHtmlViewer = (uri: vscode.Uri, viewColumn?: vscode.ViewColumn) =>
|
||||
vscode.commands.executeCommand('vscode.openWith', uri, HTML_VIEW_TYPE, viewColumn);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('archify.showPreview', (arg?: unknown) => {
|
||||
const uri = sourceUri(arg);
|
||||
if (!uri) return;
|
||||
if (isHtmlUri(uri)) return openHtmlViewer(uri);
|
||||
previews.show(uri, vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.Active);
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('archify.showPreviewToSide', (arg?: unknown) => {
|
||||
const uri = sourceUri(arg);
|
||||
if (!uri) return;
|
||||
if (isHtmlUri(uri)) return openHtmlViewer(uri, vscode.ViewColumn.Beside);
|
||||
previews.show(uri, vscode.ViewColumn.Beside, true);
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('archify.openHtmlViewer', (arg?: unknown) => {
|
||||
const uri = sourceUri(arg);
|
||||
if (uri) return openHtmlViewer(uri);
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('archify.refreshPreview', () => previews.activePreview?.update(true)),
|
||||
|
||||
vscode.commands.registerCommand('archify.showSource', async () => {
|
||||
const preview = previews.activePreview;
|
||||
if (preview) {
|
||||
const column = preview.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
|
||||
await vscode.window.showTextDocument(preview.uri, { viewColumn: column });
|
||||
return;
|
||||
}
|
||||
const html = htmlViewer.activeUri;
|
||||
if (html) await vscode.commands.executeCommand('vscode.openWith', html, 'default');
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('archify.renderToHtml', async (arg?: unknown) => {
|
||||
const uri = sourceUri(arg);
|
||||
if (!uri) return;
|
||||
const text = await readText(uri);
|
||||
const source = readDiagramSource(text, uri.path);
|
||||
if (!source) {
|
||||
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} is not an Archify diagram source.`);
|
||||
return;
|
||||
}
|
||||
const meta = (source.diagram.meta ?? {}) as { output?: unknown };
|
||||
const authored = typeof meta.output === 'string' && !/[\\/]/.test(meta.output) ? meta.output : undefined;
|
||||
const fileName = authored ?? `${path.basename(uri.path).replace(/\.json$/i, '')}.html`;
|
||||
const defaultUri = uri.scheme === 'file' ? vscode.Uri.joinPath(uri, '..', fileName) : undefined;
|
||||
const target = await vscode.window.showSaveDialog({
|
||||
defaultUri,
|
||||
filters: { HTML: ['html'] },
|
||||
saveLabel: 'Render',
|
||||
});
|
||||
if (!target) return;
|
||||
|
||||
const runtime = await runtimeFor(context, uri);
|
||||
let result = await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
|
||||
() => renderToFile(runtime, { ...source, text }, target.fsPath),
|
||||
);
|
||||
if (!result.ok && isEvidenceError(result.error)) {
|
||||
const choice = await vscode.window.showWarningMessage(
|
||||
'Archify could not verify the diagram\'s source evidence against a local checkout.',
|
||||
{ modal: true, detail: result.error },
|
||||
'Render Without Source Links',
|
||||
);
|
||||
if (choice) result = await renderToFile(runtime, { ...source, text }, target.fsPath, true);
|
||||
else return;
|
||||
}
|
||||
if (!result.ok) {
|
||||
log().error(result.error ?? 'render failed');
|
||||
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
|
||||
return;
|
||||
}
|
||||
const choice = await vscode.window.showInformationMessage(
|
||||
`Rendered ${path.basename(target.fsPath)}`,
|
||||
'Open in Viewer',
|
||||
'Open in Browser',
|
||||
);
|
||||
if (choice === 'Open in Viewer') await openHtmlViewer(target);
|
||||
if (choice === 'Open in Browser') await vscode.env.openExternal(target);
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('archify.openInBrowser', async (arg?: unknown) => {
|
||||
const uri = sourceUri(arg);
|
||||
if (!uri) return;
|
||||
if (isHtmlUri(uri)) {
|
||||
await vscode.env.openExternal(uri);
|
||||
return;
|
||||
}
|
||||
const text = await readText(uri);
|
||||
const source = readDiagramSource(text, uri.path);
|
||||
if (!source) {
|
||||
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} is not an Archify diagram source.`);
|
||||
return;
|
||||
}
|
||||
const runtime = await runtimeFor(context, uri);
|
||||
const result = await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
|
||||
() => renderDiagram(runtime, { ...source, text }, evidenceMode(uri)),
|
||||
);
|
||||
if (!result.ok || !result.html) {
|
||||
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
|
||||
return;
|
||||
}
|
||||
const dir = path.join(context.globalStorageUri.fsPath, 'browser');
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const file = path.join(dir, `${path.basename(uri.path).replace(/\.json$/i, '')}.html`);
|
||||
await fs.writeFile(file, result.html, 'utf8');
|
||||
await vscode.env.openExternal(vscode.Uri.file(file));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function deactivate(): void {}
|
||||
@@ -0,0 +1,116 @@
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { isArchifyHtml } from './detect';
|
||||
import { viewerTheme } from './runtime';
|
||||
import { handleWebviewMessage, WebviewMessage } from './webviewMessages';
|
||||
import { makeNonce, messagePage, prepareDiagramHtml } from './webviewHtml';
|
||||
|
||||
export const HTML_VIEW_TYPE = 'archify.htmlViewer';
|
||||
|
||||
class HtmlDocument implements vscode.CustomDocument {
|
||||
constructor(readonly uri: vscode.Uri) {}
|
||||
dispose(): void {}
|
||||
}
|
||||
|
||||
/** Read-only custom editor that shows rendered Archify HTML files. */
|
||||
export class HtmlViewerProvider implements vscode.CustomReadonlyEditorProvider<HtmlDocument>, vscode.Disposable {
|
||||
private readonly viewers = new Map<vscode.WebviewPanel, () => Promise<void>>();
|
||||
private active: { uri: vscode.Uri; panel: vscode.WebviewPanel } | undefined;
|
||||
private readonly disposables: vscode.Disposable[];
|
||||
|
||||
constructor() {
|
||||
this.disposables = [
|
||||
vscode.window.registerCustomEditorProvider(HTML_VIEW_TYPE, this, {
|
||||
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
||||
supportsMultipleEditorsPerDocument: true,
|
||||
}),
|
||||
vscode.window.onDidChangeActiveColorTheme(() => this.reloadAll()),
|
||||
vscode.workspace.onDidChangeConfiguration((event) => {
|
||||
if (event.affectsConfiguration('archify.theme')) this.reloadAll();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/** File shown in the focused Archify HTML viewer, if any. */
|
||||
get activeUri(): vscode.Uri | undefined {
|
||||
return this.active?.uri;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const disposable of this.disposables.splice(0)) disposable.dispose();
|
||||
}
|
||||
|
||||
openCustomDocument(uri: vscode.Uri): HtmlDocument {
|
||||
return new HtmlDocument(uri);
|
||||
}
|
||||
|
||||
async resolveCustomEditor(document: HtmlDocument, panel: vscode.WebviewPanel): Promise<void> {
|
||||
const uri = document.uri;
|
||||
let hash: string | undefined;
|
||||
panel.webview.options = { enableScripts: true, localResourceRoots: [] };
|
||||
|
||||
const reload = async () => {
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder('utf-8').decode(await vscode.workspace.fs.readFile(uri));
|
||||
} catch (error) {
|
||||
panel.webview.html = messagePage(
|
||||
{ cspSource: panel.webview.cspSource, nonce: makeNonce() },
|
||||
'Cannot open file',
|
||||
uri.fsPath,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return;
|
||||
}
|
||||
panel.webview.html = prepareDiagramHtml(text, {
|
||||
cspSource: panel.webview.cspSource,
|
||||
nonce: makeNonce(),
|
||||
theme: viewerTheme(),
|
||||
hash,
|
||||
notice: isArchifyHtml(text)
|
||||
? undefined
|
||||
: { kind: 'info', text: 'This file was not generated by Archify. It is shown in a sandbox without network access.' },
|
||||
});
|
||||
};
|
||||
|
||||
this.viewers.set(panel, reload);
|
||||
|
||||
const disposables: vscode.Disposable[] = [
|
||||
panel.webview.onDidReceiveMessage((message: WebviewMessage) => {
|
||||
if (message.type === 'hash') {
|
||||
hash = message.hash;
|
||||
return;
|
||||
}
|
||||
void handleWebviewMessage(message, uri, panel);
|
||||
}),
|
||||
panel.onDidChangeViewState(() => {
|
||||
if (panel.active) this.active = { uri, panel };
|
||||
else if (this.active?.panel === panel) this.active = undefined;
|
||||
}),
|
||||
];
|
||||
if (uri.scheme === 'file') {
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(
|
||||
new vscode.RelativePattern(vscode.Uri.file(path.dirname(uri.fsPath)), path.basename(uri.fsPath)),
|
||||
);
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const onChange = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => void reload(), 300);
|
||||
};
|
||||
disposables.push(watcher, watcher.onDidChange(onChange), watcher.onDidCreate(onChange), {
|
||||
dispose: () => clearTimeout(timer),
|
||||
});
|
||||
}
|
||||
panel.onDidDispose(() => {
|
||||
this.viewers.delete(panel);
|
||||
if (this.active?.panel === panel) this.active = undefined;
|
||||
for (const disposable of disposables) disposable.dispose();
|
||||
});
|
||||
if (panel.active) this.active = { uri, panel };
|
||||
await reload();
|
||||
}
|
||||
|
||||
reloadAll(): void {
|
||||
for (const reload of this.viewers.values()) void reload();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { findNodeAtLocation, Node, parseTree } from 'jsonc-parser';
|
||||
|
||||
export interface OffsetRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Splits an RFC 6901 JSON pointer into path segments. */
|
||||
export function pointerSegments(pointer: string): (string | number)[] {
|
||||
if (!pointer || pointer === '/') return [];
|
||||
return pointer
|
||||
.replace(/^\//, '')
|
||||
.split('/')
|
||||
.map((raw) => raw.replace(/~1/g, '/').replace(/~0/g, '~'))
|
||||
.map((segment) => (/^(0|[1-9]\d*)$/.test(segment) ? Number(segment) : segment));
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a JSON pointer to a text range. Objects and arrays resolve to their
|
||||
* property key (or opening bracket) so a "missing property" error does not
|
||||
* underline a whole block. Unresolvable paths fall back to the deepest
|
||||
* ancestor that exists.
|
||||
*/
|
||||
export function rangeForPointer(text: string, pointer: string | undefined): OffsetRange {
|
||||
const root = parseTree(text, [], { allowTrailingComma: true });
|
||||
if (!root) return { start: 0, end: 0 };
|
||||
const segments = pointerSegments(pointer ?? '');
|
||||
let node: Node | undefined;
|
||||
for (let length = segments.length; length >= 0 && !node; length -= 1) {
|
||||
node = findNodeAtLocation(root, segments.slice(0, length));
|
||||
}
|
||||
if (!node) return { start: 0, end: 0 };
|
||||
if (node.type === 'object' || node.type === 'array') {
|
||||
const key = node.parent?.type === 'property' ? node.parent.children?.[0] : undefined;
|
||||
if (key) return { start: key.offset, end: key.offset + key.length };
|
||||
return { start: node.offset, end: node.offset + 1 };
|
||||
}
|
||||
return { start: node.offset, end: node.offset + node.length };
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { readDiagramSource } from './detect';
|
||||
import { renderDiagram } from './renderer';
|
||||
import { evidenceMode, log, readText, runtimeFor, viewerTheme } from './runtime';
|
||||
import { handleWebviewMessage, WebviewMessage } from './webviewMessages';
|
||||
import { makeNonce, messagePage, prepareDiagramHtml } from './webviewHtml';
|
||||
|
||||
export const PREVIEW_VIEW_TYPE = 'archify.preview';
|
||||
|
||||
interface PreviewState {
|
||||
uri: string;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
class DiagramPreview implements vscode.Disposable {
|
||||
private seq = 0;
|
||||
private hash: string | undefined;
|
||||
private lastHtml: string | undefined;
|
||||
private rendered = false;
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private readonly onDisposeEmitter = new vscode.EventEmitter<void>();
|
||||
readonly onDidDispose = this.onDisposeEmitter.event;
|
||||
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
readonly panel: vscode.WebviewPanel,
|
||||
readonly uri: vscode.Uri,
|
||||
state?: PreviewState,
|
||||
) {
|
||||
this.hash = state?.hash;
|
||||
panel.webview.options = { enableScripts: true, localResourceRoots: [] };
|
||||
panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg');
|
||||
panel.title = `Preview ${path.basename(uri.path)}`;
|
||||
|
||||
this.disposables.push(
|
||||
panel.onDidDispose(() => this.dispose()),
|
||||
panel.webview.onDidReceiveMessage((message: WebviewMessage) => {
|
||||
if (message.type === 'hash') {
|
||||
this.hash = message.hash;
|
||||
return;
|
||||
}
|
||||
void handleWebviewMessage(message, this.uri, this.panel);
|
||||
}),
|
||||
);
|
||||
|
||||
if (uri.scheme === 'file') {
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(
|
||||
new vscode.RelativePattern(vscode.Uri.file(path.dirname(uri.fsPath)), path.basename(uri.fsPath)),
|
||||
);
|
||||
const onDisk = () => {
|
||||
// Open documents are tracked through text change events instead.
|
||||
if (!vscode.workspace.textDocuments.some((doc) => doc.uri.toString() === uri.toString())) this.scheduleUpdate();
|
||||
};
|
||||
this.disposables.push(watcher, watcher.onDidChange(onDisk), watcher.onDidCreate(onDisk));
|
||||
}
|
||||
|
||||
this.showMessage('Rendering…', `Rendering ${path.basename(uri.path)} with Archify.`);
|
||||
void this.update();
|
||||
}
|
||||
|
||||
private state(): PreviewState {
|
||||
return { uri: this.uri.toString(), hash: this.hash };
|
||||
}
|
||||
|
||||
private showMessage(title: string, body: string, detail?: string): void {
|
||||
this.lastHtml = undefined;
|
||||
this.panel.webview.html = messagePage(
|
||||
{ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), state: this.state() },
|
||||
title,
|
||||
body,
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
scheduleUpdate(): void {
|
||||
const delay = vscode.workspace.getConfiguration('archify', this.uri).get<number>('preview.debounceMs', 400);
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => void this.update(), delay);
|
||||
}
|
||||
|
||||
async update(force = false): Promise<void> {
|
||||
clearTimeout(this.timer);
|
||||
const seq = ++this.seq;
|
||||
let text: string;
|
||||
try {
|
||||
text = await readText(this.uri);
|
||||
} catch (error) {
|
||||
this.showMessage('Cannot read diagram', this.uri.fsPath, error instanceof Error ? error.message : String(error));
|
||||
return;
|
||||
}
|
||||
const source = readDiagramSource(text, this.uri.path);
|
||||
if (!source) {
|
||||
this.showMessage('Not an Archify diagram', 'The file has no "diagram_type" of architecture, workflow, sequence, dataflow or lifecycle.');
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
const runtime = await runtimeFor(this.context, this.uri);
|
||||
result = await renderDiagram(runtime, { ...source, text }, evidenceMode(this.uri));
|
||||
} catch (error) {
|
||||
result = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
if (seq !== this.seq) return; // a newer render superseded this one
|
||||
|
||||
if (!result.ok || !result.html) {
|
||||
log().warn(`Render failed for ${this.uri.fsPath}: ${result.error}`);
|
||||
if (this.rendered && this.lastHtml) {
|
||||
void this.panel.webview.postMessage({
|
||||
type: 'notice',
|
||||
notice: { kind: 'error', text: `Render failed; showing the last successful render.\n${firstLines(result.error)}` },
|
||||
});
|
||||
} else {
|
||||
this.showMessage('Archify could not render this diagram', 'Fix the problems below (also listed in the Problems panel) and save.', result.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const html = prepareDiagramHtml(result.html, {
|
||||
cspSource: this.panel.webview.cspSource,
|
||||
nonce: makeNonce(),
|
||||
theme: viewerTheme(),
|
||||
hash: this.hash,
|
||||
state: this.state(),
|
||||
notice: result.evidenceNotice ? { kind: 'warning', text: result.evidenceNotice } : undefined,
|
||||
});
|
||||
// Nonces differ per render, so compare the renderer output instead.
|
||||
const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${result.html}`;
|
||||
if (!force && this.rendered && fingerprint === this.lastHtml) {
|
||||
void this.panel.webview.postMessage({ type: 'notice', notice: undefined });
|
||||
return;
|
||||
}
|
||||
this.lastHtml = fingerprint;
|
||||
this.rendered = true;
|
||||
this.panel.webview.html = html;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
clearTimeout(this.timer);
|
||||
this.onDisposeEmitter.fire();
|
||||
this.onDisposeEmitter.dispose();
|
||||
for (const disposable of this.disposables.splice(0)) disposable.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function firstLines(text: string | undefined, count = 6): string {
|
||||
return (text ?? '').split('\n').slice(0, count).join('\n');
|
||||
}
|
||||
|
||||
export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSerializer<PreviewState> {
|
||||
private readonly previews = new Map<string, DiagramPreview>();
|
||||
private active: DiagramPreview | undefined;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
|
||||
constructor(private readonly context: vscode.ExtensionContext) {
|
||||
this.disposables.push(
|
||||
vscode.window.registerWebviewPanelSerializer(PREVIEW_VIEW_TYPE, this),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
const preview = this.previews.get(event.document.uri.toString());
|
||||
if (preview && event.contentChanges.length && this.liveUpdate(preview.uri)) preview.scheduleUpdate();
|
||||
}),
|
||||
vscode.workspace.onDidSaveTextDocument((doc) => void this.previews.get(doc.uri.toString())?.update()),
|
||||
vscode.workspace.onDidChangeConfiguration((event) => {
|
||||
if (event.affectsConfiguration('archify')) this.refreshAll();
|
||||
}),
|
||||
vscode.window.onDidChangeActiveColorTheme(() => this.refreshAll()),
|
||||
);
|
||||
}
|
||||
|
||||
private liveUpdate(uri: vscode.Uri): boolean {
|
||||
return vscode.workspace.getConfiguration('archify', uri).get<boolean>('preview.liveUpdate', true);
|
||||
}
|
||||
|
||||
get activePreview(): DiagramPreview | undefined {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
show(uri: vscode.Uri, viewColumn: vscode.ViewColumn, preserveFocus = false): void {
|
||||
const existing = this.previews.get(uri.toString());
|
||||
if (existing) {
|
||||
existing.panel.reveal(viewColumn, preserveFocus);
|
||||
return;
|
||||
}
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
PREVIEW_VIEW_TYPE,
|
||||
`Preview ${path.basename(uri.path)}`,
|
||||
{ viewColumn, preserveFocus },
|
||||
{ enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: [] },
|
||||
);
|
||||
this.track(new DiagramPreview(this.context, panel, uri));
|
||||
}
|
||||
|
||||
async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: PreviewState | undefined): Promise<void> {
|
||||
if (!state?.uri) {
|
||||
panel.dispose();
|
||||
return;
|
||||
}
|
||||
this.track(new DiagramPreview(this.context, panel, vscode.Uri.parse(state.uri), state));
|
||||
}
|
||||
|
||||
private track(preview: DiagramPreview): void {
|
||||
const key = preview.uri.toString();
|
||||
this.previews.get(key)?.panel.dispose();
|
||||
this.previews.set(key, preview);
|
||||
const onViewState = () => {
|
||||
if (preview.panel.active) this.active = preview;
|
||||
else if (this.active === preview) this.active = undefined;
|
||||
void vscode.commands.executeCommand('setContext', 'archifyPreviewFocus', Boolean(this.active));
|
||||
};
|
||||
preview.panel.onDidChangeViewState(onViewState);
|
||||
onViewState();
|
||||
preview.onDidDispose(() => {
|
||||
if (this.previews.get(key) === preview) this.previews.delete(key);
|
||||
if (this.active === preview) {
|
||||
this.active = undefined;
|
||||
void vscode.commands.executeCommand('setContext', 'archifyPreviewFocus', false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refreshAll(force = false): void {
|
||||
for (const preview of this.previews.values()) void preview.update(force);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const preview of this.previews.values()) preview.panel.dispose();
|
||||
this.previews.clear();
|
||||
for (const disposable of this.disposables.splice(0)) disposable.dispose();
|
||||
}
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { DiagramSource, DiagramType, hasSourceEvidence, stripSourceEvidence } from './detect';
|
||||
|
||||
export interface RuntimeOptions {
|
||||
/** Absolute path to vendor/archify/bin/archify.mjs. */
|
||||
cliPath: string;
|
||||
/** Node (or Electron) executable. */
|
||||
nodePath: string;
|
||||
/** Set ELECTRON_RUN_AS_NODE so VS Code's Electron binary behaves as Node. */
|
||||
runAsNode: boolean;
|
||||
/** Scratch directory for input/output files. */
|
||||
workDir: string;
|
||||
quality?: string;
|
||||
repoRoot?: string;
|
||||
timeoutMs?: number;
|
||||
log?: (line: string) => void;
|
||||
}
|
||||
|
||||
export interface ArchifyDiagnostic {
|
||||
code?: string;
|
||||
severity?: 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
subject?: { path?: string; identity?: string; [key: string]: unknown };
|
||||
supportedFixes?: string[];
|
||||
}
|
||||
|
||||
export interface ValidateResult {
|
||||
ok: boolean;
|
||||
diagnostics: ArchifyDiagnostic[];
|
||||
/** Set when the CLI did not produce parseable JSON. */
|
||||
crash?: string;
|
||||
}
|
||||
|
||||
export interface RenderResult {
|
||||
ok: boolean;
|
||||
html?: string;
|
||||
error?: string;
|
||||
/** Set when the diagram rendered without its source evidence. */
|
||||
evidenceNotice?: string;
|
||||
}
|
||||
|
||||
interface RunResult {
|
||||
code: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
const EVIDENCE_ERROR_RE = /repository-evidence\/|source evidence|--repo-root|Evidence (root|repository|revision)/i;
|
||||
|
||||
function run(options: RuntimeOptions, args: string[], env: Record<string, string> = {}): Promise<RunResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
options.log?.(`archify ${args.join(' ')}`);
|
||||
const child = spawn(options.nodePath, [options.cliPath, ...args], {
|
||||
cwd: options.workDir,
|
||||
env: {
|
||||
...process.env,
|
||||
...(options.runAsNode ? { ELECTRON_RUN_AS_NODE: '1' } : {}),
|
||||
...env,
|
||||
},
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8').on('data', (chunk: string) => (stdout += chunk));
|
||||
child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk));
|
||||
const timer = setTimeout(() => child.kill(), options.timeoutMs ?? 60_000);
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (stderr.trim()) options.log?.(stderr.trim());
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function commonArgs(options: RuntimeOptions, withRepoRoot: boolean): string[] {
|
||||
const args: string[] = [];
|
||||
if (options.quality) args.push('--quality', options.quality);
|
||||
if (withRepoRoot && options.repoRoot) args.push('--repo-root', options.repoRoot);
|
||||
return args;
|
||||
}
|
||||
|
||||
async function withScratch<T>(options: RuntimeOptions, body: (dir: string) => Promise<T>): Promise<T> {
|
||||
await fs.mkdir(options.workDir, { recursive: true });
|
||||
const dir = await fs.mkdtemp(path.join(options.workDir, 'run-'));
|
||||
try {
|
||||
return await body(dir);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function validate(options: RuntimeOptions, type: DiagramType, text: string): Promise<ValidateResult> {
|
||||
return withScratch(options, async (dir) => {
|
||||
const input = path.join(dir, `diagram.${type}.json`);
|
||||
await fs.writeFile(input, text, 'utf8');
|
||||
const result = await run(options, ['validate', type, input, '--json', ...commonArgs(options, true)]);
|
||||
try {
|
||||
const report = JSON.parse(result.stdout);
|
||||
const diagnostics: ArchifyDiagnostic[] = Array.isArray(report.diagnostics) ? report.diagnostics : [];
|
||||
if (!report.ok && diagnostics.length === 0 && report.error) {
|
||||
diagnostics.push({ severity: 'error', message: String(report.error) });
|
||||
}
|
||||
return { ok: Boolean(report.ok), diagnostics };
|
||||
} catch {
|
||||
const crash = (result.stderr || result.stdout || `archify exited with code ${result.code}`).trim();
|
||||
return { ok: false, diagnostics: [], crash };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function renderOnce(
|
||||
options: RuntimeOptions,
|
||||
type: DiagramType,
|
||||
text: string,
|
||||
withRepoRoot: boolean,
|
||||
): Promise<RenderResult> {
|
||||
return withScratch(options, async (dir) => {
|
||||
const input = path.join(dir, `diagram.${type}.json`);
|
||||
const output = path.join(dir, 'diagram.html');
|
||||
await fs.writeFile(input, text, 'utf8');
|
||||
const result = await run(options, ['render', type, input, output, ...commonArgs(options, withRepoRoot)]);
|
||||
if (result.code === 0) {
|
||||
try {
|
||||
return { ok: true, html: await fs.readFile(output, 'utf8') };
|
||||
} catch {
|
||||
// fall through to the error below
|
||||
}
|
||||
}
|
||||
const error = (result.stderr || result.stdout || `archify exited with code ${result.code}`).trim();
|
||||
return { ok: false, error };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a diagram to HTML. With `evidence: 'fallback'`, a diagram whose
|
||||
* source evidence cannot be verified is rendered again without it, and the
|
||||
* reason is returned as `evidenceNotice`.
|
||||
*/
|
||||
export async function renderDiagram(
|
||||
options: RuntimeOptions,
|
||||
source: DiagramSource & { text: string },
|
||||
evidence: 'fallback' | 'strict',
|
||||
): Promise<RenderResult> {
|
||||
const withEvidence = hasSourceEvidence(source.type, source.diagram);
|
||||
if (withEvidence && evidence === 'fallback' && !options.repoRoot) {
|
||||
const stripped = JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2);
|
||||
const result = await renderOnce(options, source.type, stripped, false);
|
||||
return {
|
||||
...result,
|
||||
evidenceNotice: 'Source links are hidden: the diagram is not inside a Git checkout, so its source evidence cannot be verified. Set "archify.repoRoot" to the matching checkout.',
|
||||
};
|
||||
}
|
||||
const first = await renderOnce(options, source.type, source.text, withEvidence);
|
||||
if (first.ok || !withEvidence || evidence !== 'fallback' || !isEvidenceError(first.error)) {
|
||||
return first;
|
||||
}
|
||||
const stripped = JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2);
|
||||
const second = await renderOnce(options, source.type, stripped, false);
|
||||
if (!second.ok) return first;
|
||||
const reason = (first.error ?? '').split('\n').find((line) => line.trim()) ?? 'verification failed';
|
||||
return { ...second, evidenceNotice: `Source links are hidden: ${reason.replace(/^\[[^\]]+\]\s*/, '')}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders to a file the user chose. Source evidence is verified strictly
|
||||
* unless `stripEvidence` is set, in which case it is removed first.
|
||||
*/
|
||||
export async function renderToFile(
|
||||
options: RuntimeOptions,
|
||||
source: DiagramSource & { text: string },
|
||||
outputPath: string,
|
||||
stripEvidence = false,
|
||||
): Promise<RenderResult> {
|
||||
const text = stripEvidence
|
||||
? JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2)
|
||||
: source.text;
|
||||
const withEvidence = !stripEvidence && hasSourceEvidence(source.type, source.diagram);
|
||||
return withScratch(options, async (dir) => {
|
||||
const input = path.join(dir, `diagram.${source.type}.json`);
|
||||
await fs.writeFile(input, text, 'utf8');
|
||||
const result = await run(options, ['render', source.type, input, outputPath, ...commonArgs(options, withEvidence)]);
|
||||
if (result.code === 0) return { ok: true };
|
||||
return { ok: false, error: (result.stderr || result.stdout || `archify exited with code ${result.code}`).trim() };
|
||||
});
|
||||
}
|
||||
|
||||
export function isEvidenceError(error: string | undefined): boolean {
|
||||
return EVIDENCE_ERROR_RE.test(error ?? '');
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { RuntimeOptions } from './renderer';
|
||||
import { ViewerTheme } from './webviewHtml';
|
||||
|
||||
let output: vscode.LogOutputChannel | undefined;
|
||||
|
||||
export function log(): vscode.LogOutputChannel {
|
||||
output ??= vscode.window.createOutputChannel('Archify', { log: true });
|
||||
return output;
|
||||
}
|
||||
|
||||
const gitRoots = new Map<string, Promise<string | undefined>>();
|
||||
|
||||
function gitTopLevel(dir: string): Promise<string | undefined> {
|
||||
let pending = gitRoots.get(dir);
|
||||
if (!pending) {
|
||||
pending = new Promise((resolve) => {
|
||||
execFile('git', ['rev-parse', '--show-toplevel'], { cwd: dir, timeout: 10_000 }, (error, stdout) => {
|
||||
resolve(error ? undefined : path.resolve(stdout.trim()));
|
||||
});
|
||||
});
|
||||
gitRoots.set(dir, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function clearGitRootCache(): void {
|
||||
gitRoots.clear();
|
||||
}
|
||||
|
||||
/** The checkout used to verify source evidence for a file, if any. */
|
||||
export async function repoRootFor(uri: vscode.Uri | undefined): Promise<string | undefined> {
|
||||
const configured = vscode.workspace.getConfiguration('archify', uri).get<string>('repoRoot', '').trim();
|
||||
if (configured) {
|
||||
if (path.isAbsolute(configured)) return configured;
|
||||
const folder = (uri && vscode.workspace.getWorkspaceFolder(uri)) ?? vscode.workspace.workspaceFolders?.[0];
|
||||
return folder ? path.resolve(folder.uri.fsPath, configured) : path.resolve(configured);
|
||||
}
|
||||
if (!uri || uri.scheme !== 'file') return undefined;
|
||||
return gitTopLevel(path.dirname(uri.fsPath));
|
||||
}
|
||||
|
||||
export async function runtimeFor(context: vscode.ExtensionContext, uri: vscode.Uri | undefined): Promise<RuntimeOptions> {
|
||||
const config = vscode.workspace.getConfiguration('archify', uri);
|
||||
const nodePath = config.get<string>('nodePath', '').trim();
|
||||
const quality = config.get<string>('quality', '');
|
||||
return {
|
||||
cliPath: context.asAbsolutePath(path.join('vendor', 'archify', 'bin', 'archify.mjs')),
|
||||
nodePath: nodePath || process.execPath,
|
||||
runAsNode: !nodePath,
|
||||
workDir: path.join(context.globalStorageUri.fsPath, 'renders'),
|
||||
quality: quality || undefined,
|
||||
repoRoot: await repoRootFor(uri),
|
||||
log: (line) => log().debug(line),
|
||||
};
|
||||
}
|
||||
|
||||
export function evidenceMode(uri: vscode.Uri | undefined): 'fallback' | 'strict' {
|
||||
return vscode.workspace.getConfiguration('archify', uri).get<'fallback' | 'strict'>('sourceEvidence', 'fallback');
|
||||
}
|
||||
|
||||
export function viewerTheme(): ViewerTheme {
|
||||
const setting = vscode.workspace.getConfiguration('archify').get<string>('theme', 'vscode');
|
||||
if (setting === 'dark' || setting === 'light') return setting;
|
||||
if (setting === 'diagram') return undefined;
|
||||
const kind = vscode.window.activeColorTheme.kind;
|
||||
return kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
/** Current text of a document: the open editor buffer, else the file on disk. */
|
||||
export async function readText(uri: vscode.Uri): Promise<string> {
|
||||
const open = vscode.workspace.textDocuments.find((doc) => doc.uri.toString() === uri.toString());
|
||||
if (open) return open.getText();
|
||||
return new TextDecoder('utf-8').decode(await vscode.workspace.fs.readFile(uri));
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
export type ViewerTheme = 'dark' | 'light' | undefined;
|
||||
|
||||
export interface PageOptions {
|
||||
/** webview.cspSource */
|
||||
cspSource: string;
|
||||
nonce: string;
|
||||
/** Forced diagram theme; undefined keeps the diagram's own choice. */
|
||||
theme: ViewerTheme;
|
||||
/** Location hash to restore (Archify keeps focus/route state there). */
|
||||
hash?: string;
|
||||
/** Opaque state handed to vscode.setState for panel restore. */
|
||||
state?: unknown;
|
||||
/** Banner shown on top of the diagram. */
|
||||
notice?: { text: string; kind: 'info' | 'warning' | 'error' };
|
||||
}
|
||||
|
||||
export function makeNonce(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let nonce = '';
|
||||
for (let i = 0; i < 32; i += 1) nonce += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
return nonce;
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** JSON that is safe to embed inside an inline <script>. */
|
||||
function scriptJson(value: unknown): string {
|
||||
return JSON.stringify(value ?? null)
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/>/g, '\\u003e')
|
||||
.replace(/\u2028/g, '\\u2028')
|
||||
.replace(/\u2029/g, '\\u2029');
|
||||
}
|
||||
|
||||
export function contentSecurityPolicy(cspSource: string, nonce: string): string {
|
||||
return [
|
||||
"default-src 'none'",
|
||||
`img-src ${cspSource} https: data: blob:`,
|
||||
`media-src ${cspSource} data: blob:`,
|
||||
`font-src ${cspSource} data:`,
|
||||
`style-src ${cspSource} 'unsafe-inline'`,
|
||||
`script-src 'nonce-${nonce}'`,
|
||||
'connect-src data: blob:',
|
||||
'worker-src blob:',
|
||||
].join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs before any page script: pins the theme, restores the hash, and
|
||||
* routes links, window.open and export downloads to the extension, since a
|
||||
* webview cannot navigate or download on its own.
|
||||
*/
|
||||
function bridgeScript(options: PageOptions): string {
|
||||
const config = { theme: options.theme, hash: options.hash, state: options.state, notice: options.notice };
|
||||
return `(function () {
|
||||
var config = ${scriptJson(config)};
|
||||
var vscode = acquireVsCodeApi();
|
||||
if (config.state) vscode.setState(config.state);
|
||||
if (config.theme) {
|
||||
try { localStorage.setItem('archify-theme', config.theme); } catch (_) {}
|
||||
}
|
||||
if (config.hash && !location.hash) {
|
||||
try { history.replaceState(null, '', config.hash); } catch (_) {}
|
||||
}
|
||||
window.addEventListener('hashchange', function () {
|
||||
if (config.state) vscode.setState(Object.assign({}, config.state, { hash: location.hash }));
|
||||
vscode.postMessage({ type: 'hash', hash: location.hash });
|
||||
});
|
||||
|
||||
function post(message) { vscode.postMessage(message); }
|
||||
|
||||
function saveBlobUrl(url, filename) {
|
||||
fetch(url).then(function (response) { return response.blob(); }).then(function (blob) {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
var data = String(reader.result);
|
||||
post({ type: 'save', filename: filename || 'archify-export', base64: data.slice(data.indexOf(',') + 1) });
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
}).catch(function (error) {
|
||||
post({ type: 'error', message: 'Export failed: ' + (error && error.message || error) });
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var target = event.target;
|
||||
var anchor = target && target.closest ? target.closest('a[href]') : null;
|
||||
if (!anchor) return;
|
||||
var href = anchor.getAttribute('href') || '';
|
||||
if (anchor.hasAttribute('download') && /^(blob|data):/i.test(anchor.href)) {
|
||||
event.preventDefault();
|
||||
saveBlobUrl(anchor.href, anchor.getAttribute('download'));
|
||||
return;
|
||||
}
|
||||
if (href.charAt(0) === '#') return;
|
||||
event.preventDefault();
|
||||
if (anchor.classList.contains('semantic-passport-source')) {
|
||||
var pathNode = anchor.querySelector('small');
|
||||
var match = /#L(\\d+)(?:-L(\\d+))?$/.exec(anchor.href);
|
||||
post({
|
||||
type: 'openSource',
|
||||
path: pathNode ? pathNode.textContent : '',
|
||||
line: match ? Number(match[1]) : undefined,
|
||||
endLine: match && match[2] ? Number(match[2]) : undefined,
|
||||
href: anchor.href
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (/^(https?|mailto):/i.test(href)) post({ type: 'openExternal', href: href });
|
||||
else post({ type: 'openRelative', href: href });
|
||||
}, true);
|
||||
|
||||
var nativeOpen = window.open;
|
||||
window.open = function (url) {
|
||||
if (typeof url === 'string' && /^(https?|mailto):/i.test(url)) {
|
||||
post({ type: 'openExternal', href: url });
|
||||
return null;
|
||||
}
|
||||
return nativeOpen ? nativeOpen.apply(window, arguments) : null;
|
||||
};
|
||||
|
||||
function showNotice(notice) {
|
||||
var existing = document.getElementById('archify-vscode-notice');
|
||||
if (existing) existing.remove();
|
||||
if (!notice || !notice.text) return;
|
||||
var bar = document.createElement('div');
|
||||
bar.id = 'archify-vscode-notice';
|
||||
bar.setAttribute('role', notice.kind === 'error' ? 'alert' : 'status');
|
||||
bar.className = 'archify-vscode-notice archify-vscode-notice-' + notice.kind;
|
||||
var text = document.createElement('span');
|
||||
text.textContent = notice.text;
|
||||
var close = document.createElement('button');
|
||||
close.type = 'button';
|
||||
close.textContent = '\\u00d7';
|
||||
close.title = 'Dismiss';
|
||||
close.addEventListener('click', function () { bar.remove(); });
|
||||
bar.appendChild(text);
|
||||
bar.appendChild(close);
|
||||
(document.body || document.documentElement).appendChild(bar);
|
||||
}
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
var message = event.data || {};
|
||||
if (message.type === 'notice') showNotice(message.notice);
|
||||
});
|
||||
if (config.notice) {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); });
|
||||
} else {
|
||||
showNotice(config.notice);
|
||||
}
|
||||
}
|
||||
})();`;
|
||||
}
|
||||
|
||||
const NOTICE_STYLE = `
|
||||
.archify-vscode-notice {
|
||||
position: fixed; left: 12px; right: 12px; bottom: 12px; z-index: 2147483647;
|
||||
display: flex; gap: 12px; align-items: flex-start; justify-content: space-between;
|
||||
padding: 8px 12px; border-radius: 6px; font: 12px/1.45 var(--vscode-font-family, system-ui, sans-serif);
|
||||
color: var(--vscode-editorWidget-foreground, #ddd); background: var(--vscode-editorWidget-background, #252526);
|
||||
border: 1px solid var(--vscode-editorWidget-border, #454545); box-shadow: 0 4px 16px rgba(0,0,0,.35);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.archify-vscode-notice-warning { border-left: 4px solid var(--vscode-editorWarning-foreground, #cca700); }
|
||||
.archify-vscode-notice-error { border-left: 4px solid var(--vscode-editorError-foreground, #f14c4c); }
|
||||
.archify-vscode-notice-info { border-left: 4px solid var(--vscode-editorInfo-foreground, #3794ff); }
|
||||
.archify-vscode-notice button {
|
||||
background: none; border: 0; color: inherit; cursor: pointer; font-size: 16px; line-height: 1; padding: 0 2px;
|
||||
}`;
|
||||
|
||||
/** Prepares Archify HTML for a VS Code webview. */
|
||||
export function prepareDiagramHtml(html: string, options: PageOptions): string {
|
||||
const head =
|
||||
`<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">\n` +
|
||||
`<script nonce="${options.nonce}">${bridgeScript(options)}</script>\n` +
|
||||
`<style>${NOTICE_STYLE}</style>\n`;
|
||||
|
||||
let page = html
|
||||
// Drop any CSP the file brings along; ours is the one that applies.
|
||||
.replace(/<meta\s+http-equiv=["']Content-Security-Policy["'][^>]*>/gi, '')
|
||||
// Allow the page's own inline scripts to run under the nonce policy.
|
||||
.replace(/<script\b(?![^>]*\bnonce=)/gi, `<script nonce="${options.nonce}"`);
|
||||
|
||||
const headMatch = /<head\b[^>]*>/i.exec(page);
|
||||
if (headMatch) {
|
||||
const at = headMatch.index + headMatch[0].length;
|
||||
page = `${page.slice(0, at)}\n${head}${page.slice(at)}`;
|
||||
} else {
|
||||
page = `<!DOCTYPE html><html><head>${head}</head><body>${page}</body></html>`;
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
/** A small VS Code-styled page for loading and error states. */
|
||||
export function messagePage(
|
||||
options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>,
|
||||
title: string,
|
||||
body: string,
|
||||
detail?: string,
|
||||
): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">
|
||||
<script nonce="${options.nonce}">(function () { var vscode = acquireVsCodeApi(); var state = ${scriptJson(options.state)}; if (state) vscode.setState(state); })();</script>
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); background: var(--vscode-editor-background); padding: 24px 28px; }
|
||||
h2 { font-weight: 600; font-size: 15px; margin: 0 0 8px; }
|
||||
p { margin: 0 0 12px; color: var(--vscode-descriptionForeground); }
|
||||
pre { font-family: var(--vscode-editor-font-family); font-size: 12px; white-space: pre-wrap; word-break: break-word;
|
||||
background: var(--vscode-textCodeBlock-background); padding: 12px 14px; border-radius: 4px;
|
||||
border-left: 3px solid var(--vscode-editorError-foreground); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>${escapeHtml(title)}</h2>
|
||||
<p>${escapeHtml(body)}</p>
|
||||
${detail ? `<pre>${escapeHtml(detail)}</pre>` : ''}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { log, repoRootFor } from './runtime';
|
||||
|
||||
export type WebviewMessage =
|
||||
| { type: 'hash'; hash: string }
|
||||
| { type: 'save'; filename: string; base64: string }
|
||||
| { type: 'openExternal'; href: string }
|
||||
| { type: 'openRelative'; href: string }
|
||||
| { type: 'openSource'; path: string; line?: number; endLine?: number; href?: string }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
/** Column for opening files next to a webview without covering it. */
|
||||
function besideColumn(panel: vscode.WebviewPanel): vscode.ViewColumn {
|
||||
return panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
|
||||
}
|
||||
|
||||
async function exists(uri: vscode.Uri): Promise<boolean> {
|
||||
try {
|
||||
await vscode.workspace.fs.stat(uri);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a diagram's source evidence in the editor. The path is relative to
|
||||
* the repository root; the diagram's checkout and the workspace folders are
|
||||
* tried in turn before falling back to the web link.
|
||||
*/
|
||||
async function openSource(
|
||||
message: Extract<WebviewMessage, { type: 'openSource' }>,
|
||||
context: vscode.Uri,
|
||||
panel: vscode.WebviewPanel,
|
||||
): Promise<void> {
|
||||
const relative = message.path.replace(/^\/+/, '');
|
||||
const roots = new Set<string>();
|
||||
const repoRoot = await repoRootFor(context);
|
||||
if (repoRoot) roots.add(repoRoot);
|
||||
for (const folder of vscode.workspace.workspaceFolders ?? []) roots.add(folder.uri.fsPath);
|
||||
|
||||
for (const root of roots) {
|
||||
const candidate = vscode.Uri.file(path.join(root, relative));
|
||||
if (relative && (await exists(candidate))) {
|
||||
const start = Math.max((message.line ?? 1) - 1, 0);
|
||||
const end = Math.max((message.endLine ?? message.line ?? 1) - 1, start);
|
||||
const selection = new vscode.Range(start, 0, end, Number.MAX_SAFE_INTEGER);
|
||||
await vscode.window.showTextDocument(candidate, { viewColumn: besideColumn(panel), selection });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (message.href && /^https?:/i.test(message.href)) {
|
||||
await vscode.env.openExternal(vscode.Uri.parse(message.href));
|
||||
return;
|
||||
}
|
||||
vscode.window.showWarningMessage(`Archify: source file "${message.path}" was not found in the workspace.`);
|
||||
}
|
||||
|
||||
export async function handleWebviewMessage(
|
||||
message: WebviewMessage,
|
||||
context: vscode.Uri,
|
||||
panel: vscode.WebviewPanel,
|
||||
): Promise<void> {
|
||||
try {
|
||||
switch (message.type) {
|
||||
case 'openExternal':
|
||||
await vscode.env.openExternal(vscode.Uri.parse(message.href));
|
||||
break;
|
||||
case 'openRelative': {
|
||||
const [file] = message.href.split('#');
|
||||
if (!file) break;
|
||||
const target = vscode.Uri.joinPath(context, '..', decodeURIComponent(file));
|
||||
await vscode.commands.executeCommand('vscode.open', target, besideColumn(panel));
|
||||
break;
|
||||
}
|
||||
case 'openSource':
|
||||
await openSource(message, context, panel);
|
||||
break;
|
||||
case 'save': {
|
||||
const name = path.basename(message.filename || 'archify-export');
|
||||
const defaultUri = vscode.Uri.joinPath(context, '..', name);
|
||||
const target = await vscode.window.showSaveDialog({ defaultUri, saveLabel: 'Export' });
|
||||
if (!target) break;
|
||||
await vscode.workspace.fs.writeFile(target, Buffer.from(message.base64, 'base64'));
|
||||
const choice = await vscode.window.showInformationMessage(`Exported ${path.basename(target.fsPath)}`, 'Reveal');
|
||||
if (choice === 'Reveal') await vscode.commands.executeCommand('revealFileInOS', target);
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
vscode.window.showErrorMessage(`Archify: ${message.message}`);
|
||||
break;
|
||||
case 'hash':
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
log().error(error instanceof Error ? error : String(error));
|
||||
vscode.window.showErrorMessage(`Archify: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { hasSourceEvidence, isArchifyHtml, readDiagramSource, stripSourceEvidence, typeFromFilename } from '../src/detect';
|
||||
import { pointerSegments, rangeForPointer } from '../src/jsonPointer';
|
||||
|
||||
test('reads diagram_type from content', () => {
|
||||
const source = readDiagramSource('{"schema_version":1,"diagram_type":"sequence","meta":{}}', 'plain.json');
|
||||
assert.equal(source?.type, 'sequence');
|
||||
});
|
||||
|
||||
test('falls back to the file name for unparseable sources', () => {
|
||||
assert.equal(readDiagramSource('{"diagram_type": ', 'x.workflow.json')?.type, 'workflow');
|
||||
assert.equal(readDiagramSource('{"name":"pkg"}', 'package.json'), undefined);
|
||||
assert.equal(typeFromFilename('/a/b/Web.Architecture.json'), 'architecture');
|
||||
});
|
||||
|
||||
test('recognizes Archify HTML by its generator meta', () => {
|
||||
assert.ok(isArchifyHtml('<html><head><meta name="generator" content="archify 3.0.1"></head></html>'));
|
||||
assert.ok(!isArchifyHtml('<html><head><meta name="generator" content="hugo"></head></html>'));
|
||||
});
|
||||
|
||||
test('strips source evidence without touching the original', () => {
|
||||
const diagram = {
|
||||
meta: { title: 't', repository: { url: 'https://github.com/a/b', revision: 'x' } },
|
||||
components: [{ id: 'a', sources: [{ path: 'src/a.ts' }] }, { id: 'b' }],
|
||||
};
|
||||
assert.ok(hasSourceEvidence('architecture', diagram));
|
||||
const stripped = stripSourceEvidence('architecture', diagram);
|
||||
assert.ok(!hasSourceEvidence('architecture', stripped));
|
||||
assert.ok(diagram.meta.repository, 'original keeps its repository');
|
||||
assert.deepEqual((stripped.components as unknown[])[1], { id: 'b' });
|
||||
});
|
||||
|
||||
test('json pointer segments decode escapes and indexes', () => {
|
||||
assert.deepEqual(pointerSegments('/components/0/a~1b~0c'), ['components', 0, 'a/b~c']);
|
||||
assert.deepEqual(pointerSegments(''), []);
|
||||
});
|
||||
|
||||
test('json pointer ranges point at keys for containers and values for leaves', () => {
|
||||
const text = '{\n "meta": { "title": "x" },\n "components": [ { "id": "a" } ]\n}';
|
||||
const meta = rangeForPointer(text, '/meta');
|
||||
assert.equal(text.slice(meta.start, meta.end), '"meta"');
|
||||
const title = rangeForPointer(text, '/meta/title');
|
||||
assert.equal(text.slice(title.start, title.end), '"x"');
|
||||
const item = rangeForPointer(text, '/components/0');
|
||||
assert.equal(text.slice(item.start, item.end), '{');
|
||||
const missing = rangeForPointer(text, '/components/0/label');
|
||||
assert.equal(text.slice(missing.start, missing.end), '{', 'falls back to the nearest existing ancestor');
|
||||
});
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "lifecycle",
|
||||
"meta": {
|
||||
"title": "Agent Run Lifecycle",
|
||||
"output": "examples/lifecycle-agent-run.html",
|
||||
"viewBox": [1030, 630],
|
||||
"animation": "trace",
|
||||
"quality_profile": "showcase"
|
||||
},
|
||||
"lanes": [
|
||||
{ "id": "main", "label": "Lifecycle phases" },
|
||||
{ "id": "waiting", "label": "Interruptions" },
|
||||
{ "id": "exceptions", "label": "Recovery loop" },
|
||||
{ "id": "terminal", "label": "Terminal exits" }
|
||||
],
|
||||
"states": [
|
||||
{ "id": "queued", "type": "start", "label": "Queued", "sublabel": "request accepted", "lane": "main", "col": 0, "step": "01", "tag": "entry" },
|
||||
{ "id": "planning", "type": "active", "label": "Planning", "sublabel": "build task graph", "lane": "main", "col": 1, "step": "02", "tag": "model" },
|
||||
{ "id": "executing", "type": "active", "label": "Executing", "sublabel": "tool calls", "lane": "main", "col": 2, "step": "03", "tag": "work" },
|
||||
{ "id": "reviewing", "type": "decision", "label": "Reviewing", "sublabel": "quality gate", "lane": "main", "col": 3, "step": "04", "tag": "check" },
|
||||
{ "id": "completed", "type": "success", "label": "Completed", "sublabel": "final response", "lane": "main", "col": 4, "step": "05", "tag": "done" },
|
||||
{ "id": "approval", "type": "waiting", "label": "Needs Approval", "sublabel": "human gate", "lane": "waiting", "col": 0, "tag": "pause" },
|
||||
{ "id": "blocked", "type": "waiting", "label": "Blocked", "sublabel": "missing input", "lane": "waiting", "col": 1, "tag": "wait" },
|
||||
{ "id": "failed", "type": "failure", "label": "Failed", "sublabel": "recoverable error", "lane": "exceptions", "col": 0, "yOffset": 78, "tag": "retryable" },
|
||||
{ "id": "cancelled", "type": "failure", "label": "Cancelled", "sublabel": "user stopped", "lane": "terminal", "col": 0, "tag": "terminal" },
|
||||
{ "id": "expired", "type": "failure", "label": "Expired", "sublabel": "timeout", "lane": "terminal", "col": 1, "tag": "terminal" }
|
||||
],
|
||||
"transitions": [
|
||||
{ "id": "approval-needed", "from": "executing", "to": "approval", "variant": "security", "fromSide": "bottom", "toSide": "top", "route": "straight" },
|
||||
{ "id": "review-blocked", "from": "reviewing", "to": "blocked", "variant": "default", "route": "drop" },
|
||||
{ "id": "execution-failed", "from": "executing", "to": "failed", "variant": "security", "fromSide": "left", "toSide": "left", "via": [[320, 157], [320, 385]] },
|
||||
{ "id": "failed-retry", "from": "failed", "to": "executing", "variant": "emphasis", "fromSide": "left", "toSide": "top", "via": [[20, 385], [20, 80], [402, 80]] },
|
||||
{ "id": "block-expired", "from": "blocked", "to": "expired", "variant": "security", "fromSide": "bottom", "toSide": "top", "route": "straight" },
|
||||
{ "id": "approval-cancelled", "from": "approval", "to": "cancelled", "variant": "security", "fromSide": "bottom", "toSide": "top", "via": [[480, 336], [480, 432], [402, 432]] }
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"dot": "emerald",
|
||||
"title": "Main Path + Waits",
|
||||
"items": [
|
||||
"The run has five ordered phases from queue to completion",
|
||||
"Approval and missing input pause the run without ending it"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "rose",
|
||||
"title": "Recovery + Terminal Exits",
|
||||
"items": [
|
||||
"Failed loops back while retry budget remains",
|
||||
"Cancelled and Expired are terminal exits with no return path"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"diagram_type": "workflow",
|
||||
"meta": {
|
||||
"title": "Agent Tool Call Workflow",
|
||||
"animation": "trace",
|
||||
"visual_preset": "signal-flow",
|
||||
"quality_profile": "showcase",
|
||||
"output": "examples/workflow-agent-tool-call-rendered.html"
|
||||
},
|
||||
"lanes": [
|
||||
{ "id": "ui", "label": "User Interface" },
|
||||
{ "id": "agent", "label": "Agent Runtime" },
|
||||
{ "id": "policy", "label": "Policy & Recovery", "variant": "exception" },
|
||||
{ "id": "tools", "label": "Tool Execution & Evidence" }
|
||||
],
|
||||
"phases": [
|
||||
{ "id": "intake", "label": "Intake", "fromCol": 0, "toCol": 1 },
|
||||
{ "id": "reasoning", "label": "Plan + route", "fromCol": 2, "toCol": 3, "variant": "emphasis" },
|
||||
{ "id": "execution", "label": "Execute + report", "fromCol": 4, "toCol": 5, "variant": "dashed" }
|
||||
],
|
||||
"groups": [
|
||||
{ "id": "agent_loop", "label": "Planning loop", "lane": "agent", "fromCol": 2, "toCol": 3, "variant": "emphasis" },
|
||||
{ "id": "exception_path", "label": "Human or policy stop", "lane": "policy", "fromCol": 3, "toCol": 5, "variant": "security" },
|
||||
{ "id": "evidence_path", "label": "Evidence path", "lane": "tools", "fromCol": 1, "toCol": 2, "variant": "dashed" },
|
||||
{ "id": "tool_work", "label": "Tool work", "lane": "tools", "fromCol": 4, "toCol": 5, "variant": "dashed" }
|
||||
],
|
||||
"mainPath": ["user", "chat", "planner", "router", "approval", "tool", "external", "final"],
|
||||
"nodes": [
|
||||
{ "id": "user", "lane": "ui", "col": 0, "type": "external", "label": "User", "sublabel": "asks for work", "width": 132 },
|
||||
{ "id": "chat", "lane": "ui", "col": 1, "type": "frontend", "label": "Chat Surface", "sublabel": "thread + files", "width": 132 },
|
||||
{ "id": "final", "lane": "ui", "col": 5, "type": "backend", "label": "Final Reply", "sublabel": "answer + changes", "width": 132 },
|
||||
{ "id": "planner", "lane": "agent", "col": 2, "type": "backend", "label": "Agent Planner", "sublabel": "plan next step", "tag": "context aware", "width": 132 },
|
||||
{ "id": "router", "lane": "agent", "col": 3, "type": "backend", "label": "Tool Router", "sublabel": "choose capability", "width": 132 },
|
||||
{ "id": "approval", "lane": "policy", "col": 3, "type": "security", "label": "Approval Gate", "sublabel": "scope + consent", "tag": "block risky ops", "width": 132 },
|
||||
{ "id": "blocked", "lane": "policy", "col": 4, "type": "security", "label": "Blocked", "sublabel": "wait or reject", "width": 132 },
|
||||
{ "id": "retry", "lane": "policy", "col": 5, "type": "messagebus", "label": "Retry Path", "sublabel": "revise request", "width": 132 },
|
||||
{ "id": "tool", "lane": "tools", "col": 4, "type": "messagebus", "label": "Tool Call", "sublabel": "shell / browser / MCP", "tag": "structured result", "width": 132 },
|
||||
{ "id": "external", "lane": "tools", "col": 5, "type": "cloud", "label": "External API", "sublabel": "network service", "width": 132 },
|
||||
{ "id": "store", "lane": "tools", "col": 1, "type": "database", "label": "Context Store", "sublabel": "repo + memory", "width": 132 },
|
||||
{ "id": "trace", "lane": "tools", "col": 2, "type": "database", "label": "Trace Log", "sublabel": "events + output", "width": 132 }
|
||||
],
|
||||
"edges": [
|
||||
{ "id": "request-chat", "from": "user", "to": "chat", "variant": "default" },
|
||||
{ "id": "plan-request", "from": "chat", "to": "planner", "label": "plan", "variant": "emphasis" },
|
||||
{ "id": "planner-route", "from": "planner", "to": "router", "variant": "default" },
|
||||
{ "id": "approval-check", "from": "router", "to": "approval", "label": "needs approval?", "variant": "security" },
|
||||
{ "id": "approved-tool", "from": "approval", "to": "tool", "variant": "emphasis" },
|
||||
{ "id": "approval-denied", "from": "approval", "to": "blocked", "label": "denied", "variant": "security", "role": "error" },
|
||||
{ "id": "retry-request", "from": "blocked", "to": "retry", "variant": "dashed", "role": "branch" },
|
||||
{ "id": "tool-external-call", "from": "tool", "to": "external", "variant": "default" },
|
||||
{ "id": "external-reply", "from": "external", "to": "final", "variant": "emphasis", "role": "return", "fromSide": "right", "toSide": "right", "route": "outside-right", "width": 1.2 },
|
||||
{ "id": "record-result", "from": "external", "to": "trace", "label": "record result", "variant": "dashed", "fromSide": "bottom", "toSide": "bottom", "route": "bottom-channel", "labelSegment": 1 },
|
||||
{ "id": "write-trace-memory", "from": "store", "to": "trace", "label": "trace + memory", "variant": "dashed" }
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"dot": "cyan",
|
||||
"title": "Compiler Contract",
|
||||
"items": [
|
||||
"Lanes and columns determine node placement",
|
||||
"Labels reserve clearance; routes stay orthogonal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "rose",
|
||||
"title": "Runtime Semantics",
|
||||
"items": [
|
||||
"Approval gates risky work before tool execution",
|
||||
"Evidence returns through isolated trace and memory"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "sequence",
|
||||
"meta": {
|
||||
"title": "Cache Miss Request Sequence",
|
||||
"output": "examples/sequence-cache-miss-request.html",
|
||||
"viewBox": [1080, 580],
|
||||
"column_fit": "spread",
|
||||
"animation": "trace",
|
||||
"quality_profile": "showcase"
|
||||
},
|
||||
"participants": [
|
||||
{ "id": "user", "type": "external", "label": "User", "sublabel": "browser session" },
|
||||
{ "id": "web", "type": "frontend", "label": "Web App", "sublabel": "React UI" },
|
||||
{ "id": "api", "type": "backend", "label": "API", "sublabel": "request handler" },
|
||||
{ "id": "auth", "type": "security", "label": "Auth", "sublabel": "JWT verify" },
|
||||
{ "id": "redis", "type": "database", "label": "Redis", "sublabel": "cache" },
|
||||
{ "id": "db", "type": "database", "label": "Postgres", "sublabel": "source of truth" },
|
||||
{ "id": "trace", "type": "messagebus", "label": "Trace", "sublabel": "async event" }
|
||||
],
|
||||
"segments": [
|
||||
{ "from": 150, "to": 250, "label": "Request" },
|
||||
{ "from": 260, "to": 370, "label": "Fallback" },
|
||||
{ "from": 380, "to": 480, "label": "Response + trace" }
|
||||
],
|
||||
"messages": [
|
||||
{ "id": "open-page", "from": "user", "to": "web", "y": 160, "label": "open page", "variant": "default" },
|
||||
{ "id": "dashboard-request", "from": "web", "to": "api", "y": 185, "label": "GET /dashboard", "variant": "emphasis" },
|
||||
{ "id": "verify-jwt", "from": "api", "to": "auth", "y": 210, "label": "verify JWT", "variant": "security" },
|
||||
{ "id": "auth-claims", "from": "auth", "to": "api", "y": 238, "label": "claims ok", "variant": "return" },
|
||||
{ "id": "cache-read", "from": "api", "to": "redis", "y": 270, "label": "read cache", "variant": "default" },
|
||||
{ "id": "cache-miss", "from": "redis", "to": "api", "y": 298, "label": "miss", "variant": "return" },
|
||||
{ "id": "profile-query", "from": "api", "to": "db", "y": 330, "label": "query profile + metrics", "variant": "emphasis" },
|
||||
{ "id": "profile-rows", "from": "db", "to": "api", "y": 358, "label": "rows", "variant": "return" },
|
||||
{ "id": "cache-write", "from": "api", "to": "redis", "y": 390, "label": "set cache", "variant": "dashed" },
|
||||
{ "id": "trace-emit", "from": "api", "to": "trace", "y": 418, "label": "emit trace", "variant": "dashed" },
|
||||
{ "id": "dashboard-response", "from": "api", "to": "web", "y": 443, "label": "200 JSON", "variant": "return" },
|
||||
{ "id": "page-render", "from": "web", "to": "user", "y": 468, "label": "render", "variant": "return" }
|
||||
],
|
||||
"activations": [
|
||||
{ "participant": "web", "from": 180, "to": 474, "type": "frontend" },
|
||||
{ "participant": "api", "from": 185, "to": 450, "type": "backend" },
|
||||
{ "participant": "auth", "from": 205, "to": 244, "type": "security" },
|
||||
{ "participant": "redis", "from": 265, "to": 304, "type": "database" },
|
||||
{ "participant": "db", "from": 325, "to": 364, "type": "database" },
|
||||
{ "participant": "trace", "from": 413, "to": 449, "type": "messagebus" }
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"dot": "emerald",
|
||||
"title": "Happy Path",
|
||||
"items": [
|
||||
"The main request is Web App -> API -> data source -> response",
|
||||
"Return messages are quieter than forward calls",
|
||||
"Activation bars make ownership duration visible"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "rose",
|
||||
"title": "Policy + Fallback",
|
||||
"items": [
|
||||
"JWT verification is colored as a security interaction",
|
||||
"Cache miss is visible without overpowering the main path",
|
||||
"Database access only appears after cache fallback"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "orange",
|
||||
"title": "Async Trace",
|
||||
"items": [
|
||||
"Trace emission is dashed and secondary",
|
||||
"It does not block the response path",
|
||||
"The diagram separates user-facing latency from observability"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "dataflow",
|
||||
"meta": {
|
||||
"title": "Order Event-stream Topology",
|
||||
"output": "examples/event-stream.html",
|
||||
"viewBox": [1080, 780],
|
||||
"animation": "trace",
|
||||
"visual_preset": "signal-flow",
|
||||
"quality_profile": "showcase"
|
||||
},
|
||||
"stages": [
|
||||
{ "label": "Producers" },
|
||||
{ "label": "Transit" },
|
||||
{ "label": "Processors" },
|
||||
{ "label": "State + recovery" },
|
||||
{ "label": "Consumers" }
|
||||
],
|
||||
"nodes": [
|
||||
{ "id": "checkout", "type": "frontend", "label": "Checkout API", "sublabel": "order producer", "stage": 0, "row": 0, "tag": "team commerce" },
|
||||
{ "id": "billing", "type": "backend", "label": "Billing API", "sublabel": "payment producer", "stage": 0, "row": 2, "tag": "team money" },
|
||||
{ "id": "orders", "type": "messagebus", "label": "orders.v1", "sublabel": "12 partitions", "stage": 1, "row": 0, "tag": "key: order_id" },
|
||||
{ "id": "payments", "type": "messagebus", "label": "payments.v2", "sublabel": "8 partitions", "stage": 1, "row": 2, "tag": "key: order_id" },
|
||||
{ "id": "validate", "type": "backend", "label": "Order Validate", "sublabel": "group fulfillment", "stage": 2, "row": 0, "tag": "ordered" },
|
||||
{ "id": "enrich", "type": "backend", "label": "Payment Enrich", "sublabel": "group analytics", "stage": 2, "row": 2, "tag": "at-least-once" },
|
||||
{ "id": "state", "type": "database", "label": "Order State", "sublabel": "materialized view", "stage": 3, "row": 1, "tag": "idempotent" },
|
||||
{ "id": "dlq", "type": "messagebus", "label": "events.dlq", "sublabel": "poison events", "stage": 3, "row": 4, "tag": "7-day retention" },
|
||||
{ "id": "fulfillment", "type": "backend", "label": "Fulfillment", "sublabel": "shipping workflow", "stage": 4, "row": 0, "tag": "consumer" },
|
||||
{ "id": "analytics", "type": "database", "label": "Analytics", "sublabel": "streaming facts", "stage": 4, "row": 2, "tag": "consumer" },
|
||||
{ "id": "replay", "type": "security", "label": "Replay Tool", "sublabel": "approved batch", "stage": 4, "row": 4, "tag": "operator gate" },
|
||||
{ "id": "ops", "type": "external", "label": "On-call", "sublabel": "DLQ owner", "stage": 4, "row": 3, "yOffset": -18, "tag": "SRE" }
|
||||
],
|
||||
"flows": [
|
||||
{ "from": "checkout", "to": "orders", "label": "OrderPlaced", "classification": "schema v1", "variant": "emphasis", "route": "straight" },
|
||||
{ "from": "billing", "to": "payments", "label": "PaymentCaptured", "classification": "schema v2", "variant": "emphasis", "route": "straight" },
|
||||
{ "from": "orders", "to": "validate", "label": "ordered orders", "classification": "consumer group", "variant": "emphasis", "route": "straight" },
|
||||
{ "from": "payments", "to": "enrich", "label": "payment facts", "classification": "at-least-once", "variant": "emphasis", "route": "straight" },
|
||||
{ "from": "validate", "to": "state", "label": "valid order", "classification": "idempotent", "variant": "emphasis", "route": "vertical-channel" },
|
||||
{ "from": "enrich", "to": "state", "label": "enriched payment", "classification": "idempotent", "variant": "default", "route": "vertical-channel" },
|
||||
{ "from": "state", "to": "fulfillment", "label": "ready orders", "classification": "read model", "variant": "emphasis", "route": "vertical-channel" },
|
||||
{ "from": "state", "to": "analytics", "label": "order facts", "classification": "non-PII", "variant": "default", "route": "vertical-channel" },
|
||||
{ "from": "validate", "to": "dlq", "label": "invalid event", "classification": "dead letter", "variant": "security", "fromSide": "top", "toSide": "top", "via": [[530, 80], [20, 80], [20, 550], [745, 550]], "labelAt": [300, 550] },
|
||||
{ "from": "enrich", "to": "dlq", "label": "poison event", "classification": "dead letter", "variant": "security", "route": "bottom-channel", "labelDy": 30 },
|
||||
{ "from": "dlq", "to": "ops", "label": "failure sample", "classification": "restricted", "variant": "security", "route": "vertical-channel" },
|
||||
{ "from": "dlq", "to": "replay", "label": "approved replay", "classification": "audited batch", "variant": "dashed", "route": "straight", "labelDy": 30 }
|
||||
],
|
||||
"cards": [
|
||||
{ "dot": "amber", "title": "Transit Contract", "items": ["Every event and topic is named", "Partition keys preserve per-order ordering", "Consumer groups expose processing ownership"] },
|
||||
{ "dot": "emerald", "title": "State + Delivery", "items": ["Processors write an idempotent materialized view", "Fulfillment and analytics consume distinct assets", "At-least-once delivery never implies duplicate business effects"] },
|
||||
{ "dot": "rose", "title": "Failure Ownership", "items": ["Poison events land in a retained dead-letter topic", "On-call inspects samples before replay", "Replay is gated, batched, and auditable"] }
|
||||
]
|
||||
}
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "architecture",
|
||||
"meta": {
|
||||
"title": "MCO Runtime Architecture",
|
||||
"subtitle": "mco-org/mco · explicit provider policy · parallel invocation · durable raw answers",
|
||||
"output": "docs/cases/mco-runtime.architecture.html",
|
||||
"animation": "trace",
|
||||
"visual_preset": "signal-flow",
|
||||
"quality_profile": "showcase",
|
||||
"repository": {
|
||||
"url": "https://github.com/mco-org/mco",
|
||||
"revision": "9f1a1cf1afdc04d7b5406782b40dfec76d9bc798"
|
||||
}
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"id": "callers",
|
||||
"type": "external",
|
||||
"label": "Callers",
|
||||
"sublabel": "human · coding agent",
|
||||
"pos": [5, 250],
|
||||
"size": [130, 60]
|
||||
},
|
||||
{
|
||||
"id": "entry",
|
||||
"type": "frontend",
|
||||
"label": "Entry Surfaces",
|
||||
"sublabel": "npm shim · Python CLI · MCP",
|
||||
"pos": [175, 250],
|
||||
"size": [160, 60],
|
||||
"sources": [
|
||||
{ "path": "bin/mco.js", "line": 1, "label": "npm entry" },
|
||||
{ "path": "runtime/mcp_server.py", "line": 108, "end_line": 153, "label": "MCP entry" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "router",
|
||||
"type": "backend",
|
||||
"label": "Command Router",
|
||||
"sublabel": "run · review · doctor · session",
|
||||
"pos": [360, 250],
|
||||
"size": [180, 60],
|
||||
"sources": [
|
||||
{ "path": "runtime/cli.py", "line": 1945, "label": "CLI main" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "database",
|
||||
"label": "Config + Selection",
|
||||
"sublabel": "files · explicit provider team",
|
||||
"pos": [565, 110],
|
||||
"size": [175, 60],
|
||||
"tag": "no silent team",
|
||||
"sources": [
|
||||
{ "path": "runtime/config.py", "line": 24, "label": "Review config" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "policy",
|
||||
"type": "security",
|
||||
"label": "Execution Policy",
|
||||
"sublabel": "read_only · write · yolo",
|
||||
"pos": [565, 250],
|
||||
"size": [175, 60],
|
||||
"tag": "explicit boundary",
|
||||
"sources": [
|
||||
{ "path": "runtime/policy.py", "line": 34, "label": "Provider policy" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "workflow",
|
||||
"type": "backend",
|
||||
"label": "Invocation Workflow",
|
||||
"sublabel": "parallel · chain · debate · synthesize",
|
||||
"pos": [765, 250],
|
||||
"size": [220, 60],
|
||||
"sources": [
|
||||
{ "path": "runtime/invocation_runtime.py", "line": 696, "label": "Workflow entry" },
|
||||
{ "path": "runtime/invocation_runtime.py", "line": 156, "label": "Single invocation" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "adapters",
|
||||
"type": "messagebus",
|
||||
"label": "Provider Adapters",
|
||||
"sublabel": "shim / ACP · detect · run · poll · cancel",
|
||||
"pos": [1010, 250],
|
||||
"size": [230, 60],
|
||||
"sources": [
|
||||
{ "path": "runtime/adapters/__init__.py", "line": 57, "label": "Adapter registry" },
|
||||
{ "path": "runtime/contracts.py", "line": 77, "label": "Provider contract" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "provider_clis",
|
||||
"type": "external",
|
||||
"label": "Provider CLIs",
|
||||
"sublabel": "Claude · Codex · Pi · 7 more",
|
||||
"pos": [1035, 0],
|
||||
"size": [180, 60]
|
||||
},
|
||||
{
|
||||
"id": "answer_transport",
|
||||
"type": "messagebus",
|
||||
"label": "Answer Transport",
|
||||
"sublabel": "plain · JSON · JSONL · ACP events",
|
||||
"pos": [765, 420],
|
||||
"size": [190, 60],
|
||||
"sources": [
|
||||
{ "path": "runtime/answer_transport.py", "line": 79, "label": "Transport decoders" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "artifacts",
|
||||
"type": "cloud",
|
||||
"label": "Invocation Artifacts",
|
||||
"sublabel": "answers · result.md · run.json",
|
||||
"pos": [1010, 420],
|
||||
"size": [170, 60],
|
||||
"tag": "raw evidence",
|
||||
"sources": [
|
||||
{ "path": "runtime/invocation_artifacts.py", "line": 8, "label": "Artifact writer" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "consumers",
|
||||
"type": "external",
|
||||
"label": "Output Consumers",
|
||||
"sublabel": "terminal · calling agent · CI",
|
||||
"pos": [1005, 520],
|
||||
"size": [180, 60]
|
||||
},
|
||||
{
|
||||
"id": "session_daemon",
|
||||
"type": "backend",
|
||||
"label": "Session Daemon",
|
||||
"sublabel": "detached worker · Unix socket",
|
||||
"pos": [365, 420],
|
||||
"size": [170, 60],
|
||||
"sources": [
|
||||
{ "path": "runtime/session/daemon.py", "line": 50, "label": "Daemon context" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "session_state",
|
||||
"type": "database",
|
||||
"label": "Session State",
|
||||
"sublabel": "state.json · history.jsonl",
|
||||
"pos": [565, 420],
|
||||
"size": [170, 60],
|
||||
"tag": ".mco/sessions",
|
||||
"sources": [
|
||||
{ "path": "runtime/session/state.py", "line": 25, "label": "Session state" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"boundaries": [
|
||||
{
|
||||
"kind": "region",
|
||||
"label": "mco local package / Python runtime",
|
||||
"wraps": ["entry", "router", "config", "policy", "workflow", "adapters", "answer_transport", "artifacts", "session_daemon", "session_state"]
|
||||
},
|
||||
{
|
||||
"kind": "security-group",
|
||||
"label": "explicit execution-policy boundary",
|
||||
"wraps": ["policy", "workflow", "adapters"]
|
||||
}
|
||||
],
|
||||
"connections": [
|
||||
{
|
||||
"id": "caller-entry",
|
||||
"from": "callers",
|
||||
"to": "entry",
|
||||
"variant": "emphasis"
|
||||
},
|
||||
{
|
||||
"id": "entry-route",
|
||||
"from": "entry",
|
||||
"to": "router",
|
||||
"variant": "emphasis"
|
||||
},
|
||||
{
|
||||
"id": "route-policy",
|
||||
"from": "router",
|
||||
"to": "policy",
|
||||
"variant": "security"
|
||||
},
|
||||
{
|
||||
"id": "config-policy",
|
||||
"from": "config",
|
||||
"to": "policy",
|
||||
"variant": "security",
|
||||
"fromSide": "bottom",
|
||||
"toSide": "top"
|
||||
},
|
||||
{
|
||||
"id": "policy-workflow",
|
||||
"from": "policy",
|
||||
"to": "workflow",
|
||||
"variant": "emphasis"
|
||||
},
|
||||
{
|
||||
"id": "workflow-adapters",
|
||||
"from": "workflow",
|
||||
"to": "adapters",
|
||||
"variant": "emphasis"
|
||||
},
|
||||
{
|
||||
"id": "adapters-providers",
|
||||
"from": "adapters",
|
||||
"to": "provider_clis",
|
||||
"label": "native CLI / ACP",
|
||||
"variant": "emphasis",
|
||||
"fromSide": "top",
|
||||
"toSide": "bottom"
|
||||
},
|
||||
{
|
||||
"id": "adapters-answer",
|
||||
"from": "adapters",
|
||||
"to": "answer_transport",
|
||||
"label": "decode events",
|
||||
"labelAt": [995, 365],
|
||||
"fromSide": "bottom",
|
||||
"toSide": "top"
|
||||
},
|
||||
{
|
||||
"id": "answer-artifacts",
|
||||
"from": "answer_transport",
|
||||
"to": "artifacts",
|
||||
"variant": "emphasis"
|
||||
},
|
||||
{
|
||||
"id": "artifacts-consumers",
|
||||
"from": "artifacts",
|
||||
"to": "consumers",
|
||||
"label": "raw answers",
|
||||
"labelAt": [1095, 510],
|
||||
"variant": "emphasis",
|
||||
"fromSide": "bottom",
|
||||
"toSide": "top"
|
||||
},
|
||||
{
|
||||
"id": "router-session",
|
||||
"from": "router",
|
||||
"to": "session_daemon",
|
||||
"label": "start · send · resume",
|
||||
"labelAt": [450, 365],
|
||||
"variant": "dashed",
|
||||
"fromSide": "bottom",
|
||||
"toSide": "top",
|
||||
"route": "straight"
|
||||
},
|
||||
{
|
||||
"id": "session-state",
|
||||
"from": "session_daemon",
|
||||
"to": "session_state",
|
||||
"label": "persist",
|
||||
"labelAt": [550, 406],
|
||||
"variant": "dashed"
|
||||
}
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"dot": "cyan",
|
||||
"title": "Source-backed dispatch",
|
||||
"items": [
|
||||
"runtime/cli.py requires an explicit provider or named agent team",
|
||||
"runtime/policy.py validates models, context, permissions, and risk",
|
||||
"runtime/invocation_runtime.py owns parallelism, timeouts, and cancellation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "emerald",
|
||||
"title": "Raw-answer contract",
|
||||
"items": [
|
||||
"Adapters keep each provider CLI behind one shared runtime contract",
|
||||
"answer_transport.py decodes output without inventing findings or consensus",
|
||||
"Artifacts persist answers and run metadata for terminal, agents, or CI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "violet",
|
||||
"title": "Real repository proof",
|
||||
"items": [
|
||||
"Repository: github.com/mco-org/mco",
|
||||
"Source snapshot: main @ 9f1a1cf",
|
||||
"Evidence read: README, CLI, policy, adapters, invocation runtime, sessions"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "architecture",
|
||||
"meta": {
|
||||
"title": "Production Deployment Ownership",
|
||||
"output": "examples/production-deployment.html",
|
||||
"visual_preset": "blueprint",
|
||||
"animation": "trace",
|
||||
"quality_profile": "showcase",
|
||||
"engineering_profile": "deployment-ownership"
|
||||
},
|
||||
"components": [
|
||||
{ "id": "clients", "type": "external", "label": "Customers", "sublabel": "web + mobile", "pos": [38, 300], "size": [122, 60] },
|
||||
{ "id": "edge", "type": "cloud", "label": "Global Edge", "sublabel": "CDN + WAF", "pos": [230, 300], "size": [126, 60], "tag": "edge team" },
|
||||
{ "id": "gateway", "type": "security", "label": "API Gateway", "sublabel": "public :443", "pos": [430, 300], "size": [128, 60], "tag": "platform" },
|
||||
{ "id": "api_a", "type": "backend", "label": "API Pods / AZ-a", "sublabel": "private subnet", "pos": [630, 195], "size": [136, 62], "tag": "app team" },
|
||||
{ "id": "api_b", "type": "backend", "label": "API Pods / AZ-b", "sublabel": "private subnet", "pos": [630, 405], "size": [136, 62], "tag": "app team" },
|
||||
{ "id": "redis", "type": "database", "label": "Redis", "sublabel": "multi-AZ cache", "pos": [840, 195], "size": [126, 62], "tag": "platform" },
|
||||
{ "id": "postgres", "type": "database", "label": "PostgreSQL", "sublabel": "primary / encrypted", "pos": [840, 405], "size": [126, 62], "tag": "data team" },
|
||||
{ "id": "events", "type": "messagebus", "label": "Event Bus", "sublabel": "orders.v1", "pos": [1040, 300], "size": [126, 60], "tag": "platform" },
|
||||
{ "id": "worker", "type": "backend", "label": "Workers", "sublabel": "private workload", "pos": [1190, 300], "size": [126, 60], "tag": "app team" },
|
||||
{ "id": "replica", "type": "database", "label": "DR Replica", "sublabel": "eu-west-1", "pos": [1040, 578], "size": [126, 62], "tag": "data team" },
|
||||
{ "id": "audit", "type": "cloud", "label": "Audit Archive", "sublabel": "immutable objects", "pos": [1190, 450], "size": [126, 62], "tag": "security" },
|
||||
{ "id": "observability", "type": "external", "label": "Observability", "sublabel": "metrics + traces", "pos": [1190, 85], "size": [126, 62], "tag": "SRE" }
|
||||
],
|
||||
"boundaries": [
|
||||
{ "kind": "region", "label": "AWS us-east-1 / production", "wraps": ["edge", "gateway", "api_a", "api_b", "redis", "postgres", "events", "worker", "audit"], "pad": 20 },
|
||||
{ "kind": "security-group", "label": "private application network", "wraps": ["api_a", "api_b", "redis", "postgres", "events", "worker"], "pad": 14 },
|
||||
{ "kind": "region", "label": "AWS eu-west-1 / disaster recovery", "wraps": ["replica"] },
|
||||
{ "kind": "security-group", "label": "DR private subnet", "wraps": ["replica"], "pad": 14 }
|
||||
],
|
||||
"connections": [
|
||||
{ "from": "clients", "to": "edge", "label": "HTTPS", "variant": "emphasis" },
|
||||
{ "from": "edge", "to": "gateway", "label": "mTLS", "variant": "security" },
|
||||
{ "from": "gateway", "to": "api_a", "label": "VPC route", "variant": "emphasis", "route": "orthogonal-h", "labelAt": [594, 275] },
|
||||
{ "from": "gateway", "to": "api_b", "label": "VPC route", "variant": "emphasis", "route": "orthogonal-h", "labelAt": [594, 385] },
|
||||
{ "from": "api_a", "to": "redis", "label": "cache", "route": "straight" },
|
||||
{ "from": "api_b", "to": "postgres", "label": "SQL", "route": "straight" },
|
||||
{ "from": "api_a", "to": "events", "label": "publish", "variant": "dashed", "fromSide": "top", "toSide": "top", "via": [[698, 170], [1103, 170]] },
|
||||
{ "from": "api_b", "to": "events", "variant": "dashed", "fromSide": "top", "toSide": "bottom", "via": [[698, 380], [1103, 380]] },
|
||||
{ "from": "events", "to": "worker", "variant": "emphasis" },
|
||||
{ "from": "postgres", "to": "replica", "label": "cross-region WAL", "variant": "security", "route": "orthogonal-v", "labelAt": [1003, 529] },
|
||||
{ "from": "worker", "to": "audit", "label": "evidence", "variant": "dashed", "fromSide": "bottom", "toSide": "top", "labelDy": 58 },
|
||||
{ "from": "worker", "to": "observability", "label": "OTLP", "variant": "dashed", "route": "orthogonal-v" }
|
||||
],
|
||||
"cards": [
|
||||
{ "dot": "cyan", "title": "Runtime Ownership", "items": ["Platform owns the edge, gateway, cache, and event bus", "Application teams own API pods and workers", "Data owns primary and disaster-recovery state"] },
|
||||
{ "dot": "rose", "title": "Named Crossings", "items": ["Public HTTPS terminates at the managed edge", "mTLS crosses into the application network", "Cross-region WAL is explicit and encrypted"] },
|
||||
{ "dot": "emerald", "title": "Operational Evidence", "items": ["Workers emit traces to SRE-owned observability", "Audit evidence lands in immutable storage", "Unknown placement should remain marked, never invented"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Runs the integration suite inside a real VS Code instance with a throwaway
|
||||
// profile. Uses the locally installed VS Code when VSCODE_PATH is set or the
|
||||
// default macOS location exists; otherwise downloads one.
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runTests } from '@vscode/test-electron';
|
||||
|
||||
// When launched from a VS Code terminal this is inherited and would make the
|
||||
// test instance start as plain Node.
|
||||
delete process.env.ELECTRON_RUN_AS_NODE;
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const macApp = '/Applications/Visual Studio Code.app/Contents/MacOS/Code';
|
||||
const vscodeExecutablePath = process.env.VSCODE_PATH || (fs.existsSync(macApp) ? macApp : undefined);
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-ext-it-'));
|
||||
const workspace = path.join(scratch, 'workspace');
|
||||
fs.cpSync(path.join(root, 'test', 'fixtures'), workspace, { recursive: true });
|
||||
|
||||
try {
|
||||
await runTests({
|
||||
vscodeExecutablePath,
|
||||
extensionDevelopmentPath: root,
|
||||
extensionTestsPath: path.join(root, 'out', 'test', 'integration', 'suite.js'),
|
||||
launchArgs: [
|
||||
workspace,
|
||||
'--disable-extensions',
|
||||
'--disable-workspace-trust',
|
||||
'--skip-welcome',
|
||||
'--skip-release-notes',
|
||||
`--user-data-dir=${path.join(scratch, 'user-data')}`,
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(scratch, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
const EXTENSION_ID = 'vscode-extensions.archify-vscode-ext';
|
||||
|
||||
function workspaceFile(name: string): vscode.Uri {
|
||||
const folder = vscode.workspace.workspaceFolders?.[0];
|
||||
assert.ok(folder, 'a workspace folder is open');
|
||||
return vscode.Uri.joinPath(folder.uri, name);
|
||||
}
|
||||
|
||||
async function waitFor<T>(what: string, probe: () => T | undefined | false, timeoutMs = 20_000): Promise<T> {
|
||||
const start = Date.now();
|
||||
for (;;) {
|
||||
const value = probe();
|
||||
if (value) return value;
|
||||
if (Date.now() - start > timeoutMs) throw new Error(`Timed out waiting for ${what}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
}
|
||||
|
||||
function webviewTabs(viewTypeSuffix: string): vscode.Tab[] {
|
||||
return vscode.window.tabGroups.all
|
||||
.flatMap((group) => group.tabs)
|
||||
.filter((tab) =>
|
||||
(tab.input instanceof vscode.TabInputWebview && tab.input.viewType.endsWith(viewTypeSuffix)) ||
|
||||
(tab.input instanceof vscode.TabInputCustom && tab.input.viewType === viewTypeSuffix));
|
||||
}
|
||||
|
||||
async function check(name: string, body: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await body();
|
||||
console.log(` ✔ ${name}`);
|
||||
} catch (error) {
|
||||
console.log(` ✖ ${name}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
console.log('Archify extension integration suite');
|
||||
|
||||
await check('activates on a diagram source and sets the context key', async () => {
|
||||
const doc = await vscode.workspace.openTextDocument(workspaceFile('production-deployment.architecture.json'));
|
||||
await vscode.window.showTextDocument(doc);
|
||||
const extension = vscode.extensions.getExtension(EXTENSION_ID);
|
||||
assert.ok(extension, 'extension is installed');
|
||||
await waitFor('activation', () => extension.isActive);
|
||||
});
|
||||
|
||||
await check('opens a live preview beside the source', async () => {
|
||||
await vscode.commands.executeCommand('archify.showPreviewToSide');
|
||||
const tabs = await waitFor('preview tab', () => {
|
||||
const found = webviewTabs('archify.preview');
|
||||
return found.length > 0 && found;
|
||||
});
|
||||
assert.equal(tabs.length, 1);
|
||||
assert.match(tabs[0].label, /Preview production-deployment\.architecture\.json/);
|
||||
});
|
||||
|
||||
await check('reports validation problems in the Problems panel with JSON ranges', async () => {
|
||||
const doc = await vscode.workspace.openTextDocument({
|
||||
language: 'json',
|
||||
content: JSON.stringify({ schema_version: 1, diagram_type: 'architecture', meta: { title: 'x' }, components: [{ id: 'a' }] }, null, 2),
|
||||
});
|
||||
await vscode.window.showTextDocument(doc);
|
||||
const diagnostics = await waitFor('archify diagnostics', () => {
|
||||
const found = vscode.languages.getDiagnostics(doc.uri).filter((d) => d.source === 'archify');
|
||||
return found.length > 0 && found;
|
||||
});
|
||||
const component = diagnostics.find((d) => /components\/0/.test(d.message));
|
||||
assert.ok(component, 'a diagnostic for /components/0');
|
||||
assert.equal(doc.getText(component.range), '{', 'range points at the component object');
|
||||
assert.equal(component.severity, vscode.DiagnosticSeverity.Error);
|
||||
});
|
||||
|
||||
await check('clears problems once the document is fixed', async () => {
|
||||
const good = await vscode.workspace.openTextDocument(workspaceFile('event-stream.dataflow.json'));
|
||||
const doc = await vscode.workspace.openTextDocument({ language: 'json', content: good.getText() });
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
const errors = vscode.languages.getDiagnostics(doc.uri).filter((d) => d.source === 'archify' && d.severity === vscode.DiagnosticSeverity.Error);
|
||||
assert.deepEqual(errors.map((d) => d.message), []);
|
||||
});
|
||||
|
||||
await check('renders to an HTML file and opens it in the Archify viewer', async () => {
|
||||
const source = workspaceFile('cache-miss-request.sequence.json');
|
||||
const output = vscode.Uri.file(path.join(path.dirname(source.fsPath), 'cache-miss-request.sequence.html'));
|
||||
// Stub the save dialog and the follow-up notification.
|
||||
const window = vscode.window as unknown as Record<string, unknown>;
|
||||
const originalSave = window.showSaveDialog;
|
||||
const originalInfo = window.showInformationMessage;
|
||||
window.showSaveDialog = async () => output;
|
||||
window.showInformationMessage = async () => 'Open in Viewer';
|
||||
try {
|
||||
await vscode.commands.executeCommand('archify.renderToHtml', source);
|
||||
} finally {
|
||||
window.showSaveDialog = originalSave;
|
||||
window.showInformationMessage = originalInfo;
|
||||
}
|
||||
const html = new TextDecoder().decode(await vscode.workspace.fs.readFile(output));
|
||||
assert.match(html, /<meta name="generator" content="archify/);
|
||||
await waitFor('custom editor tab', () => webviewTabs('archify.htmlViewer').length > 0);
|
||||
});
|
||||
|
||||
await check('source-backed diagram previews with the evidence fallback', async () => {
|
||||
const uri = workspaceFile('mco-runtime.architecture.json');
|
||||
await vscode.commands.executeCommand('archify.showPreview', uri);
|
||||
await waitFor('second preview tab', () => webviewTabs('archify.preview').length >= 2);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
const diagnostics = await waitFor('evidence diagnostics', () => {
|
||||
const found = vscode.languages.getDiagnostics(doc.uri).filter((d) => d.source === 'archify');
|
||||
return found.length > 0 && found;
|
||||
});
|
||||
assert.ok(
|
||||
diagnostics.every((d) => d.severity !== vscode.DiagnosticSeverity.Error),
|
||||
'unverifiable evidence is a warning in fallback mode',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { readDiagramSource } from '../src/detect';
|
||||
import { renderDiagram, renderToFile, RuntimeOptions, validate } from '../src/renderer';
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
const fixtures = path.join(root, 'test', 'fixtures');
|
||||
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-ext-test-'));
|
||||
|
||||
function runtime(extra: Partial<RuntimeOptions> = {}): RuntimeOptions {
|
||||
return {
|
||||
cliPath: path.join(root, 'vendor', 'archify', 'bin', 'archify.mjs'),
|
||||
nodePath: process.execPath,
|
||||
runAsNode: false,
|
||||
workDir,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function load(name: string) {
|
||||
const text = fs.readFileSync(path.join(fixtures, name), 'utf8');
|
||||
const source = readDiagramSource(text, name);
|
||||
assert.ok(source, `${name} is a diagram source`);
|
||||
return { ...source, text };
|
||||
}
|
||||
|
||||
for (const name of fs.readdirSync(fixtures).filter((file) => !file.startsWith('mco-'))) {
|
||||
test(`renders ${name}`, async () => {
|
||||
const result = await renderDiagram(runtime(), load(name), 'strict');
|
||||
assert.equal(result.error, undefined);
|
||||
assert.ok(result.ok);
|
||||
assert.match(result.html ?? '', /<meta name="generator" content="archify/);
|
||||
assert.equal(result.evidenceNotice, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('reports schema problems with JSON pointer paths', async () => {
|
||||
const text = JSON.stringify({ schema_version: 1, diagram_type: 'architecture', meta: { title: 'x' }, components: [{ id: 'a' }] });
|
||||
const result = await validate(runtime(), 'architecture', text);
|
||||
assert.equal(result.ok, false);
|
||||
assert.ok(result.diagnostics.some((d) => d.subject?.path === '/components/0' && d.severity === 'error'));
|
||||
});
|
||||
|
||||
test('render failure returns the CLI error', async () => {
|
||||
const text = JSON.stringify({ schema_version: 1, diagram_type: 'architecture', meta: {}, components: [] });
|
||||
const result = await renderDiagram(runtime(), { type: 'architecture', diagram: JSON.parse(text), text }, 'fallback');
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error ?? '', /schema validation failed/);
|
||||
});
|
||||
|
||||
test('unverifiable source evidence falls back to a render without source links', async () => {
|
||||
// This checkout's origin is not mco-org/mco, so verification must fail.
|
||||
const source = load('mco-runtime.architecture.json');
|
||||
const strict = await renderDiagram(runtime({ repoRoot: root }), source, 'strict');
|
||||
assert.equal(strict.ok, false);
|
||||
const fallback = await renderDiagram(runtime({ repoRoot: root }), source, 'fallback');
|
||||
assert.ok(fallback.ok, fallback.error);
|
||||
assert.match(fallback.evidenceNotice ?? '', /Source links are hidden/);
|
||||
const noRoot = await renderDiagram(runtime(), source, 'fallback');
|
||||
assert.ok(noRoot.ok);
|
||||
assert.match(noRoot.evidenceNotice ?? '', /not inside a Git checkout/);
|
||||
});
|
||||
|
||||
test('renders to a chosen file', async () => {
|
||||
const output = path.join(workDir, 'exported.html');
|
||||
const result = await renderToFile(runtime(), load('event-stream.dataflow.json'), output);
|
||||
assert.ok(result.ok, result.error);
|
||||
assert.ok(fs.statSync(output).size > 10_000);
|
||||
});
|
||||
|
||||
test('scratch directories are cleaned up', () => {
|
||||
assert.deepEqual(fs.readdirSync(workDir).filter((entry) => entry.startsWith('run-')), []);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { prepareDiagramHtml } from '../src/webviewHtml';
|
||||
|
||||
const page = `<!DOCTYPE html><html><head>
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src *">
|
||||
<script>var a = 1;</script>
|
||||
<script id="data" type="application/json">{"x":1}</script>
|
||||
</head><body><script>var b = 2;</script></body></html>`;
|
||||
|
||||
test('adds one CSP and nonces every inline script', () => {
|
||||
const html = prepareDiagramHtml(page, { cspSource: 'vscode-resource:', nonce: 'N0NCE', theme: 'light' });
|
||||
const csps = html.match(/Content-Security-Policy/g) ?? [];
|
||||
assert.equal(csps.length, 1);
|
||||
assert.match(html, /script-src ('|')nonce-N0NCE/);
|
||||
const scripts = html.match(/<script\b[^>]*>/g) ?? [];
|
||||
assert.equal(scripts.length, 4, 'bridge plus the three page scripts');
|
||||
for (const tag of scripts) assert.match(tag, /nonce="N0NCE"/);
|
||||
});
|
||||
|
||||
test('bridge runs before the page scripts and pins the theme', () => {
|
||||
const html = prepareDiagramHtml(page, { cspSource: 'x', nonce: 'n', theme: 'dark', hash: '#focus=api' });
|
||||
const bridge = html.indexOf('acquireVsCodeApi');
|
||||
assert.ok(bridge > 0 && bridge < html.indexOf('var a = 1'));
|
||||
assert.match(html, /"theme":"dark"/);
|
||||
assert.match(html, /"hash":"#focus=api"/);
|
||||
});
|
||||
|
||||
test('embedded config cannot close the script element', () => {
|
||||
const html = prepareDiagramHtml(page, {
|
||||
cspSource: 'x',
|
||||
nonce: 'n',
|
||||
theme: undefined,
|
||||
notice: { kind: 'warning', text: '</script><script>alert(1)</script>' },
|
||||
});
|
||||
assert.ok(!html.includes('</script><script>alert(1)'));
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["node", "vscode"]
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"upstream": "https://github.com/tt-a1i/archify",
|
||||
"version": "3.0.1",
|
||||
"revision": "d5a1333d7447c866a765adac7d4d062f2f02e4d2"
|
||||
}
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 tt-a1i (Archify)
|
||||
Copyright (c) 2025 Cocoon AI
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
# Third-party notices
|
||||
|
||||
Archify includes optional vector data for third-party brand marks. These marks
|
||||
are provided only to identify technologies and services in user-authored
|
||||
diagrams. Their inclusion does not imply sponsorship, endorsement, partnership,
|
||||
or affiliation with Archify.
|
||||
|
||||
The Archify MIT license applies to Archify's own code and content. It does not
|
||||
replace the copyright licenses, trademark policies, or brand guidelines that
|
||||
apply to third-party marks. Users are responsible for confirming that their
|
||||
particular use is permitted.
|
||||
|
||||
## Simple Icons
|
||||
|
||||
Most of the built-in vector paths and their metadata were generated from
|
||||
[Simple Icons 16.28.0](https://github.com/simple-icons/simple-icons/tree/16.28.0).
|
||||
Simple Icons makes its collection work available under
|
||||
[CC0 1.0 Universal](https://github.com/simple-icons/simple-icons/blob/16.28.0/LICENSE.md).
|
||||
|
||||
As Simple Icons explains in its
|
||||
[disclaimer](https://github.com/simple-icons/simple-icons/blob/16.28.0/DISCLAIMER.md),
|
||||
CC0 for the collection does not mean that every underlying icon is CC0. License
|
||||
and brand-guideline metadata may be incomplete or change over time. The absence
|
||||
of an individual license entry is not a grant of permission.
|
||||
|
||||
Archify embeds the selected icons as vector-path data and may render them in a
|
||||
user-selected color. The following individual licenses were recorded in the
|
||||
pinned Simple Icons 16.28.0 metadata:
|
||||
|
||||
| Mark | Recorded source | Recorded license | Archify treatment |
|
||||
|---|---|---|---|
|
||||
| Angular | [Angular press kit](https://angular.dev/press-kit) | [`CC-BY-4.0`](https://creativecommons.org/licenses/by/4.0/) | Embedded as vector-path data; color may be changed by the authored diagram. |
|
||||
| Apache Airflow | [Apache logos](https://apache.org/logos) | [`Apache-2.0`](https://www.apache.org/licenses/LICENSE-2.0) | Embedded as vector-path data; Apache trademarks remain subject to the [ASF trademark policy](https://www.apache.org/foundation/marks/). |
|
||||
| Apache Kafka | [Apache logos](https://apache.org/logos) | [`Apache-2.0`](https://www.apache.org/licenses/LICENSE-2.0) | Embedded as vector-path data; Apache trademarks remain subject to the [ASF trademark policy](https://www.apache.org/foundation/marks/). |
|
||||
| .NET | [.NET brand repository](https://github.com/dotnet/brand/blob/c7d0f51b8ec59531332d05fb27a5b758a7a3d689/logo/dotnet-logo.svg) | [`CC0-1.0`](https://creativecommons.org/publicdomain/zero/1.0/) | Embedded as vector-path data; color may be changed by the authored diagram. |
|
||||
| JavaScript | [JS community logo](https://github.com/voodootikigod/logo.js/blob/1544bdeed6d618a6cfe4f0650d04ab8d9cfa76d9/js.svg) | [`MIT`](https://github.com/voodootikigod/logo.js/blob/1544bdeed6d618a6cfe4f0650d04ab8d9cfa76d9/LICENSE) | Embedded as vector-path data; color may be changed by the authored diagram. |
|
||||
| Jenkins | [Jenkins artwork source](https://get.jenkins.io/art/) | [`CC-BY-SA-3.0`](https://creativecommons.org/licenses/by-sa/3.0/) | Embedded as vector-path data; color may be changed by the authored diagram. Jenkins retains its trademark rights. |
|
||||
| Rust | [Rust project](https://www.rust-lang.org) | [`CC-BY-SA-4.0`](https://creativecommons.org/licenses/by-sa/4.0/) | Embedded as vector-path data; color may be changed by the authored diagram. See the [Rust media guide](https://www.rust-lang.org/policies/media-guide). |
|
||||
| Vue.js | [Vue logo source](https://github.com/vuejs/art/blob/a1c78b74569b70a25300925b4eacfefcc143b8f6/logo.svg) | [`CC-BY-NC-SA-4.0`](https://creativecommons.org/licenses/by-nc-sa/4.0/) | Embedded as vector-path data; color may be changed by the authored diagram. The non-commercial and share-alike conditions remain applicable; see the [Vue artwork terms](https://github.com/vuejs/art/blob/a1c78b74569b70a25300925b4eacfefcc143b8f6/README.md). |
|
||||
|
||||
The source, guideline, and known license fields for every packaged mark are
|
||||
preserved in `renderers/shared/generated-brand-marks.mjs`.
|
||||
|
||||
## OpenAI mark
|
||||
|
||||
The OpenAI vector path is recorded from the
|
||||
[OpenAI brand guidelines](https://openai.com/brand/), not from Simple Icons.
|
||||
Use remains subject to those current guidelines and any applicable trademark
|
||||
rights. Its inclusion does not state or imply endorsement by OpenAI.
|
||||
|
||||
## JetBrains Mono
|
||||
|
||||
Delivered Archify viewer artifacts embed the JetBrains Mono variable font
|
||||
subsets served by Google Fonts. For characters covered by these subsets, font
|
||||
selection does not depend on a network request or a locally installed copy.
|
||||
Uncovered characters (including CJK) still use the system fallback stack;
|
||||
browser and operating-system rasterization can differ.
|
||||
JetBrains Mono is maintained at
|
||||
[github.com/JetBrains/JetBrainsMono](https://github.com/JetBrains/JetBrainsMono)
|
||||
and is distributed under the SIL Open Font License 1.1. The complete license
|
||||
text is preserved in `assets/JetBrainsMono-OFL.txt` in the packaged Skill and
|
||||
in the font CSS carried by standalone HTML and SVG exports.
|
||||
|
||||
## No additional rights granted
|
||||
|
||||
Brand names, logos, and trademarks remain the property of their respective
|
||||
owners. This notice records provenance and known terms; it does not grant rights
|
||||
that Archify does not hold, and it does not state that every packaged mark has
|
||||
been cleared for every commercial, promotional, or redistributive use.
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Vendored
+13709
File diff suppressed because one or more lines are too long
+6936
File diff suppressed because it is too large
Load Diff
+91
@@ -0,0 +1,91 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const childPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'delivery-update-child.mjs');
|
||||
const DEADLINE_MS = 1_000;
|
||||
const MAX_OUTPUT_BYTES = 16 * 1_024;
|
||||
|
||||
function unavailable(reason) {
|
||||
return { status: 'unavailable', installedVersion: null, availableVersion: null,
|
||||
releaseNotes: null, checkedAt: null, source: null, noticeRequired: false,
|
||||
noticeText: null, reason };
|
||||
}
|
||||
|
||||
function normalize(result) {
|
||||
if (result?.status === 'current') return {
|
||||
status: 'current', installedVersion: result.installedVersion,
|
||||
availableVersion: result.availableVersion, releaseNotes: null,
|
||||
checkedAt: result.checkedAt, source: result.source,
|
||||
noticeRequired: false, noticeText: null,
|
||||
};
|
||||
if (result?.status !== 'update_available') {
|
||||
return unavailable(result?.reason || (result?.status === 'silent' ? 'no-update' : 'invalid-result'));
|
||||
}
|
||||
const noticeRequired = result.noticeRequired !== false;
|
||||
const cached = result.source === 'cache';
|
||||
const noticeText = noticeRequired
|
||||
? `Archify ${result.severity === 'security' ? 'security update' : 'update'}: ${result.installedVersion} → ${result.latestVersion}. ${cached
|
||||
? `A previous check at ${result.checkedAt} found a newer release.`
|
||||
: 'A newer release is available.'} Release notes: ${result.releaseNotes}. The installed Skill has not changed; ask to snooze or ignore this reminder.`
|
||||
: null;
|
||||
return {
|
||||
status: 'update_available',
|
||||
installedVersion: result.installedVersion,
|
||||
availableVersion: result.latestVersion,
|
||||
releaseNotes: result.releaseNotes,
|
||||
severity: result.severity,
|
||||
checkedAt: result.checkedAt,
|
||||
source: result.source,
|
||||
noticeRequired,
|
||||
noticeText,
|
||||
...(result.eventKey ? { eventKey: result.eventKey } : {}),
|
||||
...(result.reason ? { reason: result.reason } : {}),
|
||||
...(result.suppressedUntil ? { suppressedUntil: result.suppressedUntil } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function startDeliveryUpdateCheck({
|
||||
env = process.env, deadlineMs = DEADLINE_MS, checkerPath = childPath,
|
||||
} = {}) {
|
||||
if (env.ARCHIFY_UPDATE_CHECK_DISABLED === '1') return Promise.resolve(unavailable('disabled'));
|
||||
return new Promise((resolve) => {
|
||||
let child;
|
||||
try {
|
||||
const deadlineNs = process.hrtime.bigint() + BigInt(Math.floor(deadlineMs * 1_000_000));
|
||||
child = spawn(process.execPath, [checkerPath, String(deadlineNs)], {
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
resolve(unavailable('runtime-unavailable'));
|
||||
return;
|
||||
}
|
||||
let output = '';
|
||||
let timedOut = false;
|
||||
let overflow = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGKILL');
|
||||
}, deadlineMs);
|
||||
child.stdout.on('data', (chunk) => {
|
||||
if (Buffer.byteLength(output) + chunk.length > MAX_OUTPUT_BYTES) {
|
||||
overflow = true;
|
||||
child.kill('SIGKILL');
|
||||
} else output += chunk.toString('utf8');
|
||||
});
|
||||
child.on('error', () => {});
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (overflow) return resolve(unavailable('invalid-result'));
|
||||
// A synchronous renderer can delay both this callback and the parent's
|
||||
// timer. A complete child result wins even if the timer fired meanwhile.
|
||||
if (code === 0 && output.endsWith('\n')) {
|
||||
try { return resolve(normalize(JSON.parse(output))); }
|
||||
catch { return resolve(unavailable('invalid-result')); }
|
||||
}
|
||||
resolve(unavailable(timedOut || child.signalCode === 'SIGKILL' ? 'timeout' : 'check-failed'));
|
||||
});
|
||||
});
|
||||
}
|
||||
Vendored
+895
@@ -0,0 +1,895 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { canonicalFuturePath, pathsAlias, resolveNativeOutputDirectory, resolveOutputPath } from '../renderers/shared/output-path.mjs';
|
||||
import { boundedSidecarStem } from '../renderers/shared/sidecar-path.mjs';
|
||||
import {
|
||||
captureAtomicOutput, captureRegularFileBinding, publishRegularFileBinding,
|
||||
releaseRegularFileBinding, removeOwnedRegularFile, verifyAtomicOutput,
|
||||
} from '../renderers/shared/atomic-output.mjs';
|
||||
import {
|
||||
browserCheckSidecarPaths,
|
||||
CAPTURE_VIEWPORTS,
|
||||
ChromeVisualBrowser,
|
||||
findChrome,
|
||||
VISUAL_CHECK_VIEWPORTS,
|
||||
} from './visual-check.mjs';
|
||||
import { startDeliveryUpdateCheck } from './delivery-update.mjs';
|
||||
|
||||
export const FINALIZE_STAGES = Object.freeze(['validate', 'deliver', 'check', 'browser-check']);
|
||||
const FINALIZE_UPDATE_DEADLINE_MS = 4_000;
|
||||
|
||||
function sha256(buffer) {
|
||||
return createHash('sha256').update(buffer).digest('hex');
|
||||
}
|
||||
|
||||
function identity(file) {
|
||||
try {
|
||||
const bytes = fs.readFileSync(file);
|
||||
return { path: path.resolve(file), sha256: sha256(bytes), bytes: bytes.byteLength };
|
||||
} catch {
|
||||
return { path: path.resolve(file) };
|
||||
}
|
||||
}
|
||||
|
||||
function durationMs(start) {
|
||||
return Number((process.hrtime.bigint() - start) / 1000000n);
|
||||
}
|
||||
|
||||
function receiptWriteError(file, state) {
|
||||
const error = new Error(`Could not publish finalize evidence "${file}" safely (${state.reason?.code || state.status}).`);
|
||||
error.finalizeCode = 'finalize/receipt-publication';
|
||||
error.finalizeEvidence = { file, state };
|
||||
return error;
|
||||
}
|
||||
|
||||
function captureReceipt(file) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const capture = captureAtomicOutput(file, { requestedEntryPolicy: 'regular-or-absent' });
|
||||
if (capture.status !== 'captured') throw receiptWriteError(file, capture);
|
||||
return capture;
|
||||
}
|
||||
|
||||
function writeJsonAtomic(file, value, capture, beforeCommit) {
|
||||
const serialized = Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
|
||||
const temporary = path.join(path.dirname(capture.commitPath), `.archify-finalize-${randomUUID()}.json`);
|
||||
let binding;
|
||||
let stagedIdentity;
|
||||
try {
|
||||
fs.writeFileSync(temporary, serialized, { flag: 'wx' });
|
||||
const metadata = fs.lstatSync(temporary, { bigint: true });
|
||||
stagedIdentity = { device: metadata.dev, inode: metadata.ino };
|
||||
if (capture.mode !== null) fs.chmodSync(temporary, capture.mode);
|
||||
const staged = captureRegularFileBinding(temporary, {
|
||||
subject: 'finalize-receipt', expectedIdentity: stagedIdentity,
|
||||
expectedSha256: sha256(serialized), expectedBytes: serialized.byteLength,
|
||||
...(capture.mode === null ? {} : { expectedMode: capture.mode }),
|
||||
});
|
||||
if (staged.status !== 'captured') throw receiptWriteError(file, staged);
|
||||
binding = staged.binding;
|
||||
beforeCommit();
|
||||
const publication = publishRegularFileBinding(binding, temporary, capture.snapshot, { subject: 'finalize-receipt' });
|
||||
if (!['committed', 'committed-with-warning'].includes(publication.status)) throw receiptWriteError(file, publication);
|
||||
// Preserve recovery evidence instead of reporting a clean completion when cleanup failed.
|
||||
if (publication.status === 'committed-with-warning') throw receiptWriteError(file, publication);
|
||||
const next = captureReceipt(file);
|
||||
if (!pathsAlias(next.commitPath, capture.commitPath)) {
|
||||
throw receiptWriteError(file, { status: 'different', reason: { code: 'receipt-slot-changed' } });
|
||||
}
|
||||
return next;
|
||||
} finally {
|
||||
if (binding) releaseRegularFileBinding(binding);
|
||||
if (stagedIdentity) removeOwnedRegularFile(temporary, stagedIdentity, { subject: 'finalize-receipt' });
|
||||
}
|
||||
}
|
||||
|
||||
function parsedReceipt(stdout) {
|
||||
const source = String(stdout || '').trim();
|
||||
if (!source) return null;
|
||||
try { return JSON.parse(source); } catch { return null; }
|
||||
}
|
||||
|
||||
function isReceiptObject(receipt) {
|
||||
return Boolean(receipt && typeof receipt === 'object' && !Array.isArray(receipt));
|
||||
}
|
||||
|
||||
function validIdentity(value) {
|
||||
return isReceiptObject(value)
|
||||
&& typeof value.sha256 === 'string'
|
||||
&& /^[0-9a-f]{64}$/.test(value.sha256)
|
||||
&& Number.isSafeInteger(value.bytes)
|
||||
&& value.bytes >= 0;
|
||||
}
|
||||
|
||||
function identitiesMatch(left, right) {
|
||||
return validIdentity(left)
|
||||
&& validIdentity(right)
|
||||
&& left.sha256 === right.sha256
|
||||
&& left.bytes === right.bytes;
|
||||
}
|
||||
|
||||
function validReceiptId(value) {
|
||||
return typeof value === 'string'
|
||||
&& /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
||||
}
|
||||
|
||||
function allChecksPassed(checks) {
|
||||
return Array.isArray(checks) && checks.length > 0
|
||||
&& checks.every((check) => isReceiptObject(check) && check.ok === true);
|
||||
}
|
||||
|
||||
function validCheckComposition(receipt, quality) {
|
||||
const composition = receipt?.composition;
|
||||
return isReceiptObject(composition)
|
||||
&& composition.schemaVersion === 1
|
||||
&& composition.profile === quality
|
||||
&& composition.status === 'pass'
|
||||
&& composition.summary?.errors === 0
|
||||
&& (quality !== 'showcase' || composition.summary?.warnings === 0);
|
||||
}
|
||||
|
||||
function exactViewportCoverage(entries, viewports, predicate) {
|
||||
if (!Array.isArray(entries) || entries.length !== viewports.length) return false;
|
||||
return viewports.every((expected) => {
|
||||
const matching = entries.filter((entry) => (
|
||||
isReceiptObject(entry)
|
||||
&& entry.width === expected.width
|
||||
&& entry.height === expected.height
|
||||
&& (expected.theme === undefined || entry.requestedTheme === expected.theme)
|
||||
));
|
||||
return matching.length === 1 && predicate(matching[0], expected);
|
||||
});
|
||||
}
|
||||
|
||||
function validBrowserEvidence(receipt) {
|
||||
return exactViewportCoverage(receipt.containment?.viewports, VISUAL_CHECK_VIEWPORTS, (entry) => (
|
||||
entry.theme === 'light' && entry.ok === true
|
||||
))
|
||||
&& exactViewportCoverage(receipt.readability?.viewports, VISUAL_CHECK_VIEWPORTS, (entry) => (
|
||||
entry.theme === 'light' && entry.ok === true && entry.readabilityOk === true
|
||||
))
|
||||
&& exactViewportCoverage(receipt.viewerChrome?.viewports, VISUAL_CHECK_VIEWPORTS, (entry) => (
|
||||
entry.theme === 'light' && entry.ok === true && entry.viewerChromeOk === true
|
||||
))
|
||||
&& exactViewportCoverage(receipt.themeStates?.viewports,
|
||||
[...VISUAL_CHECK_VIEWPORTS.map((viewport) => ({ ...viewport, theme: 'light' })),
|
||||
...CAPTURE_VIEWPORTS.map((viewport) => ({ ...viewport, theme: 'dark' }))],
|
||||
(entry, expected) => entry.ok === true
|
||||
&& entry.requestedTheme === expected.theme
|
||||
&& entry.resolvedTheme === expected.theme);
|
||||
}
|
||||
|
||||
function validDeliveryValidation(receipt, quality, { allowShowcaseWarnings = false } = {}) {
|
||||
const validation = receipt?.validation;
|
||||
return isReceiptObject(validation)
|
||||
&& Number.isInteger(validation.checkCount)
|
||||
&& validation.checkCount > 0
|
||||
&& validation.checksPassed === validation.checkCount
|
||||
&& validation.compositionStatus === 'pass'
|
||||
&& validation.errors === 0
|
||||
&& validation.compositionProfile === quality
|
||||
&& (quality !== 'showcase' || allowShowcaseWarnings || validation.warnings === 0);
|
||||
}
|
||||
|
||||
function validStageReceipt(stage, receipt, quality, options) {
|
||||
if (!isReceiptObject(receipt) || receipt.ok !== true) return false;
|
||||
if (receipt.status && receipt.status !== 'pass') return false;
|
||||
if (stage === 'validate') return receipt.command === 'validate' && Array.isArray(receipt.checks);
|
||||
if (stage === 'deliver') {
|
||||
return receipt.command === 'deliver'
|
||||
&& receipt.schemaVersion === 1
|
||||
&& validReceiptId(receipt.receiptId)
|
||||
&& isReceiptObject(receipt.specification)
|
||||
&& validIdentity(receipt.specification)
|
||||
&& validIdentity(receipt.artifact)
|
||||
&& validDeliveryValidation(receipt, quality, options);
|
||||
}
|
||||
if (stage === 'check') {
|
||||
return validIdentity(receipt.artifact)
|
||||
&& allChecksPassed(receipt.checks)
|
||||
&& receipt.provenance === 'current'
|
||||
&& validReceiptId(receipt.deliveryReceiptId)
|
||||
&& validCheckComposition(receipt, quality);
|
||||
}
|
||||
return receipt.command === 'browser-check'
|
||||
&& receipt.schemaVersion === 1
|
||||
&& receipt.status === 'pass'
|
||||
&& receipt.evidenceKind === 'automated-browser'
|
||||
&& validIdentity(receipt.artifact)
|
||||
&& receipt.provenance === 'current'
|
||||
&& validReceiptId(receipt.deliveryReceiptId)
|
||||
&& receipt.containment?.status === 'pass'
|
||||
&& receipt.themeStates?.status === 'pass'
|
||||
&& receipt.readability?.status === 'pass'
|
||||
&& receipt.viewerChrome?.status === 'pass'
|
||||
&& validBrowserEvidence(receipt);
|
||||
}
|
||||
|
||||
function identityMismatchDiagnostic({ stage, expected, actual, expectedReceiptId, actualReceiptId, output }) {
|
||||
return {
|
||||
code: 'finalize/artifact-binding-mismatch',
|
||||
severity: 'error',
|
||||
message: `The ${stage} receipt does not prove the artifact delivered by this finalize run.`,
|
||||
subject: { stage, artifact: output },
|
||||
evidence: {
|
||||
expectedArtifact: expected,
|
||||
actualArtifact: actual,
|
||||
...(expectedReceiptId ? { expectedDeliveryReceiptId: expectedReceiptId } : {}),
|
||||
...(actualReceiptId ? { actualDeliveryReceiptId: actualReceiptId } : {}),
|
||||
},
|
||||
supportedFixes: ['finish other delivery attempts for this output, then rerun finalize from the frozen candidate'],
|
||||
};
|
||||
}
|
||||
|
||||
function showcaseWarningDiagnostics(receipt) {
|
||||
const issues = receipt.validation.compositionIssues;
|
||||
const warnings = receipt.validation.warnings;
|
||||
if (!Array.isArray(issues) || issues.length !== warnings
|
||||
|| !issues.every((issue) => isReceiptObject(issue)
|
||||
&& issue.severity === 'warning'
|
||||
&& typeof issue.code === 'string'
|
||||
&& issue.code.startsWith('composition/')
|
||||
&& typeof issue.detail === 'string' && issue.detail.trim())) {
|
||||
return [{
|
||||
code: 'finalize/showcase-warnings', severity: 'error',
|
||||
message: `Showcase delivery reported ${warnings} composition warning(s), but did not provide matching issue details.`,
|
||||
subject: { stage: 'deliver', check: 'composition' },
|
||||
evidence: { warnings, reportedIssues: Array.isArray(issues) ? issues.length : null },
|
||||
supportedFixes: ['run validate on the frozen candidate to inspect the composition warnings, repair them, then rerun finalize'],
|
||||
}];
|
||||
}
|
||||
return issues.map((issue) => {
|
||||
const { code, severity, detail, ...evidence } = issue;
|
||||
return {
|
||||
code, severity: 'error',
|
||||
message: `Showcase delivery reported ${code}; zero warnings are required.`,
|
||||
subject: { stage: 'deliver', check: 'composition', ...(issue.nodeId ? { nodeId: issue.nodeId } : {}) },
|
||||
evidence: { reportedSeverity: severity, ...evidence },
|
||||
supportedFixes: [detail.replace(/^\[[^\]]+\]\s*/, '')],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function stageBindingDiagnostic({ stage, receipt, expectedArtifact, expectedReceiptId, output, specification, type, deliveryValidation }) {
|
||||
if (stage === 'deliver') {
|
||||
if (receipt.type !== type) {
|
||||
return {
|
||||
code: 'finalize/delivery-type-mismatch',
|
||||
severity: 'error',
|
||||
message: 'The delivery command reported a different diagram type than finalize requested.',
|
||||
subject: { stage: 'deliver', artifact: output },
|
||||
evidence: { expectedType: type, actualType: receipt.type },
|
||||
supportedFixes: ['rerun finalize with a delivery command for the requested diagram type'],
|
||||
};
|
||||
}
|
||||
if (!identitiesMatch(receipt.specification, specification)) {
|
||||
return {
|
||||
code: 'finalize/candidate-changed-during-delivery',
|
||||
severity: 'error',
|
||||
message: 'The delivery command froze a different candidate than finalize started with.',
|
||||
subject: { stage: 'validate', candidate: specification.path },
|
||||
evidence: {
|
||||
expectedSha256: specification.sha256,
|
||||
actualSha256: receipt.specification?.sha256,
|
||||
expectedBytes: specification.bytes,
|
||||
actualBytes: receipt.specification?.bytes,
|
||||
},
|
||||
supportedFixes: ['restore the frozen candidate and rerun finalize'],
|
||||
};
|
||||
}
|
||||
if ((!receipt.output || !pathsAlias(receipt.output, output)) || !identitiesMatch(receipt.artifact, identity(output))) {
|
||||
return identityMismatchDiagnostic({
|
||||
stage,
|
||||
expected: receipt.artifact,
|
||||
actual: identity(output),
|
||||
expectedReceiptId: receipt.receiptId,
|
||||
output,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (stage === 'check' && deliveryValidation?.checkCount !== receipt.checks.length) {
|
||||
return {
|
||||
code: 'finalize/delivery-validation-mismatch',
|
||||
severity: 'error',
|
||||
message: 'The delivery validation count does not match the complete checker receipt for the delivered artifact.',
|
||||
subject: { stage: 'check', artifact: output },
|
||||
evidence: {
|
||||
deliveryCheckCount: deliveryValidation?.checkCount,
|
||||
checkerCheckCount: receipt.checks.length,
|
||||
},
|
||||
supportedFixes: ['restore the complete delivery validation receipt and rerun finalize from the frozen candidate'],
|
||||
};
|
||||
}
|
||||
const receiptArtifactPath = receipt.artifact?.path;
|
||||
const receiptFile = receipt.file;
|
||||
const pathMatchesOutput = stage === 'check'
|
||||
? typeof receiptFile === 'string' && pathsAlias(receiptFile, output)
|
||||
: typeof receiptArtifactPath === 'string' && pathsAlias(receiptArtifactPath, output);
|
||||
if (!pathMatchesOutput
|
||||
|| !identitiesMatch(receipt.artifact, expectedArtifact)
|
||||
|| receipt.deliveryReceiptId !== expectedReceiptId) {
|
||||
return identityMismatchDiagnostic({
|
||||
stage,
|
||||
expected: expectedArtifact,
|
||||
actual: receipt.artifact,
|
||||
expectedReceiptId,
|
||||
actualReceiptId: receipt.deliveryReceiptId,
|
||||
output,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function finalArtifactDiagnostic({ output, expectedArtifact, expectedReceiptId, type, deliveryPath, inspectDelivery }) {
|
||||
// Recheck dev's pending/lock, physical identity, and regular-file provenance barriers at handoff.
|
||||
const provenance = inspectDelivery?.(output);
|
||||
if (provenance && (!provenance.ok || provenance.receiptId !== expectedReceiptId)) {
|
||||
return provenance.diagnostics?.[0] || identityMismatchDiagnostic({
|
||||
stage: 'finalize', output, expected: expectedArtifact,
|
||||
expectedReceiptId, actualReceiptId: provenance.receiptId,
|
||||
});
|
||||
}
|
||||
const currentArtifact = identity(output);
|
||||
let delivery;
|
||||
try {
|
||||
delivery = JSON.parse(fs.readFileSync(deliveryPath, 'utf8'));
|
||||
} catch {
|
||||
delivery = null;
|
||||
}
|
||||
if (identitiesMatch(currentArtifact, expectedArtifact)
|
||||
&& delivery?.schemaVersion === 1
|
||||
&& delivery.command === 'deliver'
|
||||
&& delivery.status === 'current'
|
||||
&& delivery.receiptId === expectedReceiptId
|
||||
&& delivery.type === type
|
||||
&& typeof delivery.output === 'string' && pathsAlias(delivery.output, output)
|
||||
&& identitiesMatch(delivery.artifact, expectedArtifact)) return null;
|
||||
return {
|
||||
code: 'finalize/final-artifact-mismatch',
|
||||
severity: 'error',
|
||||
message: 'The artifact or delivery receipt changed after the final gate completed.',
|
||||
subject: { stage: 'finalize', artifact: output },
|
||||
evidence: {
|
||||
expectedArtifact,
|
||||
currentArtifact,
|
||||
expectedDeliveryReceiptId: expectedReceiptId,
|
||||
expectedType: type,
|
||||
...(delivery?.receiptId ? { currentDeliveryReceiptId: delivery.receiptId } : {}),
|
||||
...(delivery?.type ? { currentType: delivery.type } : {}),
|
||||
...(delivery?.artifact ? { currentDeliveryArtifact: delivery.artifact } : {}),
|
||||
},
|
||||
supportedFixes: ['finish other delivery attempts for this output, then rerun finalize from the frozen candidate'],
|
||||
};
|
||||
}
|
||||
|
||||
function stageStatus(stage, exitCode, receipt, quality) {
|
||||
if (exitCode === 2 || receipt?.status === 'skipped') return 'skipped';
|
||||
return exitCode === 0 && validStageReceipt(stage, receipt, quality) ? 'pass' : 'fail';
|
||||
}
|
||||
|
||||
function failureDiagnostics(stage, result, receipt, quality) {
|
||||
if (Array.isArray(receipt?.diagnostics) && receipt.diagnostics.length) return receipt.diagnostics;
|
||||
const invalidReceipt = (result.status ?? 1) === 0 && !validStageReceipt(stage, receipt, quality);
|
||||
return [{
|
||||
code: invalidReceipt ? 'finalize/invalid-stage-receipt' : 'finalize/stage-failure',
|
||||
severity: 'error',
|
||||
message: invalidReceipt
|
||||
? `The ${stage} stage exited successfully without a valid passing receipt.`
|
||||
: `The ${stage} stage did not complete successfully.`,
|
||||
subject: { stage },
|
||||
evidence: {
|
||||
exitCode: result.status ?? 1,
|
||||
...(result.signal ? { signal: result.signal } : {}),
|
||||
...(result.error?.message ? { reason: result.error.message } : {}),
|
||||
...(String(result.stdout || '').trim() ? { stdout: String(result.stdout).trim().slice(0, 2000) } : {}),
|
||||
...(String(result.stderr || '').trim() ? { stderr: String(result.stderr).trim().slice(0, 2000) } : {}),
|
||||
},
|
||||
supportedFixes: [invalidReceipt
|
||||
? `restore the ${stage} JSON receipt contract before retrying finalize`
|
||||
: 'use the compact finalize summary to repair the named subject in place, then rerun finalize once'],
|
||||
}];
|
||||
}
|
||||
|
||||
function sidecarFile(directory, value) {
|
||||
if (!value) return null;
|
||||
return path.resolve(directory, value);
|
||||
}
|
||||
|
||||
function browserEvidence(receipt, artifactPath) {
|
||||
if (!receipt) return {};
|
||||
const directory = receipt.sidecars?.directory
|
||||
? path.resolve(receipt.sidecars.directory)
|
||||
: path.dirname(path.resolve(artifactPath));
|
||||
return {
|
||||
...(receipt.sidecars?.receipt ? { browserCheckReceipt: sidecarFile(directory, receipt.sidecars.receipt) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultRunner({ cliPath, args, cwd, env }) {
|
||||
return new Promise((resolve) => {
|
||||
execFile(process.execPath, [cliPath, ...args], { cwd, env, encoding: 'utf8' }, (error, stdout, stderr) => {
|
||||
resolve({
|
||||
status: error ? (Number.isInteger(error.code) ? error.code : 1) : 0,
|
||||
stdout,
|
||||
stderr,
|
||||
...(error ? { error, signal: error.signal } : {}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function stageArguments({ stage, type, input, output, quality, repoRoot, outDir }) {
|
||||
const qualityArgs = ['--quality', quality];
|
||||
const repoArgs = repoRoot ? ['--repo-root', repoRoot] : [];
|
||||
if (stage === 'validate') return ['validate', type, input, ...qualityArgs, ...repoArgs, '--json'];
|
||||
if (stage === 'deliver') return ['deliver', type, input, output, ...qualityArgs, ...repoArgs, '--json'];
|
||||
if (stage === 'check') return ['check', output, '--require-provenance'];
|
||||
return [
|
||||
'browser-check', output, '--json', '--require-provenance',
|
||||
...(outDir ? ['--out-dir', outDir] : []),
|
||||
];
|
||||
}
|
||||
|
||||
export function defaultFinalizeReceiptPath(output, { outDir } = {}) {
|
||||
// Reuse the physical artifact and cross-directory namespace used by browser evidence.
|
||||
const browserReceipt = browserCheckSidecarPaths(output, { outDir }).receipt;
|
||||
const stem = path.basename(browserReceipt).slice(0, -'.browser-check.json'.length);
|
||||
return path.join(path.dirname(browserReceipt), `${boundedSidecarStem(stem, ['.finalize.json', '.finalize-summary.json'])}.finalize.json`);
|
||||
}
|
||||
|
||||
export function defaultFinalizeSummaryPath(receiptPath) {
|
||||
const receipt = path.resolve(receiptPath);
|
||||
const component = path.basename(receipt);
|
||||
const isDefault = /\.finalize\.json$/i.test(component);
|
||||
const stem = component.replace(isDefault ? /\.finalize\.json$/i : /\.json$/i, '');
|
||||
const suffix = isDefault ? '.finalize-summary.json' : '-summary.json';
|
||||
// Already bounded default stems share the receipt namespace; avoid hashing twice.
|
||||
const summaryStem = Buffer.byteLength(`${stem}${suffix}`) <= 255 && `${stem}${suffix}`.length <= 255
|
||||
? stem : boundedSidecarStem(stem, [suffix]);
|
||||
return path.join(path.dirname(receipt), `${summaryStem}${suffix}`);
|
||||
}
|
||||
|
||||
function defaultDeliveryPaths(output) {
|
||||
const artifact = canonicalFuturePath(output);
|
||||
const delivery = artifact.replace(/\.html?$/i, '.delivery.json');
|
||||
return {
|
||||
provenance: delivery,
|
||||
pending: delivery.replace(/\.json$/i, '-pending.json'),
|
||||
lock: delivery.replace(/\.json$/i, '-lock.json'),
|
||||
directoryLock: path.join(path.dirname(artifact), '.archify-delivery-lock.json'),
|
||||
};
|
||||
}
|
||||
|
||||
function reservedFinalizePaths({ input, output, outDir, deliveryPaths }) {
|
||||
const browser = browserCheckSidecarPaths(output, { outDir });
|
||||
return [path.resolve(input), path.resolve(output), ...Object.values(deliveryPaths(output)), browser.receipt];
|
||||
}
|
||||
|
||||
// Node moves that remove the measured crossings and detours, most specific
|
||||
// first. Only positions and sizes change; every relationship keeps its endpoints.
|
||||
function placementHints({ crossings = [], detours = [], crowdedSides = [] }) {
|
||||
const name = (relation) => `${relation.from} → ${relation.to}`;
|
||||
const other = (relation, shared) => (relation.from === shared ? relation.to : relation.from);
|
||||
const hints = [];
|
||||
for (const side of crowdedSides) {
|
||||
hints.push(`${side.node} has ${side.relationships} relationships facing its ${side.side} side, which fits ${Math.max(1, Math.floor((side.sidePx - 32) / 14) + 1)} ports: make that side at least ${side.neededPx}px, or move some of those neighbours so they face another side of ${side.node}.`);
|
||||
}
|
||||
for (const crossing of crossings) {
|
||||
const shared = crossing.sharedNode;
|
||||
hints.push(shared
|
||||
? `${name(crossing.left)} and ${name(crossing.right)} cross next to ${shared}: move node ${other(crossing.left, shared)} or ${other(crossing.right, shared)} so the two reach ${shared} from different sides (for example one level with it, one directly above or below it).`
|
||||
: `${name(crossing.left)} crosses ${name(crossing.right)}: move the node of whichever is a branch, return, or second entrance to the other side of the main path, so that relationship runs through an empty corridor.`);
|
||||
}
|
||||
for (const detour of detours) {
|
||||
if (detour.directCorridorBlockers?.length) continue;
|
||||
hints.push(`${name(detour.relationship)} needs ${detour.bends} bends: move node ${detour.relationship.from} or ${detour.relationship.to} so they share a row or column with matching centers, or sit diagonally with a clear corner.`);
|
||||
}
|
||||
return [...new Set(hints)].slice(0, 8);
|
||||
}
|
||||
|
||||
export function compactFinalizeReceipt(receipt) {
|
||||
const gates = {};
|
||||
for (const stage of FINALIZE_STAGES) gates[stage] = receipt.stages?.[stage]?.status || 'not-run';
|
||||
const allDiagnostics = receipt.diagnostics || [];
|
||||
const diagnosticLimit = 8;
|
||||
const selectedDiagnostics = [];
|
||||
const selectedIndexes = new Set();
|
||||
const seenCodes = new Set();
|
||||
const seenSubjects = new Set();
|
||||
const subjectKey = (entry) => {
|
||||
const subject = entry?.subject;
|
||||
if (!subject) return null;
|
||||
if (typeof subject === 'string') return subject;
|
||||
if (typeof subject !== 'object' || Array.isArray(subject)) return JSON.stringify(subject);
|
||||
for (const key of ['id', 'edge', 'connection', 'relationship', 'component', 'node', 'path', 'stage']) {
|
||||
if (subject[key] !== undefined) return `${key}:${JSON.stringify(subject[key])}`;
|
||||
}
|
||||
return JSON.stringify(subject);
|
||||
};
|
||||
const addDiagnostic = (entry, index) => {
|
||||
if (selectedDiagnostics.length >= diagnosticLimit || selectedIndexes.has(index)) return;
|
||||
selectedIndexes.add(index);
|
||||
selectedDiagnostics.push({
|
||||
code: entry.code,
|
||||
severity: entry.severity || 'error',
|
||||
message: entry.message,
|
||||
...(entry.subject ? { subject: entry.subject } : {}),
|
||||
...(entry.evidence && Object.keys(entry.evidence).length ? { evidence: entry.evidence } : {}),
|
||||
...(Array.isArray(entry.supportedFixes) && entry.supportedFixes.length
|
||||
? { supportedFixes: entry.supportedFixes.slice(0, 2) } : {}),
|
||||
});
|
||||
};
|
||||
for (const [index, entry] of allDiagnostics.entries()) {
|
||||
if (seenCodes.has(entry.code)) continue;
|
||||
seenCodes.add(entry.code);
|
||||
addDiagnostic(entry, index);
|
||||
}
|
||||
for (const [index, entry] of allDiagnostics.entries()) {
|
||||
const key = subjectKey(entry);
|
||||
if (!key || seenSubjects.has(key)) continue;
|
||||
seenSubjects.add(key);
|
||||
addDiagnostic(entry, index);
|
||||
}
|
||||
for (const [index, entry] of allDiagnostics.entries()) addDiagnostic(entry, index);
|
||||
const compact = {
|
||||
schemaVersion: 1,
|
||||
ok: receipt.ok,
|
||||
command: 'finalize',
|
||||
status: receipt.status,
|
||||
type: receipt.type,
|
||||
quality: receipt.quality,
|
||||
specification: receipt.specification,
|
||||
artifact: receipt.artifact,
|
||||
gates,
|
||||
...(receipt.failedStage ? { failedStage: receipt.failedStage } : {}),
|
||||
diagnostics: selectedDiagnostics,
|
||||
diagnosticSummary: {
|
||||
total: allDiagnostics.length,
|
||||
shown: selectedDiagnostics.length,
|
||||
truncated: allDiagnostics.length > selectedDiagnostics.length,
|
||||
},
|
||||
evidence: receipt.evidence,
|
||||
...(receipt.update ? { update: receipt.update } : {}),
|
||||
visualReview: receipt.visualReview || 'not-requested',
|
||||
durationMs: receipt.durationMs,
|
||||
};
|
||||
const metrics = receipt.stages?.check?.receipt?.composition?.metrics;
|
||||
const reviewSignals = Object.fromEntries([
|
||||
'resolvedCrossovers', 'routesOverSuggestedBends', 'routesOverSuggestedStretch',
|
||||
].filter((key) => Number.isFinite(metrics?.[key]) && metrics[key] > 0)
|
||||
.map((key) => [key, metrics[key]]));
|
||||
if (receipt.ok && Object.keys(reviewSignals).length) {
|
||||
const routeReview = receipt.stages?.check?.receipt?.composition?.routeReview;
|
||||
const hints = routeReview ? placementHints(routeReview) : [];
|
||||
compact.visualReviewRecommendation = {
|
||||
action: 'inspect-route-readability',
|
||||
signals: reviewSignals,
|
||||
reason: 'Automated gates passed, but crossings or detours still need perceptual review before claiming visual quality.',
|
||||
...(routeReview ? {
|
||||
affectedRoutes: {
|
||||
crossings: routeReview.crossings.slice(0, 8),
|
||||
detours: routeReview.detours.slice(0, 8),
|
||||
truncated: routeReview.crossings.length > 8 || routeReview.detours.length > 8,
|
||||
},
|
||||
...(hints.length ? { hints } : {}),
|
||||
repair: 'Trace these relationships at the desktop viewport. For Architecture, use references/architecture-layout-repair.md: reflow a blocked main path or tangled connected scene, and repair an isolated defect locally only when the surrounding composition is accepted. Preserve all semantic content and user-fixed geometry. Rerun finalize once after the edit.',
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
const leadingSpace = receipt.stages?.check?.receipt?.composition?.leadingSpace;
|
||||
if (receipt.ok && leadingSpace?.reviewSuggested === true) {
|
||||
compact.layoutReviewRecommendation = {
|
||||
action: 'inspect-leading-space',
|
||||
evidence: leadingSpace,
|
||||
reason: 'Measured content, including routes and labels, leaves a large empty area above the diagram. This is a composition suggestion, not a failed gate.',
|
||||
repair: 'Check whether that leading space is intentional. If not, reposition the connected scene nearer the canvas origin while retaining room for its actual boundaries, labels and return routes. Preserve all meaning and user-fixed geometry, then rerun finalize. No screenshot is required.',
|
||||
};
|
||||
}
|
||||
const sequenceColumnSpace = receipt.stages?.check?.receipt?.composition?.sequenceColumnSpace;
|
||||
if (receipt.ok && receipt.type === 'sequence' && sequenceColumnSpace?.reviewSuggested === true) {
|
||||
compact.layoutReviewRecommendation = {
|
||||
action: 'inspect-sequence-width',
|
||||
evidence: sequenceColumnSpace,
|
||||
reason: 'Fixed participant columns leave substantial unused space on the right, after accounting for message labels and notes. This is a layout suggestion, not a failed gate.',
|
||||
repair: 'For a newly authored Sequence with omitted meta.column_fit and no user-fixed column geometry, set meta.column_fit to "spread" and rerun finalize once. Preserve participant order, every message, its y position, labels, notes and sources. Retain explicit fixed layouts and legacy inputs; report the suggestion instead of changing them automatically.',
|
||||
};
|
||||
}
|
||||
if (!receipt.ok && receipt.status === 'fail' && receipt.failedStage === 'validate') {
|
||||
compact.nextAction = {
|
||||
action: 'edit-in-place',
|
||||
candidate: receipt.specification?.path,
|
||||
constraint: receipt.type === 'architecture'
|
||||
? 'Preserve all semantics and user-fixed geometry. Use references/architecture-layout-repair.md to choose a local repair or connected-scene reflow; edit the existing candidate.'
|
||||
: 'Preserve unaffected semantics and geometry; do not replace the whole candidate.',
|
||||
then: 'finalize-once',
|
||||
};
|
||||
}
|
||||
return compact;
|
||||
}
|
||||
|
||||
export async function runFinalize({
|
||||
cliPath,
|
||||
type,
|
||||
input,
|
||||
output,
|
||||
quality = 'showcase',
|
||||
repoRoot,
|
||||
candidateSha256,
|
||||
outDir,
|
||||
receiptPath,
|
||||
deliveryPaths = defaultDeliveryPaths,
|
||||
inspectDelivery,
|
||||
cwd = process.cwd(),
|
||||
env = process.env,
|
||||
runCommand = defaultRunner,
|
||||
runBrowserCheck,
|
||||
startUpdateCheck = startDeliveryUpdateCheck,
|
||||
resolveChrome = findChrome,
|
||||
createBrowser = (chromePath, options) => new ChromeVisualBrowser(chromePath, options),
|
||||
} = {}) {
|
||||
if (!cliPath || !type || !input || !output) throw new Error('finalize requires cliPath, type, input, and output.');
|
||||
const started = process.hrtime.bigint();
|
||||
const startedAt = new Date().toISOString();
|
||||
const resolvedInput = path.resolve(input);
|
||||
const resolvedOutput = resolveOutputPath({ requestedOutput: output, inputPaths: [resolvedInput] }).outputPath;
|
||||
const resolvedOutDir = outDir === undefined ? undefined : resolveNativeOutputDirectory(outDir);
|
||||
const specification = identity(resolvedInput);
|
||||
if (candidateSha256 && specification.sha256 !== candidateSha256) {
|
||||
const error = new Error(`The candidate changed after validation: expected sha256 ${candidateSha256}, found ${specification.sha256 || 'unreadable'}.`);
|
||||
error.finalizeCode = 'finalize/candidate-changed';
|
||||
error.finalizeEvidence = {
|
||||
candidate: resolvedInput,
|
||||
expectedSha256: candidateSha256,
|
||||
...(specification.sha256 ? { actualSha256: specification.sha256 } : {}),
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
// Validate raw native CLI paths before creating directories or normalizing them.
|
||||
if (receiptPath !== undefined) resolveOutputPath({ requestedOutput: receiptPath, inputPaths: [resolvedInput], requiredExtension: '.json' });
|
||||
fs.mkdirSync(path.dirname(canonicalFuturePath(resolvedOutput)), { recursive: true });
|
||||
const resolvedReceipt = resolveOutputPath({
|
||||
requestedOutput: receiptPath ?? defaultFinalizeReceiptPath(resolvedOutput, { outDir: resolvedOutDir }),
|
||||
inputPaths: [resolvedInput], requiredExtension: '.json',
|
||||
}).outputPath;
|
||||
const resolvedSummary = resolveOutputPath({
|
||||
requestedOutput: defaultFinalizeSummaryPath(resolvedReceipt), inputPaths: [resolvedInput], requiredExtension: '.json',
|
||||
}).outputPath;
|
||||
const assertReceiptPaths = () => {
|
||||
const reserved = reservedFinalizePaths({ input: resolvedInput, output: resolvedOutput, outDir: resolvedOutDir, deliveryPaths });
|
||||
const receiptCollision = reserved.find((file) => pathsAlias(resolvedReceipt, file));
|
||||
if (receiptCollision) throw new Error(`The finalize receipt must be distinct from the specification, artifact, and gate sidecars: "${receiptCollision}".`);
|
||||
const summaryCollision = [resolvedReceipt, ...reserved].find((file) => pathsAlias(resolvedSummary, file));
|
||||
if (summaryCollision) throw new Error(`The finalize summary must be distinct from the full receipt, specification, artifact, and gate sidecars: "${summaryCollision}".`);
|
||||
};
|
||||
assertReceiptPaths();
|
||||
// Capture both paths before changing either: an unsafe summary cannot leave a new full receipt behind.
|
||||
let receiptCapture = captureReceipt(resolvedReceipt);
|
||||
let summaryCapture = captureReceipt(resolvedSummary);
|
||||
|
||||
const receipt = {
|
||||
schemaVersion: 1,
|
||||
ok: false,
|
||||
command: 'finalize',
|
||||
status: 'running',
|
||||
type,
|
||||
quality,
|
||||
startedAt,
|
||||
specification,
|
||||
artifact: { path: resolvedOutput },
|
||||
stages: {},
|
||||
diagnostics: [],
|
||||
evidence: { receipt: resolvedReceipt, summaryReceipt: resolvedSummary },
|
||||
visualReview: 'not-requested',
|
||||
};
|
||||
const persistReceipts = () => {
|
||||
assertReceiptPaths();
|
||||
for (const [file, capture] of [[resolvedReceipt, receiptCapture], [resolvedSummary, summaryCapture]]) {
|
||||
const current = verifyAtomicOutput(capture.snapshot);
|
||||
if (current.status !== 'match') throw receiptWriteError(file, current);
|
||||
}
|
||||
receiptCapture = writeJsonAtomic(resolvedReceipt, receipt, receiptCapture, assertReceiptPaths);
|
||||
summaryCapture = writeJsonAtomic(resolvedSummary, compactFinalizeReceipt(receipt), summaryCapture, assertReceiptPaths);
|
||||
};
|
||||
persistReceipts();
|
||||
// The gates below usually take seconds, so a slower network can finish the
|
||||
// update check in parallel instead of timing out on every delivery.
|
||||
const updateCheck = startUpdateCheck({ env, deadlineMs: FINALIZE_UPDATE_DEADLINE_MS });
|
||||
|
||||
// Only launch/attach the blank browser here. The normal browser gate still
|
||||
// verifies current delivery provenance before it consumes this one-shot factory.
|
||||
const chromePath = runBrowserCheck ? resolveChrome({ env }) : null;
|
||||
let browser;
|
||||
let browserStartupError;
|
||||
let browserTransferred = false;
|
||||
if (chromePath) {
|
||||
try {
|
||||
browser = createBrowser(chromePath, { env });
|
||||
// Deliver/check may fail before inspect() awaits startup. Handle the
|
||||
// rejection now while retaining the same promise for the browser gate.
|
||||
browser.sessionPromise.catch(() => {});
|
||||
} catch (error) {
|
||||
browserStartupError = error;
|
||||
}
|
||||
}
|
||||
const browserFactory = () => {
|
||||
if (browserStartupError) throw browserStartupError;
|
||||
if (browserTransferred || !browser) throw new Error('The finalize browser is unavailable or already consumed.');
|
||||
browserTransferred = true;
|
||||
return browser;
|
||||
};
|
||||
|
||||
try {
|
||||
let exitCode = 0;
|
||||
for (const stage of ['deliver', 'check', 'browser-check']) {
|
||||
const stageStarted = process.hrtime.bigint();
|
||||
const args = stageArguments({
|
||||
stage,
|
||||
type,
|
||||
input: resolvedInput,
|
||||
output: resolvedOutput,
|
||||
quality,
|
||||
repoRoot,
|
||||
outDir: resolvedOutDir,
|
||||
});
|
||||
const inProcess = stage === 'browser-check' && runBrowserCheck;
|
||||
let result;
|
||||
if (inProcess) {
|
||||
const checked = await runBrowserCheck({
|
||||
artifactPath: resolvedOutput,
|
||||
outDir: resolvedOutDir,
|
||||
chromePath,
|
||||
resolveChrome: () => chromePath,
|
||||
browserFactory,
|
||||
});
|
||||
result = { status: checked.exitCode, stdout: JSON.stringify(checked.receipt) };
|
||||
} else {
|
||||
result = await runCommand({ stage, cliPath, args, cwd,
|
||||
env: stage === 'deliver' ? { ...env, ARCHIFY_UPDATE_CHECK_DISABLED: '1' } : env });
|
||||
}
|
||||
const stageReceipt = parsedReceipt(result.stdout);
|
||||
const code = result.status ?? 1;
|
||||
let status = stageStatus(stage, code, stageReceipt, quality);
|
||||
let stageDiagnostics;
|
||||
const command = [process.execPath, cliPath, ...args];
|
||||
const elapsed = durationMs(stageStarted);
|
||||
if (status === 'pass') {
|
||||
const deliveryReceipt = stage === 'deliver' ? stageReceipt : receipt.stages.deliver?.receipt;
|
||||
const bindingDiagnostic = stageBindingDiagnostic({
|
||||
stage,
|
||||
receipt: stageReceipt,
|
||||
expectedArtifact: deliveryReceipt?.artifact,
|
||||
expectedReceiptId: deliveryReceipt?.receiptId,
|
||||
output: resolvedOutput,
|
||||
specification,
|
||||
type,
|
||||
deliveryValidation: deliveryReceipt?.validation,
|
||||
});
|
||||
if (bindingDiagnostic) stageDiagnostics = [bindingDiagnostic];
|
||||
} else if (stage === 'deliver' && code === 0 && quality === 'showcase'
|
||||
&& Number.isSafeInteger(stageReceipt?.validation?.warnings)
|
||||
&& stageReceipt.validation.warnings > 0
|
||||
&& validStageReceipt(stage, stageReceipt, quality, { allowShowcaseWarnings: true })) {
|
||||
const bindingDiagnostic = stageBindingDiagnostic({
|
||||
stage, receipt: stageReceipt, output: resolvedOutput, specification, type,
|
||||
});
|
||||
stageDiagnostics = bindingDiagnostic ? [bindingDiagnostic] : showcaseWarningDiagnostics(stageReceipt);
|
||||
}
|
||||
if (stageDiagnostics) {
|
||||
status = 'fail';
|
||||
}
|
||||
const stageEntry = {
|
||||
status,
|
||||
exitCode: code,
|
||||
durationMs: elapsed,
|
||||
command,
|
||||
...(inProcess ? { execution: 'in-process' } : {}),
|
||||
...(stageReceipt ? { receipt: stageReceipt } : {}),
|
||||
...(!stageReceipt && String(result.stdout || '').trim() ? { stdout: String(result.stdout).trim() } : {}),
|
||||
...(String(result.stderr || '').trim() ? { stderr: String(result.stderr).trim() } : {}),
|
||||
...(result.signal ? { signal: result.signal } : {}),
|
||||
};
|
||||
|
||||
if (stage === 'deliver' && status === 'pass') {
|
||||
receipt.stages.validate = {
|
||||
status: 'pass',
|
||||
exitCode: 0,
|
||||
durationMs: null,
|
||||
execution: 'embedded-in-deliver',
|
||||
command,
|
||||
receipt: {
|
||||
schemaVersion: 1,
|
||||
ok: true,
|
||||
command: 'validate',
|
||||
specification: stageReceipt.specification,
|
||||
validation: stageReceipt.validation,
|
||||
},
|
||||
};
|
||||
receipt.stages.deliver = stageEntry;
|
||||
} else if (stage === 'deliver') {
|
||||
const validationFailed = stageDiagnostics?.some((diagnostic) => diagnostic.code === 'finalize/candidate-changed-during-delivery')
|
||||
|| ['input', 'render', 'check'].includes(stageReceipt?.stage);
|
||||
receipt.stages.validate = validationFailed ? {
|
||||
...stageEntry,
|
||||
status: 'fail',
|
||||
durationMs: null,
|
||||
execution: 'embedded-in-deliver',
|
||||
} : { status: 'not-run', execution: 'embedded-in-deliver' };
|
||||
receipt.stages.deliver = validationFailed
|
||||
? { status: 'not-run', execution: 'blocked-by-validate' }
|
||||
: stageEntry;
|
||||
} else {
|
||||
receipt.stages[stage] = stageEntry;
|
||||
}
|
||||
|
||||
if (stage === 'deliver' && stageReceipt?.artifact) receipt.artifact = {
|
||||
path: resolvedOutput,
|
||||
...stageReceipt.artifact,
|
||||
};
|
||||
if (stage === 'browser-check') {
|
||||
receipt.evidence = {
|
||||
receipt: resolvedReceipt,
|
||||
summaryReceipt: resolvedSummary,
|
||||
...browserEvidence(stageReceipt, resolvedOutput),
|
||||
};
|
||||
}
|
||||
|
||||
if (status !== 'pass') {
|
||||
exitCode = status === 'skipped' ? 2 : (code || 1);
|
||||
receipt.status = status;
|
||||
receipt.diagnostics = stageDiagnostics || failureDiagnostics(stage, result, stageReceipt, quality);
|
||||
receipt.failedStage = stage === 'deliver'
|
||||
&& receipt.stages.validate.status === 'fail' ? 'validate' : stage;
|
||||
break;
|
||||
}
|
||||
persistReceipts();
|
||||
}
|
||||
|
||||
receipt.ok = exitCode === 0;
|
||||
receipt.status = receipt.ok ? 'pass' : receipt.status === 'running' ? 'fail' : receipt.status;
|
||||
if (receipt.ok) {
|
||||
const deliveryReceipt = receipt.stages.deliver?.receipt;
|
||||
const diagnostic = finalArtifactDiagnostic({
|
||||
output: resolvedOutput,
|
||||
expectedArtifact: deliveryReceipt?.artifact,
|
||||
expectedReceiptId: deliveryReceipt?.receiptId,
|
||||
type,
|
||||
deliveryPath: deliveryPaths(resolvedOutput).provenance,
|
||||
inspectDelivery,
|
||||
});
|
||||
if (diagnostic) {
|
||||
exitCode = 1;
|
||||
receipt.ok = false;
|
||||
receipt.status = 'fail';
|
||||
receipt.failedStage = 'finalize';
|
||||
receipt.diagnostics = [diagnostic];
|
||||
}
|
||||
}
|
||||
if (receipt.ok && !identitiesMatch(specification, identity(resolvedInput))) {
|
||||
exitCode = 1;
|
||||
receipt.ok = false;
|
||||
receipt.status = 'fail';
|
||||
receipt.failedStage = 'finalize';
|
||||
receipt.diagnostics = [{
|
||||
code: 'finalize/candidate-changed', severity: 'error',
|
||||
message: 'The frozen candidate changed before the final handoff.',
|
||||
subject: { candidate: resolvedInput },
|
||||
evidence: { expected: specification, actual: identity(resolvedInput) },
|
||||
supportedFixes: ['restore the frozen candidate, or finalize the edited candidate as a new attempt'],
|
||||
}];
|
||||
}
|
||||
receipt.artifact = receipt.ok
|
||||
? { path: resolvedOutput, ...receipt.stages.deliver.receipt.artifact }
|
||||
: identity(resolvedOutput);
|
||||
receipt.finishedAt = new Date().toISOString();
|
||||
receipt.durationMs = durationMs(started);
|
||||
receipt.update = await updateCheck;
|
||||
persistReceipts();
|
||||
return { exitCode, receipt, summary: compactFinalizeReceipt(receipt) };
|
||||
} finally {
|
||||
if (browser && !browserTransferred) await browser.close();
|
||||
await updateCheck;
|
||||
}
|
||||
}
|
||||
Vendored
+146
@@ -0,0 +1,146 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
const OPENERS = {
|
||||
darwin: {
|
||||
command: 'open',
|
||||
method: 'open',
|
||||
timeoutMs: 5000,
|
||||
args: (target) => [target],
|
||||
},
|
||||
linux: {
|
||||
command: 'xdg-open',
|
||||
method: 'xdg-open',
|
||||
timeoutMs: 5000,
|
||||
args: (target) => [target],
|
||||
},
|
||||
win32: {
|
||||
command: 'powershell.exe',
|
||||
method: 'powershell',
|
||||
// PowerShell cold starts can approach five seconds on hosted Windows
|
||||
// runners. Keep the launch bounded without treating normal startup as a
|
||||
// timeout.
|
||||
timeoutMs: 15000,
|
||||
// Keep the command constant and pass the target through a child-only
|
||||
// environment variable. Paths are never interpolated into executable source.
|
||||
args: () => [
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
'Start-Process -FilePath $env:ARCHIFY_OPEN_TARGET',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function failureDetails(result, opener, timeoutMs) {
|
||||
const error = result?.error;
|
||||
if (error?.code === 'ENOENT') {
|
||||
return {
|
||||
code: 'opener/unavailable',
|
||||
reason: `Could not find ${opener.command}. Install or enable the platform opener, then open the target manually.`,
|
||||
systemCode: 'ENOENT',
|
||||
};
|
||||
}
|
||||
if (error?.code === 'ETIMEDOUT') {
|
||||
return {
|
||||
code: 'opener/timeout',
|
||||
reason: `${opener.command} did not finish within ${timeoutMs}ms. Open the target manually or retry when the system is less busy.`,
|
||||
systemCode: 'ETIMEDOUT',
|
||||
timeoutMs,
|
||||
};
|
||||
}
|
||||
if (error) {
|
||||
return {
|
||||
code: 'opener/spawn-failed',
|
||||
reason: error.message || `${opener.command} could not be started. Open the target manually.`,
|
||||
...(error.code ? { systemCode: String(error.code) } : {}),
|
||||
};
|
||||
}
|
||||
if (result?.signal) {
|
||||
return {
|
||||
code: 'opener/signaled',
|
||||
reason: `${opener.command} was terminated by ${result.signal}. Open the target manually.`,
|
||||
signal: result.signal,
|
||||
};
|
||||
}
|
||||
if (result?.status !== 0) {
|
||||
return {
|
||||
code: 'opener/nonzero-exit',
|
||||
reason: `${opener.command} exited with status ${result?.status ?? 'unknown'}. Open the target manually.`,
|
||||
exitCode: result?.status ?? null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function launchTimeout(value, fallback) {
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function launchTarget(target, options = {}) {
|
||||
const platform = options.platform || process.platform;
|
||||
const opener = OPENERS[platform];
|
||||
if (!opener) {
|
||||
return {
|
||||
requested: true,
|
||||
status: 'unsupported',
|
||||
target,
|
||||
method: null,
|
||||
};
|
||||
}
|
||||
|
||||
const spawn = options.spawn || spawnSync;
|
||||
const timeoutMs = launchTimeout(options.timeoutMs, opener.timeoutMs);
|
||||
let result;
|
||||
try {
|
||||
const spawnOptions = {
|
||||
encoding: 'utf8',
|
||||
shell: false,
|
||||
stdio: 'ignore',
|
||||
timeout: timeoutMs,
|
||||
windowsHide: true,
|
||||
};
|
||||
if (platform === 'win32') {
|
||||
spawnOptions.env = {
|
||||
...process.env,
|
||||
ARCHIFY_OPEN_TARGET: target,
|
||||
};
|
||||
}
|
||||
result = spawn(opener.command, opener.args(target), spawnOptions);
|
||||
} catch (error) {
|
||||
result = { error };
|
||||
}
|
||||
|
||||
const failure = failureDetails(result, opener, timeoutMs);
|
||||
let status = 'opened';
|
||||
if (failure?.code === 'opener/unavailable') status = 'unsupported';
|
||||
else if (failure) status = 'failed';
|
||||
|
||||
return {
|
||||
requested: true,
|
||||
status,
|
||||
target,
|
||||
method: opener.method,
|
||||
...(failure ? { failure } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function openArtifact(target, options = {}) {
|
||||
return launchTarget(path.resolve(target), options);
|
||||
}
|
||||
|
||||
export function openLoopbackUrl(target, options = {}) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(target);
|
||||
} catch {
|
||||
throw new TypeError('Preview URL must be a valid loopback HTTP URL.');
|
||||
}
|
||||
if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port) {
|
||||
throw new TypeError('Preview URL must be a loopback URL using http://127.0.0.1:<port>.');
|
||||
}
|
||||
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
|
||||
throw new TypeError('Preview URL must target the loopback preview root.');
|
||||
}
|
||||
return launchTarget(url.href, options);
|
||||
}
|
||||
Vendored
+969
@@ -0,0 +1,969 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { openLoopbackUrl } from './open-artifact.mjs';
|
||||
import { resolveOutputPath } from '../renderers/shared/output-path.mjs';
|
||||
import { sameLocation } from '../renderers/shared/path-semantics.mjs';
|
||||
import {
|
||||
captureAtomicOutput,
|
||||
captureRegularFileBinding,
|
||||
publishRegularFileBinding,
|
||||
releaseRegularFileBinding,
|
||||
removeEmptyDirectoryWithRetry,
|
||||
removeOwnedRegularFile,
|
||||
verifyAtomicOutput,
|
||||
} from '../renderers/shared/atomic-output.mjs';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const cliPath = path.join(here, 'archify.mjs');
|
||||
const loopbackHost = '127.0.0.1';
|
||||
const defaultDebounceMs = 400;
|
||||
const defaultPollMs = 800;
|
||||
const defaultStopGraceMs = 3000;
|
||||
const defaultStopKillMs = 750;
|
||||
const diagramTypes = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']);
|
||||
let previewCommitSequence = 0;
|
||||
|
||||
function sha256(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function sourceDigest(inputPath) {
|
||||
try {
|
||||
const bytes = fs.readFileSync(inputPath);
|
||||
return { hash: sha256(bytes), bytes, missing: false };
|
||||
} catch (error) {
|
||||
return { hash: `unreadable:${error.code || 'unknown'}`, bytes: null, missing: true };
|
||||
}
|
||||
}
|
||||
|
||||
function initialAuthoredOutput(inputPath) {
|
||||
try {
|
||||
const source = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
|
||||
if (typeof source?.meta?.output === 'string') {
|
||||
return source.meta.output;
|
||||
}
|
||||
} catch {
|
||||
// An invalid initial source still gets a status shell. Its output target is
|
||||
// fixed to the same fallback that `deliver` would use after repair.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function previewPage() {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Archify Live Preview</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #0b111b; }
|
||||
body { display: grid; grid-template-rows: auto minmax(0, 1fr); color: #e8edf5; }
|
||||
header { position: relative; z-index: 2; display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 7px 12px; border-bottom: 1px solid #253248; background: rgba(11, 17, 27, .96); box-shadow: 0 8px 22px rgba(0,0,0,.18); }
|
||||
.brand { font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #9cadc6; }
|
||||
#status { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; min-height: 30px; padding: 5px 10px; border: 1px solid #33435d; border-radius: 999px; background: #111b2a; font-size: 12px; white-space: nowrap; }
|
||||
#status::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: #6f819d; }
|
||||
body[data-state="checking"] #status::before { background: #f3b44b; box-shadow: 0 0 0 4px rgba(243,180,75,.12); }
|
||||
body[data-state="verified"] #status::before { background: #45d6a8; box-shadow: 0 0 0 4px rgba(69,214,168,.12); }
|
||||
body[data-state="needs-fix"] #status::before { background: #ff6f78; box-shadow: 0 0 0 4px rgba(255,111,120,.12); }
|
||||
details { max-width: min(62vw, 760px); }
|
||||
summary { cursor: pointer; color: #ffbdc2; font-size: 12px; }
|
||||
.diagnostic { position: absolute; top: 38px; right: 12px; width: min(760px, calc(100vw - 24px)); max-height: min(44vh, 360px); overflow: auto; padding: 14px; border: 1px solid #6a3440; border-radius: 10px; background: #17131b; box-shadow: 0 14px 48px rgba(0,0,0,.42); }
|
||||
pre { margin: 0 0 10px; white-space: pre-wrap; overflow-wrap: anywhere; font: 11px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; color: #f2dfe2; }
|
||||
button { min-height: 32px; padding: 5px 10px; border: 1px solid #4a5d79; border-radius: 7px; background: #1a273a; color: #eef4ff; cursor: pointer; }
|
||||
main { position: relative; min-height: 0; }
|
||||
iframe { display: none; width: 100%; height: 100%; border: 0; background: #fff; }
|
||||
body[data-has-artifact="true"] iframe { display: block; }
|
||||
#empty { position: absolute; inset: 0; display: grid; place-items: center; padding: 32px; color: #91a2bc; text-align: center; background: radial-gradient(circle at 50% 38%, #15233a 0, #0b111b 55%); }
|
||||
body[data-has-artifact="true"] #empty { display: none; }
|
||||
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } }
|
||||
</style>
|
||||
</head>
|
||||
<body data-state="checking" data-has-artifact="false">
|
||||
<header>
|
||||
<span class="brand">Archify Preview</span>
|
||||
<details id="failure" hidden>
|
||||
<summary role="button" aria-controls="diagnostic-panel">View diagnostic</summary>
|
||||
<div class="diagnostic" id="diagnostic-panel"><pre id="diagnostic"></pre><button id="copy" type="button">Copy diagnostic</button></div>
|
||||
</details>
|
||||
<span id="status" role="status" aria-live="polite">Checking · generation 1</span>
|
||||
</header>
|
||||
<main>
|
||||
<div id="empty">Waiting for the first verified diagram. Invalid input will stay here with an exact diagnostic.</div>
|
||||
<iframe id="artifact" title="Verified Archify diagram"></iframe>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var body = document.body;
|
||||
var status = document.getElementById('status');
|
||||
var failure = document.getElementById('failure');
|
||||
var diagnostic = document.getElementById('diagnostic');
|
||||
var artifact = document.getElementById('artifact');
|
||||
var lastRevision = 0;
|
||||
|
||||
function render(state) {
|
||||
body.dataset.state = state.status;
|
||||
if (state.status === 'verified') {
|
||||
status.textContent = 'Verified · rev ' + state.revision;
|
||||
failure.hidden = true;
|
||||
failure.open = false;
|
||||
if (state.revision !== lastRevision) {
|
||||
lastRevision = state.revision;
|
||||
artifact.src = '/artifact.html?revision=' + encodeURIComponent(state.revision) + '&sha=' + encodeURIComponent(state.lastVerified.sha256.slice(0, 12));
|
||||
body.dataset.hasArtifact = 'true';
|
||||
}
|
||||
} else if (state.status === 'needs-fix') {
|
||||
status.textContent = 'Needs fix · ' + (state.revision ? 'showing rev ' + state.revision : 'no verified revision');
|
||||
diagnostic.textContent = 'Generation ' + state.generation + ' · ' + state.failure.stage + '\\n\\n' + state.failure.message;
|
||||
failure.hidden = false;
|
||||
} else {
|
||||
status.textContent = 'Checking · generation ' + state.generation;
|
||||
failure.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('copy').addEventListener('click', function () {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(diagnostic.textContent).catch(function () {});
|
||||
}
|
||||
});
|
||||
|
||||
var events = new EventSource('/events');
|
||||
events.addEventListener('state', function (event) {
|
||||
try { render(JSON.parse(event.data)); } catch (_) {}
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function compactMessage(value) {
|
||||
let text = String(value || 'Preview build failed without a diagnostic.').trim();
|
||||
const lines = text.split(/\r?\n/);
|
||||
const errorLine = lines.findIndex((line) => /^Error:\s/.test(line));
|
||||
if (errorLine > 0) text = lines.slice(errorLine).join('\n');
|
||||
const relevant = text.split(/\r?\n/);
|
||||
const stackLine = relevant.findIndex((line, index) => index > 0 && /^\s*at\s/.test(line));
|
||||
if (stackLine > 0) text = relevant.slice(0, stackLine).join('\n');
|
||||
return text.length > 6000 ? `${text.slice(0, 6000)}\n… diagnostic truncated` : text;
|
||||
}
|
||||
|
||||
function redactDiagnostic(value, paths) {
|
||||
let text = compactMessage(value);
|
||||
for (const [absolutePath, replacement] of paths) {
|
||||
if (!absolutePath) continue;
|
||||
text = text.split(absolutePath).join(replacement);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function safeJson(value) {
|
||||
return JSON.stringify(value).replace(/</g, '\\u003c');
|
||||
}
|
||||
|
||||
function responseHeaders(contentType) {
|
||||
return {
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Type': contentType,
|
||||
'Cross-Origin-Resource-Policy': 'same-origin',
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'SAMEORIGIN',
|
||||
};
|
||||
}
|
||||
|
||||
function parseReceipt(stdout) {
|
||||
try {
|
||||
return JSON.parse(stdout);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function atomicOutputFailure(result) {
|
||||
const code = result?.reason?.code || 'unclassified';
|
||||
let diagnosticCode = 'output/target-indeterminate';
|
||||
let message;
|
||||
if (code === 'target-hardlinked' || code === 'candidate-hardlinked') {
|
||||
diagnosticCode = 'output/target-hardlinked';
|
||||
message = code === 'candidate-hardlinked'
|
||||
? 'Preview commit candidate has multiple hard-link names and cannot be published safely.'
|
||||
: 'Preview output has multiple hard-link names; atomic publication cannot update every name.';
|
||||
} else if (code === 'target-not-regular-file' || code === 'candidate-not-regular-file') {
|
||||
diagnosticCode = 'output/target-not-regular-file';
|
||||
message = code === 'candidate-not-regular-file'
|
||||
? 'Preview commit candidate is no longer a regular file.'
|
||||
: 'Preview output already exists and is not a regular file.';
|
||||
} else if (result?.status === 'different') {
|
||||
diagnosticCode = 'output/target-changed';
|
||||
message = `Preview output changed while the verified candidate was being prepared (${code}).`;
|
||||
} else {
|
||||
message = `Preview output stability could not be determined safely (${code}).`;
|
||||
}
|
||||
return {
|
||||
code: diagnosticCode,
|
||||
message,
|
||||
evidence: { relation: result?.reason || { code } },
|
||||
};
|
||||
}
|
||||
|
||||
function atomicOutputError(result) {
|
||||
const failure = atomicOutputFailure(result);
|
||||
return Object.assign(new Error(failure.message), { previewFailure: failure });
|
||||
}
|
||||
|
||||
function stagePreviewCommit(commitPath, artifact, mode) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
previewCommitSequence += 1;
|
||||
const candidatePath = path.join(
|
||||
path.dirname(commitPath),
|
||||
`.archify-preview-commit-${process.pid}-${Date.now().toString(36)}-${previewCommitSequence}.tmp`,
|
||||
);
|
||||
let descriptor;
|
||||
let identity;
|
||||
try {
|
||||
const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0);
|
||||
descriptor = fs.openSync(
|
||||
candidatePath,
|
||||
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow,
|
||||
mode ?? 0o666,
|
||||
);
|
||||
let metadata;
|
||||
try {
|
||||
metadata = fs.fstatSync(descriptor, { bigint: true });
|
||||
} catch (error) {
|
||||
try {
|
||||
const retry = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (retry.isFile() && retry.ino !== 0n) {
|
||||
identity = { device: retry.dev, inode: retry.ino };
|
||||
}
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
if (!metadata.isFile() || metadata.ino === 0n) {
|
||||
throw new Error('Preview commit candidate identity could not be verified safely.');
|
||||
}
|
||||
identity = { device: metadata.dev, inode: metadata.ino };
|
||||
fs.writeFileSync(descriptor, artifact);
|
||||
if (mode !== null) fs.fchmodSync(descriptor, mode);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
return { candidatePath, identity };
|
||||
} catch (error) {
|
||||
if (descriptor !== undefined) {
|
||||
try { fs.closeSync(descriptor); } catch {}
|
||||
}
|
||||
if (error.code === 'EEXIST') continue;
|
||||
if (identity) removeOwnedRegularFile(candidatePath, identity);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw Object.assign(new Error('Could not reserve a preview commit candidate beside the output.'), {
|
||||
code: 'EEXIST',
|
||||
errno: -17,
|
||||
syscall: 'open',
|
||||
});
|
||||
}
|
||||
|
||||
function captureOwnedDirectory(directoryPath) {
|
||||
const metadata = fs.lstatSync(directoryPath, { bigint: true });
|
||||
if (!metadata.isDirectory() || metadata.ino === 0n) {
|
||||
throw new Error('Preview staging directory identity could not be verified safely.');
|
||||
}
|
||||
return { device: metadata.dev, inode: metadata.ino };
|
||||
}
|
||||
|
||||
function cleanupOwnedDirectory(directoryPath, identity) {
|
||||
let current;
|
||||
try {
|
||||
current = fs.lstatSync(directoryPath, { bigint: true });
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return;
|
||||
throw error;
|
||||
}
|
||||
// Preserve a replacement rather than recursively deleting a directory that
|
||||
// this preview instance did not create.
|
||||
if (!current.isDirectory()
|
||||
|| current.ino === 0n
|
||||
|| current.dev !== identity.device
|
||||
|| current.ino !== identity.inode) return;
|
||||
try {
|
||||
removeEmptyDirectoryWithRetry(directoryPath);
|
||||
} catch (error) {
|
||||
// An entry whose identity was never bound to this preview may be an
|
||||
// external claimant. Preserve the private directory as recovery material
|
||||
// instead of recursively deleting unknown contents.
|
||||
if (error?.code !== 'ENOTEMPTY' && error?.code !== 'EEXIST') throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function captureOwnedDeliverySidecars(candidatePath, snapshotPath, receipt) {
|
||||
const suffixes = ['.delivery.json', '.delivery-pending.json'];
|
||||
const capturedSidecars = [];
|
||||
for (const suffix of suffixes) {
|
||||
const sidecarPath = candidatePath.replace(/\.html$/iu, suffix);
|
||||
const captured = captureRegularFileBinding(sidecarPath, {
|
||||
subject: 'preview-delivery-sidecar',
|
||||
expectedLinks: 1,
|
||||
includeContent: true,
|
||||
});
|
||||
if (captured.status !== 'captured') continue;
|
||||
try {
|
||||
const sidecar = JSON.parse(captured.content.buffer.toString('utf8'));
|
||||
const commonMatches = sidecar?.schemaVersion === 1
|
||||
&& sidecar.command === 'deliver'
|
||||
&& sameLocation(sidecar.output, candidatePath).status === 'match'
|
||||
&& (!receipt?.receiptId || sidecar.receiptId === receipt.receiptId);
|
||||
const currentMatches = suffix === '.delivery.json'
|
||||
&& commonMatches
|
||||
&& sidecar.status === 'current'
|
||||
&& receipt?.artifact?.sha256
|
||||
&& sidecar.artifact?.sha256 === receipt.artifact.sha256;
|
||||
const pendingMatches = suffix === '.delivery-pending.json'
|
||||
&& commonMatches
|
||||
&& sidecar.status === 'pending'
|
||||
&& sameLocation(sidecar.input, snapshotPath).status === 'match';
|
||||
if (currentMatches || pendingMatches) {
|
||||
capturedSidecars.push({ path: sidecarPath, identity: captured.identity });
|
||||
}
|
||||
} catch {
|
||||
// Preserve malformed or claimant-controlled sidecars for recovery.
|
||||
} finally {
|
||||
releaseRegularFileBinding(captured.binding);
|
||||
}
|
||||
}
|
||||
return capturedSidecars;
|
||||
}
|
||||
|
||||
export async function startPreview(options) {
|
||||
const type = options.type;
|
||||
if (!diagramTypes.has(type)) throw new Error(`Unknown diagram type "${type}".`);
|
||||
if (options.quality && !['standard', 'showcase'].includes(options.quality)) {
|
||||
throw new Error(`Unknown quality profile "${options.quality}".`);
|
||||
}
|
||||
const inputPath = path.resolve(options.input);
|
||||
const outputRequest = {
|
||||
requestedOutput: options.output,
|
||||
authoredOutput: initialAuthoredOutput(inputPath),
|
||||
defaultOutput: `${type}.html`,
|
||||
inputPaths: [inputPath],
|
||||
inputDescription: 'its JSON input',
|
||||
cwd: options.cwd || process.cwd(),
|
||||
};
|
||||
const { outputPath } = resolveOutputPath(outputRequest);
|
||||
const outputDirectory = path.dirname(outputPath);
|
||||
const debounceMs = Number.isFinite(options.debounceMs) ? options.debounceMs : defaultDebounceMs;
|
||||
const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : defaultPollMs;
|
||||
const stopGraceMs = Number.isFinite(options.stopGraceMs) ? Math.max(0, options.stopGraceMs) : defaultStopGraceMs;
|
||||
const stopKillMs = Number.isFinite(options.stopKillMs) ? Math.max(0, options.stopKillMs) : defaultStopKillMs;
|
||||
const shouldOpen = options.open !== false;
|
||||
|
||||
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||
// Keep cleanup bound to the physical directory selected at startup. A
|
||||
// symlink or junction in the requested output parent may be redirected while
|
||||
// preview is running and must not redirect recursive cleanup to a claimant.
|
||||
const physicalOutputDirectory = fs.realpathSync.native(outputDirectory);
|
||||
const stagingDirectory = fs.mkdtempSync(path.toNamespacedPath(
|
||||
path.join(physicalOutputDirectory, '.archify-preview-'),
|
||||
));
|
||||
let stagingIdentity;
|
||||
try {
|
||||
stagingIdentity = captureOwnedDirectory(stagingDirectory);
|
||||
} catch (error) {
|
||||
try { fs.rmdirSync(stagingDirectory); } catch {}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let port = 0;
|
||||
let watcher;
|
||||
let debounceTimer;
|
||||
let pollTimer;
|
||||
let stopGraceTimer;
|
||||
let stopKillTimer;
|
||||
let child;
|
||||
let stopping = false;
|
||||
let forceStopping = false;
|
||||
let stopped = false;
|
||||
let serverClosing = false;
|
||||
let serverClosed = false;
|
||||
let queuedHash = null;
|
||||
let activeHash = null;
|
||||
let lastGoodSourceHash = null;
|
||||
let sourceEpoch = 0;
|
||||
let activeEpoch = 0;
|
||||
let pendingBuild = false;
|
||||
let artifactBuffer = null;
|
||||
const clients = new Set();
|
||||
const sockets = new Set();
|
||||
const state = {
|
||||
schemaVersion: 1,
|
||||
status: 'checking',
|
||||
generation: 0,
|
||||
revision: 0,
|
||||
lastVerified: null,
|
||||
failure: null,
|
||||
};
|
||||
|
||||
let resolveClosed;
|
||||
const closed = new Promise((resolve) => { resolveClosed = resolve; });
|
||||
|
||||
function publicState() {
|
||||
return JSON.parse(JSON.stringify(state));
|
||||
}
|
||||
|
||||
function sendState(res) {
|
||||
res.write(`event: state\ndata: ${safeJson(publicState())}\n\n`);
|
||||
}
|
||||
|
||||
function broadcast() {
|
||||
for (const res of clients) sendState(res);
|
||||
}
|
||||
|
||||
const page = Buffer.from(previewPage());
|
||||
const server = http.createServer((req, res) => {
|
||||
const expectedHost = `${loopbackHost}:${port}`;
|
||||
if (req.headers.host !== expectedHost) {
|
||||
res.writeHead(403, responseHeaders('text/plain; charset=utf-8'));
|
||||
res.end('Forbidden host');
|
||||
return;
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405, { ...responseHeaders('text/plain; charset=utf-8'), Allow: 'GET, HEAD' });
|
||||
res.end('Method not allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(req.url, `http://${expectedHost}`);
|
||||
} catch {
|
||||
res.writeHead(400, responseHeaders('text/plain; charset=utf-8'));
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/') {
|
||||
res.writeHead(200, {
|
||||
...responseHeaders('text/html; charset=utf-8'),
|
||||
'Content-Security-Policy': "default-src 'none'; frame-src 'self'; connect-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'",
|
||||
'Content-Length': page.byteLength,
|
||||
});
|
||||
if (req.method === 'HEAD') res.end();
|
||||
else res.end(page);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/state') {
|
||||
const body = Buffer.from(`${safeJson(publicState())}\n`);
|
||||
res.writeHead(200, { ...responseHeaders('application/json; charset=utf-8'), 'Content-Length': body.byteLength });
|
||||
if (req.method === 'HEAD') res.end();
|
||||
else res.end(body);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/artifact.html') {
|
||||
if (!artifactBuffer) {
|
||||
res.writeHead(404, responseHeaders('text/plain; charset=utf-8'));
|
||||
res.end('No verified artifact yet');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { ...responseHeaders('text/html; charset=utf-8'), 'Content-Length': artifactBuffer.byteLength });
|
||||
if (req.method === 'HEAD') res.end();
|
||||
else res.end(artifactBuffer);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/events' && req.method === 'GET') {
|
||||
res.writeHead(200, {
|
||||
...responseHeaders('text/event-stream; charset=utf-8'),
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
res.write('retry: 1000\n\n');
|
||||
clients.add(res);
|
||||
sendState(res);
|
||||
req.on('close', () => clients.delete(res));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, responseHeaders('text/plain; charset=utf-8'));
|
||||
res.end('Not found');
|
||||
});
|
||||
|
||||
server.on('connection', (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
// A connection event already queued when force-stop begins must not keep
|
||||
// server.close() waiting after the current sockets have been destroyed.
|
||||
if (forceStopping) socket.destroy();
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, loopbackHost, () => {
|
||||
server.off('error', reject);
|
||||
port = server.address().port;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
try { server.close(); } catch {}
|
||||
cleanupOwnedDirectory(stagingDirectory, stagingIdentity);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const url = `http://${loopbackHost}:${port}/`;
|
||||
|
||||
function finishStop() {
|
||||
if (stopped || child || !serverClosed) return;
|
||||
stopped = true;
|
||||
clearTimeout(debounceTimer);
|
||||
clearInterval(pollTimer);
|
||||
clearTimeout(stopGraceTimer);
|
||||
clearTimeout(stopKillTimer);
|
||||
try {
|
||||
cleanupOwnedDirectory(stagingDirectory, stagingIdentity);
|
||||
} finally {
|
||||
resolveClosed();
|
||||
}
|
||||
}
|
||||
|
||||
function signalActiveChild(signal) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
||||
try {
|
||||
if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal);
|
||||
else child.kill(signal);
|
||||
} catch (error) {
|
||||
if (error.code === 'ESRCH') return;
|
||||
try { child.kill(signal); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function closeServer() {
|
||||
if (serverClosing) return;
|
||||
serverClosing = true;
|
||||
for (const res of clients) res.end();
|
||||
clients.clear();
|
||||
server.close(() => {
|
||||
serverClosed = true;
|
||||
finishStop();
|
||||
});
|
||||
server.closeIdleConnections?.();
|
||||
}
|
||||
|
||||
function startBoundedChildDrain() {
|
||||
if (!child || stopGraceTimer || stopKillTimer) return;
|
||||
stopGraceTimer = setTimeout(() => {
|
||||
stopGraceTimer = undefined;
|
||||
if (!child) return finishStop();
|
||||
signalActiveChild('SIGTERM');
|
||||
stopKillTimer = setTimeout(() => {
|
||||
stopKillTimer = undefined;
|
||||
signalActiveChild('SIGKILL');
|
||||
}, stopKillMs);
|
||||
}, stopGraceMs);
|
||||
}
|
||||
|
||||
async function stop({ force = false } = {}) {
|
||||
if (force) forceStopping = true;
|
||||
if (!stopping) {
|
||||
stopping = true;
|
||||
clearTimeout(debounceTimer);
|
||||
clearInterval(pollTimer);
|
||||
watcher?.close();
|
||||
closeServer();
|
||||
}
|
||||
if (forceStopping) {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
}
|
||||
if (child && force) {
|
||||
clearTimeout(stopGraceTimer);
|
||||
clearTimeout(stopKillTimer);
|
||||
stopGraceTimer = undefined;
|
||||
stopKillTimer = undefined;
|
||||
signalActiveChild('SIGKILL');
|
||||
} else if (child) {
|
||||
startBoundedChildDrain();
|
||||
} else {
|
||||
finishStop();
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
function publishFailure(receipt, stdout, stderr, candidatePath, snapshotPath) {
|
||||
const repairDetails = receipt?.diagnostics
|
||||
?.slice(0, 12)
|
||||
.map((entry) => {
|
||||
const fix = entry.supportedFixes?.length ? `\nFix: ${entry.supportedFixes.join('; ')}` : '';
|
||||
return `[${entry.code}] ${entry.message}${fix}`;
|
||||
}) || [];
|
||||
const checkerDetails = receipt?.checker?.checks
|
||||
?.filter((check) => !check.ok)
|
||||
.flatMap((check) => check.details || [])
|
||||
.filter(Boolean)
|
||||
.slice(0, 12) || [];
|
||||
const diagnostic = [
|
||||
receipt?.error,
|
||||
...(repairDetails.length ? repairDetails : checkerDetails),
|
||||
].filter(Boolean).join('\n') || stderr || stdout;
|
||||
state.status = 'needs-fix';
|
||||
state.failure = {
|
||||
stage: receipt?.stage || 'render',
|
||||
...(receipt?.code ? { code: receipt.code } : {}),
|
||||
...(receipt?.evidence ? { evidence: receipt.evidence } : {}),
|
||||
message: redactDiagnostic(
|
||||
diagnostic,
|
||||
[
|
||||
[inputPath, '<input.json>'],
|
||||
[outputPath, '<output.html>'],
|
||||
[snapshotPath, '<input.json>'],
|
||||
[candidatePath, '<candidate.html>'],
|
||||
[stagingDirectory, '<preview-staging>'],
|
||||
[path.resolve(here, '..'), '<archify-skill>'],
|
||||
[path.resolve(options.cwd || process.cwd()), '<working-directory>'],
|
||||
...(options.repoRoot ? [[path.resolve(options.repoRoot), '<repo-root>']] : []),
|
||||
],
|
||||
),
|
||||
};
|
||||
broadcast();
|
||||
}
|
||||
|
||||
function commitCandidate(candidatePath, receipt, generationHash, outputCapture) {
|
||||
let candidate;
|
||||
let sourceCandidateBinding;
|
||||
let sourceCandidateIdentity;
|
||||
let commitCandidatePath;
|
||||
let commitCandidateIdentity;
|
||||
let commitCandidateBinding;
|
||||
try {
|
||||
const sourceCapture = captureRegularFileBinding(candidatePath, {
|
||||
subject: 'candidate',
|
||||
expectedSha256: receipt?.artifact?.sha256,
|
||||
expectedBytes: receipt?.artifact?.bytes,
|
||||
includeContent: true,
|
||||
});
|
||||
if (sourceCapture.status !== 'captured') throw atomicOutputError(sourceCapture);
|
||||
sourceCandidateBinding = sourceCapture.binding;
|
||||
sourceCandidateIdentity = sourceCapture.identity;
|
||||
candidate = sourceCapture.content.buffer;
|
||||
const digest = sourceCapture.content.sha256;
|
||||
const releasedSource = releaseRegularFileBinding(sourceCandidateBinding);
|
||||
sourceCandidateBinding = undefined;
|
||||
if (releasedSource.status !== 'released') throw atomicOutputError(releasedSource);
|
||||
resolveOutputPath(outputRequest);
|
||||
const sameArtifact = state.lastVerified?.sha256 === digest;
|
||||
const currentSource = sourceDigest(inputPath);
|
||||
if (currentSource.hash !== generationHash) {
|
||||
return {
|
||||
committed: false,
|
||||
supersededBy: currentSource,
|
||||
candidateIdentity: sourceCandidateIdentity,
|
||||
};
|
||||
}
|
||||
const beforeStage = verifyAtomicOutput(outputCapture.snapshot);
|
||||
if (beforeStage.status !== 'match') throw atomicOutputError(beforeStage);
|
||||
({
|
||||
candidatePath: commitCandidatePath,
|
||||
identity: commitCandidateIdentity,
|
||||
} = stagePreviewCommit(outputCapture.commitPath, candidate, outputCapture.mode));
|
||||
const candidateCapture = captureRegularFileBinding(commitCandidatePath, {
|
||||
subject: 'candidate',
|
||||
expectedSha256: digest,
|
||||
expectedBytes: candidate.byteLength,
|
||||
expectedIdentity: commitCandidateIdentity,
|
||||
...(outputCapture.mode === null ? {} : { expectedMode: outputCapture.mode }),
|
||||
});
|
||||
if (candidateCapture.status !== 'captured') throw atomicOutputError(candidateCapture);
|
||||
commitCandidateBinding = candidateCapture.binding;
|
||||
const beforeCommit = verifyAtomicOutput(outputCapture.snapshot);
|
||||
if (beforeCommit.status !== 'match') throw atomicOutputError(beforeCommit);
|
||||
const publication = publishRegularFileBinding(
|
||||
commitCandidateBinding,
|
||||
commitCandidatePath,
|
||||
outputCapture.snapshot,
|
||||
{ subject: 'candidate' },
|
||||
);
|
||||
if (!['committed', 'committed-with-warning'].includes(publication.status)) {
|
||||
throw atomicOutputError(publication);
|
||||
}
|
||||
const releasedCandidate = releaseRegularFileBinding(commitCandidateBinding);
|
||||
commitCandidateBinding = undefined;
|
||||
if (releasedCandidate.status !== 'released') throw atomicOutputError(releasedCandidate);
|
||||
commitCandidatePath = undefined;
|
||||
commitCandidateIdentity = undefined;
|
||||
artifactBuffer = candidate;
|
||||
lastGoodSourceHash = generationHash;
|
||||
state.status = 'verified';
|
||||
if (!sameArtifact) {
|
||||
state.revision += 1;
|
||||
state.lastVerified = {
|
||||
sha256: digest,
|
||||
bytes: candidate.byteLength,
|
||||
checksPassed: receipt.validation.checksPassed,
|
||||
checkCount: receipt.validation.checkCount,
|
||||
compositionProfile: receipt.validation.compositionProfile,
|
||||
compositionStatus: receipt.validation.compositionStatus,
|
||||
};
|
||||
}
|
||||
state.failure = null;
|
||||
broadcast();
|
||||
return { committed: true, supersededBy: null, candidateIdentity: sourceCandidateIdentity };
|
||||
} catch (error) {
|
||||
publishFailure({
|
||||
stage: 'commit',
|
||||
error: `Could not publish the verified preview: ${error.message}`,
|
||||
...(error.previewFailure || {}),
|
||||
}, '', '', candidatePath);
|
||||
return { committed: false, supersededBy: null, candidateIdentity: sourceCandidateIdentity };
|
||||
} finally {
|
||||
if (sourceCandidateBinding) releaseRegularFileBinding(sourceCandidateBinding);
|
||||
if (commitCandidateBinding) releaseRegularFileBinding(commitCandidateBinding);
|
||||
if (commitCandidatePath && commitCandidateIdentity) {
|
||||
removeOwnedRegularFile(commitCandidatePath, commitCandidateIdentity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function beginBuild(digest, epoch) {
|
||||
if (stopping || child) return;
|
||||
activeHash = digest.hash;
|
||||
activeEpoch = epoch;
|
||||
state.generation += 1;
|
||||
state.status = 'checking';
|
||||
state.failure = null;
|
||||
broadcast();
|
||||
|
||||
const candidatePath = path.join(stagingDirectory, `generation-${state.generation}.html`);
|
||||
const snapshotPath = path.join(stagingDirectory, `generation-${state.generation}.json`);
|
||||
let snapshotIdentity;
|
||||
const outputCapture = captureAtomicOutput(outputPath);
|
||||
if (outputCapture.status !== 'captured') {
|
||||
const failure = atomicOutputFailure(outputCapture);
|
||||
publishFailure(
|
||||
{ stage: 'prepare', error: failure.message, ...failure },
|
||||
'',
|
||||
'',
|
||||
candidatePath,
|
||||
snapshotPath,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const beforeBuild = verifyAtomicOutput(outputCapture.snapshot);
|
||||
if (beforeBuild.status !== 'match') {
|
||||
const failure = atomicOutputFailure(beforeBuild);
|
||||
publishFailure(
|
||||
{ stage: 'prepare', error: failure.message, ...failure },
|
||||
'',
|
||||
'',
|
||||
candidatePath,
|
||||
snapshotPath,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (digest.bytes !== null) {
|
||||
try {
|
||||
fs.writeFileSync(snapshotPath, digest.bytes, { flag: 'wx', mode: 0o600 });
|
||||
const metadata = fs.lstatSync(snapshotPath, { bigint: true });
|
||||
if (metadata.isFile() && metadata.ino !== 0n) {
|
||||
snapshotIdentity = { device: metadata.dev, inode: metadata.ino };
|
||||
}
|
||||
} catch (error) {
|
||||
publishFailure(
|
||||
{ stage: 'prepare', error: `Could not snapshot the observed input: ${error.message}` },
|
||||
'',
|
||||
'',
|
||||
candidatePath,
|
||||
snapshotPath,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const args = [options.deliveryCli || cliPath, 'deliver', type, snapshotPath, candidatePath, '--json'];
|
||||
if (options.quality) args.push('--quality', options.quality);
|
||||
if (options.repoRoot) args.push('--repo-root', path.resolve(options.repoRoot));
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child = spawn(process.execPath, args, {
|
||||
cwd: options.cwd || process.cwd(),
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: process.platform !== 'win32',
|
||||
});
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.on('error', (error) => { stderr += error.message; });
|
||||
child.on('close', (code) => {
|
||||
const receipt = parseReceipt(stdout);
|
||||
const generationEpoch = activeEpoch;
|
||||
const generationHash = activeHash;
|
||||
const stale = generationEpoch !== sourceEpoch;
|
||||
let supersededBy = null;
|
||||
let candidateIdentity;
|
||||
const deliverySidecars = captureOwnedDeliverySidecars(
|
||||
candidatePath,
|
||||
snapshotPath,
|
||||
receipt,
|
||||
);
|
||||
child = null;
|
||||
clearTimeout(stopGraceTimer);
|
||||
clearTimeout(stopKillTimer);
|
||||
stopGraceTimer = undefined;
|
||||
stopKillTimer = undefined;
|
||||
if (!stopping && !stale && code === 0 && receipt?.ok) {
|
||||
({ supersededBy, candidateIdentity } = commitCandidate(
|
||||
candidatePath,
|
||||
receipt,
|
||||
generationHash,
|
||||
outputCapture,
|
||||
));
|
||||
} else if (code === 0 && receipt?.ok) {
|
||||
const abandonedCandidate = captureRegularFileBinding(candidatePath, {
|
||||
subject: 'abandoned-preview-candidate',
|
||||
expectedSha256: receipt.artifact?.sha256,
|
||||
expectedBytes: receipt.artifact?.bytes,
|
||||
});
|
||||
if (abandonedCandidate.status === 'captured') {
|
||||
candidateIdentity = abandonedCandidate.identity;
|
||||
releaseRegularFileBinding(abandonedCandidate.binding);
|
||||
}
|
||||
} else if (!stopping && !stale) {
|
||||
publishFailure(receipt, stdout, stderr, candidatePath, snapshotPath);
|
||||
}
|
||||
if (candidateIdentity) removeOwnedRegularFile(candidatePath, candidateIdentity);
|
||||
if (snapshotIdentity) removeOwnedRegularFile(snapshotPath, snapshotIdentity, { subject: 'snapshot' });
|
||||
for (const deliverySidecar of deliverySidecars) {
|
||||
removeOwnedRegularFile(deliverySidecar.path, deliverySidecar.identity, {
|
||||
subject: 'preview-delivery-sidecar',
|
||||
});
|
||||
}
|
||||
|
||||
if (stopping) {
|
||||
finishStop();
|
||||
} else if (pendingBuild || stale || supersededBy) {
|
||||
pendingBuild = false;
|
||||
if (supersededBy && sourceEpoch === generationEpoch) sourceEpoch += 1;
|
||||
const digest = supersededBy || sourceDigest(inputPath);
|
||||
queueStableBuild(digest.hash, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function queueStableBuild(hash, immediate = false) {
|
||||
queuedHash = hash;
|
||||
clearTimeout(debounceTimer);
|
||||
const launch = () => {
|
||||
if (stopping) return;
|
||||
const digest = sourceDigest(inputPath);
|
||||
if (digest.hash !== queuedHash) {
|
||||
queueStableBuild(digest.hash);
|
||||
return;
|
||||
}
|
||||
if (digest.hash === lastGoodSourceHash) {
|
||||
if (state.status !== 'verified' && state.lastVerified) {
|
||||
state.status = 'verified';
|
||||
state.failure = null;
|
||||
broadcast();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (child) {
|
||||
pendingBuild = true;
|
||||
return;
|
||||
}
|
||||
beginBuild(digest, sourceEpoch);
|
||||
};
|
||||
debounceTimer = setTimeout(launch, immediate ? 0 : debounceMs);
|
||||
}
|
||||
|
||||
function observeSource({ immediate = false } = {}) {
|
||||
const digest = sourceDigest(inputPath);
|
||||
if (!immediate && digest.hash === queuedHash) return;
|
||||
sourceEpoch += 1;
|
||||
queueStableBuild(digest.hash, immediate);
|
||||
}
|
||||
|
||||
if (options.watch !== false) {
|
||||
try {
|
||||
// `path.resolve` keeps Windows 8.3 names intact. Canonicalize short names
|
||||
// and junctions before libuv opens the directory so its callback path has
|
||||
// the same prefix as the watched path.
|
||||
const watchedDirectory = fs.realpathSync.native(path.dirname(inputPath));
|
||||
watcher = fs.watch(watchedDirectory, (event, filename) => {
|
||||
// On Windows the watcher hands us just the basename; on POSIX it can be
|
||||
// null. Resolve named events against the directory libuv actually opened
|
||||
// so file-level case, 8.3, and hard-link aliases still identify the input.
|
||||
if (
|
||||
!filename
|
||||
|| sameLocation(path.join(watchedDirectory, filename.toString()), inputPath).status === 'match'
|
||||
) {
|
||||
observeSource();
|
||||
}
|
||||
});
|
||||
watcher.on('error', () => {
|
||||
const failedWatcher = watcher;
|
||||
watcher = undefined;
|
||||
failedWatcher?.close();
|
||||
});
|
||||
} catch (error) {
|
||||
await stop();
|
||||
throw new Error(`Could not watch the input directory: ${error.message}`);
|
||||
}
|
||||
}
|
||||
pollTimer = setInterval(() => observeSource(), pollMs);
|
||||
|
||||
let opener = null;
|
||||
if (shouldOpen) {
|
||||
try {
|
||||
opener = openLoopbackUrl(url);
|
||||
} catch {
|
||||
opener = { requested: true, status: 'unsupported', target: url, method: null };
|
||||
}
|
||||
}
|
||||
|
||||
observeSource({ immediate: true });
|
||||
|
||||
return {
|
||||
url,
|
||||
input: inputPath,
|
||||
output: outputPath,
|
||||
opener,
|
||||
state: publicState,
|
||||
stop,
|
||||
closed,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runPreview(options) {
|
||||
const preview = await startPreview(options);
|
||||
console.log(`preview ${preview.url}`);
|
||||
console.log(`watching ${preview.input}`);
|
||||
console.log(`output ${preview.output}`);
|
||||
if (preview.opener && preview.opener.status !== 'opened') {
|
||||
console.error(`Could not open the preview (${preview.opener.status}). ${preview.opener.failure?.reason || 'Open it manually.'} Target: ${preview.url}`);
|
||||
}
|
||||
|
||||
let signalCount = 0;
|
||||
const stop = () => {
|
||||
signalCount += 1;
|
||||
if (signalCount === 1) {
|
||||
console.log('\nstopping preview…');
|
||||
preview.stop();
|
||||
} else {
|
||||
console.log('\nforcing preview shutdown…');
|
||||
preview.stop({ force: true });
|
||||
}
|
||||
};
|
||||
process.on('SIGINT', stop);
|
||||
process.on('SIGTERM', stop);
|
||||
await preview.closed;
|
||||
process.off('SIGINT', stop);
|
||||
process.off('SIGTERM', stop);
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { recoverRetiredPublication } from '../renderers/shared/atomic-output.mjs';
|
||||
|
||||
function usage(stream = process.stderr) {
|
||||
stream.write('Usage: node bin/recover-output.mjs <private-recovery-directory> [--json]\n');
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
|
||||
usage(process.stdout);
|
||||
} else {
|
||||
let json = false;
|
||||
let recoveryDirectory;
|
||||
let invalid = false;
|
||||
for (const argument of args) {
|
||||
if (argument === '--json' && !json) {
|
||||
json = true;
|
||||
} else if (argument.startsWith('-') || recoveryDirectory !== undefined) {
|
||||
invalid = true;
|
||||
} else {
|
||||
recoveryDirectory = argument;
|
||||
}
|
||||
}
|
||||
|
||||
if (invalid || recoveryDirectory === undefined) {
|
||||
usage();
|
||||
process.exitCode = 64;
|
||||
} else {
|
||||
const result = recoverRetiredPublication(recoveryDirectory);
|
||||
if (json) {
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} else {
|
||||
const reason = result.reason?.code || 'publication-recovery-unknown';
|
||||
process.stdout.write(`${result.status}: ${reason}\n`);
|
||||
}
|
||||
// A complete prior recovery is idempotent. A preserved target is a safe,
|
||||
// deliberate non-action that needs the operator to inspect the claimant.
|
||||
process.exitCode = ['recovered', 'absent'].includes(result.status)
|
||||
? 0
|
||||
: result.status === 'preserved' ? 2 : 1;
|
||||
}
|
||||
}
|
||||
Vendored
+2667
File diff suppressed because it is too large
Load Diff
Vendored
+31
@@ -0,0 +1,31 @@
|
||||
# Built-in brand marks
|
||||
|
||||
Archify ships a bounded catalogue of 107 commonly used brands for architecture,
|
||||
workflow, sequence, data-flow, and lifecycle nodes. The mark is optional authored
|
||||
identity: it never replaces the node's semantic `type`, color, label, or
|
||||
relationships.
|
||||
|
||||
Unknown sites are handled by an explicit two-stage workflow. Run
|
||||
`node bin/archify.mjs brands capture <url> --json`, then author the returned
|
||||
digest-pinned `brand` value. Normal render and validate commands do not perform
|
||||
an unpinned capture, and changed or unavailable content fails closed.
|
||||
|
||||
Most vector paths and brand metadata are generated from Simple Icons 16.28.0.
|
||||
The OpenAI mark is traced to OpenAI's official brand guidelines. Every generated
|
||||
entry records its source and, when available upstream, its guidelines and license
|
||||
metadata in `renderers/shared/generated-brand-marks.mjs`.
|
||||
|
||||
Brand names and logos may be trademarks of their respective owners. Simple
|
||||
Icons' CC0 license covers its collection work, not every underlying trademark or
|
||||
artwork. Contributors must review the recorded source, current brand guidelines,
|
||||
and intended referential use before adding or updating a mark. Archify does not
|
||||
imply sponsorship, endorsement, or partnership.
|
||||
|
||||
Edit `catalog.json`, then regenerate the committed zero-runtime-dependency bundle:
|
||||
|
||||
```bash
|
||||
npm run generate:brand-marks
|
||||
npm run check:brand-marks
|
||||
```
|
||||
|
||||
Do not hand-edit `renderers/shared/generated-brand-marks.mjs`.
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"marks": [
|
||||
{
|
||||
"id": "openai",
|
||||
"title": "OpenAI",
|
||||
"category": "ai",
|
||||
"aliases": ["chatgpt", "gpt", "codex"],
|
||||
"domains": ["openai.com", "chatgpt.com"],
|
||||
"custom": {
|
||||
"viewBox": 20,
|
||||
"hex": "000000",
|
||||
"path": "M11.248 18.25q-.825 0-1.568-.314a4.3 4.3 0 0 1-1.32-.874 4 4 0 0 1-1.304.214 4 4 0 0 1-2.046-.544 4.27 4.27 0 0 1-1.518-1.485 4 4 0 0 1-.56-2.095q0-.48.131-1.04A4.4 4.4 0 0 1 2.04 10.71a4.07 4.07 0 0 1 .017-3.4 4.2 4.2 0 0 1 1.056-1.418 3.8 3.8 0 0 1 1.6-.842 3.9 3.9 0 0 1 .76-1.683q.593-.759 1.451-1.188a4.04 4.04 0 0 1 1.832-.429q.825 0 1.567.313.742.314 1.32.875a4 4 0 0 1 1.304-.215q1.106 0 2.046.545a4.14 4.14 0 0 1 1.501 1.485q.578.941.578 2.095 0 .48-.132 1.04.66.61 1.023 1.419.363.792.363 1.666 0 .892-.38 1.717a4.3 4.3 0 0 1-1.072 1.435 3.8 3.8 0 0 1-1.584.825 3.8 3.8 0 0 1-.775 1.683 4.06 4.06 0 0 1-1.436 1.188 4.04 4.04 0 0 1-1.832.429m-4.076-2.062q.825 0 1.435-.347l3.103-1.782a.36.36 0 0 0 .164-.313v-1.42L7.881 14.62a.67.67 0 0 1-.726 0l-3.118-1.798a.5.5 0 0 1-.017.115v.198q0 .841.396 1.551.413.693 1.139 1.089a3.2 3.2 0 0 0 1.617.412m.165-2.69a.4.4 0 0 0 .181.05q.083 0 .165-.05l1.238-.71-3.977-2.31a.7.7 0 0 1-.363-.643v-3.58q-.825.362-1.32 1.122a2.9 2.9 0 0 0-.495 1.65q0 .809.413 1.55.412.743 1.072 1.123zm3.91 3.663q.875 0 1.585-.396a2.96 2.96 0 0 0 1.534-2.64v-3.564a.32.32 0 0 0-.165-.297l-1.254-.726v4.604a.7.7 0 0 1-.363.643l-3.119 1.799a3 3 0 0 0 1.783.577m.627-6.039V8.878L10.01 7.822 8.129 8.878v2.244l1.881 1.056zM7.057 5.859a.7.7 0 0 1 .363-.644l3.119-1.798a3 3 0 0 0-1.782-.578q-.874 0-1.584.396A2.96 2.96 0 0 0 6.05 4.324a3.07 3.07 0 0 0-.396 1.551v3.547q0 .199.165.314l1.237.726zm8.383 7.887q.825-.364 1.303-1.123.495-.758.495-1.65a3.15 3.15 0 0 0-.412-1.55q-.413-.743-1.073-1.123l-3.086-1.782q-.099-.065-.181-.049a.3.3 0 0 0-.165.05l-1.238.692 3.993 2.327a.6.6 0 0 1 .264.264.64.64 0 0 1 .1.363zm-3.317-8.382a.63.63 0 0 1 .726 0l3.135 1.831v-.297q0-.792-.396-1.501a2.86 2.86 0 0 0-1.105-1.155q-.71-.43-1.65-.43-.825 0-1.436.347L8.294 5.941a.36.36 0 0 0-.165.314v1.418z",
|
||||
"source": "https://openai.com/brand/",
|
||||
"guidelines": "https://openai.com/brand/"
|
||||
}
|
||||
},
|
||||
{ "id": "claude", "category": "ai", "simpleIcon": "claude", "aliases": ["claude-ai"], "domains": ["claude.ai"] },
|
||||
{ "id": "anthropic", "category": "ai", "simpleIcon": "anthropic", "domains": ["anthropic.com"] },
|
||||
{ "id": "google-gemini", "category": "ai", "simpleIcon": "googlegemini", "aliases": ["gemini"], "domains": ["gemini.google.com"] },
|
||||
{ "id": "deepseek", "category": "ai", "simpleIcon": "deepseek", "domains": ["deepseek.com"] },
|
||||
{ "id": "qwen", "category": "ai", "simpleIcon": "qwen", "domains": ["qwen.ai"] },
|
||||
{ "id": "meta", "category": "ai", "simpleIcon": "meta", "aliases": ["llama"], "domains": ["meta.com"] },
|
||||
{ "id": "mistral-ai", "category": "ai", "simpleIcon": "mistralai", "aliases": ["mistral"], "domains": ["mistral.ai"] },
|
||||
{ "id": "hugging-face", "category": "ai", "simpleIcon": "huggingface", "aliases": ["huggingface"], "domains": ["huggingface.co"] },
|
||||
{ "id": "ollama", "category": "ai", "simpleIcon": "ollama", "domains": ["ollama.com"] },
|
||||
{ "id": "openrouter", "category": "ai", "simpleIcon": "openrouter", "aliases": ["open-router"], "domains": ["openrouter.ai"] },
|
||||
{ "id": "perplexity", "category": "ai", "simpleIcon": "perplexity", "domains": ["perplexity.ai"] },
|
||||
{ "id": "replicate", "category": "ai", "simpleIcon": "replicate", "domains": ["replicate.com"] },
|
||||
|
||||
{ "id": "google-cloud", "category": "cloud", "simpleIcon": "googlecloud", "aliases": ["gcp", "googlecloud"], "domains": ["cloud.google.com"] },
|
||||
{ "id": "cloudflare", "category": "cloud", "simpleIcon": "cloudflare", "domains": ["cloudflare.com"] },
|
||||
{ "id": "vercel", "category": "cloud", "simpleIcon": "vercel", "domains": ["vercel.com"] },
|
||||
{ "id": "netlify", "category": "cloud", "simpleIcon": "netlify", "domains": ["netlify.com"] },
|
||||
{ "id": "digitalocean", "category": "cloud", "simpleIcon": "digitalocean", "aliases": ["digital-ocean"], "domains": ["digitalocean.com"] },
|
||||
{ "id": "render", "category": "cloud", "simpleIcon": "render", "domains": ["render.com"] },
|
||||
{ "id": "railway", "category": "cloud", "simpleIcon": "railway", "domains": ["railway.com", "railway.app"] },
|
||||
{ "id": "fly-io", "category": "cloud", "simpleIcon": "flydotio", "aliases": ["fly.io"], "domains": ["fly.io"] },
|
||||
{ "id": "cloudinary", "category": "cloud", "simpleIcon": "cloudinary", "domains": ["cloudinary.com"] },
|
||||
{ "id": "alibaba-cloud", "category": "cloud", "simpleIcon": "alibabacloud", "aliases": ["aliyun"], "domains": ["alibabacloud.com", "aliyun.com"] },
|
||||
{ "id": "firebase", "category": "cloud", "simpleIcon": "firebase", "domains": ["firebase.google.com"] },
|
||||
{ "id": "supabase", "category": "cloud", "simpleIcon": "supabase", "domains": ["supabase.com"] },
|
||||
{ "id": "neon", "category": "cloud", "simpleIcon": "neon", "domains": ["neon.tech"] },
|
||||
|
||||
{ "id": "github", "category": "engineering", "simpleIcon": "github", "domains": ["github.com"] },
|
||||
{ "id": "gitlab", "category": "engineering", "simpleIcon": "gitlab", "domains": ["gitlab.com"] },
|
||||
{ "id": "bitbucket", "category": "engineering", "simpleIcon": "bitbucket", "domains": ["bitbucket.org"] },
|
||||
{ "id": "docker", "category": "engineering", "simpleIcon": "docker", "domains": ["docker.com"] },
|
||||
{ "id": "kubernetes", "category": "engineering", "simpleIcon": "kubernetes", "aliases": ["k8s"], "domains": ["kubernetes.io"] },
|
||||
{ "id": "terraform", "category": "engineering", "simpleIcon": "terraform", "domains": ["terraform.io"] },
|
||||
{ "id": "pulumi", "category": "engineering", "simpleIcon": "pulumi", "domains": ["pulumi.com"] },
|
||||
{ "id": "ansible", "category": "engineering", "simpleIcon": "ansible", "domains": ["ansible.com"] },
|
||||
{ "id": "jenkins", "category": "engineering", "simpleIcon": "jenkins", "domains": ["jenkins.io"] },
|
||||
{ "id": "circleci", "category": "engineering", "simpleIcon": "circleci", "aliases": ["circle-ci"], "domains": ["circleci.com"] },
|
||||
{ "id": "github-actions", "category": "engineering", "simpleIcon": "githubactions" },
|
||||
{ "id": "argo", "category": "engineering", "simpleIcon": "argo", "aliases": ["argocd", "argo-cd"], "domains": ["argoproj.github.io"] },
|
||||
{ "id": "helm", "category": "engineering", "simpleIcon": "helm", "domains": ["helm.sh"] },
|
||||
{ "id": "grafana", "category": "engineering", "simpleIcon": "grafana", "domains": ["grafana.com"] },
|
||||
{ "id": "prometheus", "category": "engineering", "simpleIcon": "prometheus", "domains": ["prometheus.io"] },
|
||||
{ "id": "sentry", "category": "engineering", "simpleIcon": "sentry", "domains": ["sentry.io"] },
|
||||
{ "id": "datadog", "category": "engineering", "simpleIcon": "datadog", "domains": ["datadoghq.com"] },
|
||||
{ "id": "pagerduty", "category": "engineering", "simpleIcon": "pagerduty", "aliases": ["pager-duty"], "domains": ["pagerduty.com"] },
|
||||
|
||||
{ "id": "postgresql", "category": "data", "simpleIcon": "postgresql", "aliases": ["postgres"], "domains": ["postgresql.org"] },
|
||||
{ "id": "mysql", "category": "data", "simpleIcon": "mysql", "domains": ["mysql.com"] },
|
||||
{ "id": "mongodb", "category": "data", "simpleIcon": "mongodb", "aliases": ["mongo"], "domains": ["mongodb.com"] },
|
||||
{ "id": "redis", "category": "data", "simpleIcon": "redis", "domains": ["redis.io"] },
|
||||
{ "id": "apache-kafka", "category": "data", "simpleIcon": "apachekafka", "aliases": ["kafka"], "domains": ["kafka.apache.org"] },
|
||||
{ "id": "rabbitmq", "category": "data", "simpleIcon": "rabbitmq", "aliases": ["rabbit-mq"], "domains": ["rabbitmq.com"] },
|
||||
{ "id": "clickhouse", "category": "data", "simpleIcon": "clickhouse", "domains": ["clickhouse.com"] },
|
||||
{ "id": "elasticsearch", "category": "data", "simpleIcon": "elasticsearch", "aliases": ["elastic"], "domains": ["elastic.co"] },
|
||||
{ "id": "opensearch", "category": "data", "simpleIcon": "opensearch", "aliases": ["open-search"], "domains": ["opensearch.org"] },
|
||||
{ "id": "snowflake", "category": "data", "simpleIcon": "snowflake", "domains": ["snowflake.com"] },
|
||||
{ "id": "databricks", "category": "data", "simpleIcon": "databricks", "domains": ["databricks.com"] },
|
||||
{ "id": "planetscale", "category": "data", "simpleIcon": "planetscale", "aliases": ["planet-scale"], "domains": ["planetscale.com"] },
|
||||
{ "id": "prisma", "category": "data", "simpleIcon": "prisma", "domains": ["prisma.io"] },
|
||||
{ "id": "sqlite", "category": "data", "simpleIcon": "sqlite", "domains": ["sqlite.org"] },
|
||||
{ "id": "mariadb", "category": "data", "simpleIcon": "mariadb", "aliases": ["maria-db"], "domains": ["mariadb.org"] },
|
||||
{ "id": "influxdb", "category": "data", "simpleIcon": "influxdb", "aliases": ["influx-db"], "domains": ["influxdata.com"] },
|
||||
{ "id": "apache-airflow", "category": "data", "simpleIcon": "apacheairflow", "aliases": ["airflow"], "domains": ["airflow.apache.org"] },
|
||||
|
||||
{ "id": "notion", "category": "collaboration", "simpleIcon": "notion", "domains": ["notion.so"] },
|
||||
{ "id": "figma", "category": "collaboration", "simpleIcon": "figma", "domains": ["figma.com"] },
|
||||
{ "id": "jira", "category": "collaboration", "simpleIcon": "jira", "domains": ["atlassian.com"] },
|
||||
{ "id": "linear", "category": "collaboration", "simpleIcon": "linear", "domains": ["linear.app"] },
|
||||
{ "id": "discord", "category": "collaboration", "simpleIcon": "discord", "domains": ["discord.com"] },
|
||||
{ "id": "zoom", "category": "collaboration", "simpleIcon": "zoom", "domains": ["zoom.us"] },
|
||||
{ "id": "trello", "category": "collaboration", "simpleIcon": "trello", "domains": ["trello.com"] },
|
||||
{ "id": "asana", "category": "collaboration", "simpleIcon": "asana", "domains": ["asana.com"] },
|
||||
{ "id": "airtable", "category": "collaboration", "simpleIcon": "airtable", "domains": ["airtable.com"] },
|
||||
{ "id": "miro", "category": "collaboration", "simpleIcon": "miro", "domains": ["miro.com"] },
|
||||
{ "id": "stripe", "category": "business", "simpleIcon": "stripe", "domains": ["stripe.com"] },
|
||||
{ "id": "shopify", "category": "business", "simpleIcon": "shopify", "domains": ["shopify.com"] },
|
||||
{ "id": "hubspot", "category": "business", "simpleIcon": "hubspot", "domains": ["hubspot.com"] },
|
||||
{ "id": "paypal", "category": "business", "simpleIcon": "paypal", "domains": ["paypal.com"] },
|
||||
{ "id": "intercom", "category": "business", "simpleIcon": "intercom", "domains": ["intercom.com"] },
|
||||
{ "id": "zendesk", "category": "business", "simpleIcon": "zendesk", "domains": ["zendesk.com"] },
|
||||
{ "id": "wordpress", "category": "business", "simpleIcon": "wordpress", "domains": ["wordpress.org", "wordpress.com"] },
|
||||
{ "id": "woocommerce", "category": "business", "simpleIcon": "woocommerce", "aliases": ["woo-commerce"], "domains": ["woocommerce.com"] },
|
||||
|
||||
{ "id": "wechat", "category": "channel", "simpleIcon": "wechat", "aliases": ["weixin", "微信"], "domains": ["weixin.qq.com"] },
|
||||
{ "id": "youtube", "category": "channel", "simpleIcon": "youtube", "domains": ["youtube.com", "youtu.be"] },
|
||||
{ "id": "tiktok", "category": "channel", "simpleIcon": "tiktok", "aliases": ["douyin", "抖音"], "domains": ["tiktok.com", "douyin.com"] },
|
||||
{ "id": "x", "category": "channel", "simpleIcon": "x", "aliases": ["twitter"], "domains": ["x.com", "twitter.com"] },
|
||||
{ "id": "instagram", "category": "channel", "simpleIcon": "instagram", "domains": ["instagram.com"] },
|
||||
{ "id": "facebook", "category": "channel", "simpleIcon": "facebook", "domains": ["facebook.com"] },
|
||||
{ "id": "reddit", "category": "channel", "simpleIcon": "reddit", "domains": ["reddit.com"] },
|
||||
{ "id": "telegram", "category": "channel", "simpleIcon": "telegram", "domains": ["telegram.org", "t.me"] },
|
||||
{ "id": "whatsapp", "category": "channel", "simpleIcon": "whatsapp", "domains": ["whatsapp.com"] },
|
||||
{ "id": "pinterest", "category": "channel", "simpleIcon": "pinterest", "domains": ["pinterest.com"] },
|
||||
|
||||
{ "id": "python", "category": "language", "simpleIcon": "python", "domains": ["python.org"] },
|
||||
{ "id": "typescript", "category": "language", "simpleIcon": "typescript", "aliases": ["ts"], "domains": ["typescriptlang.org"] },
|
||||
{ "id": "javascript", "category": "language", "simpleIcon": "javascript", "aliases": ["js"] },
|
||||
{ "id": "go", "category": "language", "simpleIcon": "go", "aliases": ["golang"], "domains": ["go.dev"] },
|
||||
{ "id": "rust", "category": "language", "simpleIcon": "rust", "domains": ["rust-lang.org"] },
|
||||
{ "id": "node-js", "category": "framework", "simpleIcon": "nodedotjs", "aliases": ["node", "nodejs"], "domains": ["nodejs.org"] },
|
||||
{ "id": "react", "category": "framework", "simpleIcon": "react", "aliases": ["reactjs"], "domains": ["react.dev"] },
|
||||
{ "id": "vue", "category": "framework", "simpleIcon": "vuedotjs", "aliases": ["vuejs", "vue.js"], "domains": ["vuejs.org"] },
|
||||
{ "id": "next-js", "category": "framework", "simpleIcon": "nextdotjs", "aliases": ["nextjs", "next.js"], "domains": ["nextjs.org"] },
|
||||
{ "id": "pytorch", "category": "framework", "simpleIcon": "pytorch", "domains": ["pytorch.org"] },
|
||||
{ "id": "tensorflow", "category": "framework", "simpleIcon": "tensorflow", "domains": ["tensorflow.org"] },
|
||||
{ "id": "angular", "category": "framework", "simpleIcon": "angular", "domains": ["angular.dev"] },
|
||||
{ "id": "svelte", "category": "framework", "simpleIcon": "svelte", "domains": ["svelte.dev"] },
|
||||
{ "id": "django", "category": "framework", "simpleIcon": "django", "domains": ["djangoproject.com"] },
|
||||
{ "id": "flask", "category": "framework", "simpleIcon": "flask", "domains": ["palletsprojects.com"] },
|
||||
{ "id": "fastapi", "category": "framework", "simpleIcon": "fastapi", "domains": ["fastapi.tiangolo.com"] },
|
||||
{ "id": "spring", "category": "framework", "simpleIcon": "spring", "aliases": ["spring-boot"], "domains": ["spring.io"] },
|
||||
{ "id": "dotnet", "category": "framework", "simpleIcon": "dotnet", "aliases": [".net"], "domains": ["dotnet.microsoft.com"] }
|
||||
]
|
||||
}
|
||||
+1313
File diff suppressed because it is too large
Load Diff
+280
@@ -0,0 +1,280 @@
|
||||
import { compileWorkflow } from '../renderers/workflow/workflow-compiler.mjs';
|
||||
import {
|
||||
createMappedWorkflowCandidate,
|
||||
intrinsicWorkflow,
|
||||
planningWorkflow,
|
||||
} from '../renderers/workflow/workflow-migration-geometry.mjs';
|
||||
import { validateSchema } from '../renderers/shared/validator.mjs';
|
||||
|
||||
export { createHorizontalRankMapper } from '../renderers/workflow/workflow-migration-geometry.mjs';
|
||||
|
||||
const TARGET_SCHEMA_VERSION = 2;
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function diagnostic({ code, message, subject = {}, evidence = {}, supportedFixes = [] }) {
|
||||
return {
|
||||
code,
|
||||
severity: 'error',
|
||||
message,
|
||||
subject,
|
||||
evidence,
|
||||
supportedFixes,
|
||||
};
|
||||
}
|
||||
|
||||
function schemaDiagnostics(workflow) {
|
||||
try {
|
||||
validateSchema('workflow', workflow);
|
||||
return [];
|
||||
} catch (error) {
|
||||
if (Array.isArray(error?.archifyDiagnostics)) {
|
||||
return error.archifyDiagnostics.map((entry) => ({ ...entry }));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function legacyLayoutProbe(workflow, qualityProfile) {
|
||||
// The probe discovers fixed-v1 rank centers, not authored canvas capacity.
|
||||
// Omitting viewBox lets a capacity-only legacy failure reach the v2 compiler,
|
||||
// which can measure and monotonically expand the real migrated document.
|
||||
const probe = {
|
||||
schema_version: 1,
|
||||
diagram_type: 'workflow',
|
||||
meta: {
|
||||
title: workflow.meta.title,
|
||||
output: workflow.meta.output,
|
||||
...(workflow.meta.locale ? { locale: workflow.meta.locale } : {}),
|
||||
legend: { mode: 'hidden' },
|
||||
},
|
||||
lanes: clone(workflow.lanes),
|
||||
nodes: [{
|
||||
id: 'migration_probe',
|
||||
lane: workflow.lanes[0].id,
|
||||
col: 0,
|
||||
type: 'backend',
|
||||
label: 'Probe',
|
||||
}],
|
||||
edges: [],
|
||||
};
|
||||
return compileWorkflow({ workflow: probe, qualityProfile });
|
||||
}
|
||||
|
||||
function legacyRequirementProbe(workflow, qualityProfile) {
|
||||
// Measure the complete fixed-v1 document without treating an authored
|
||||
// viewBox as its intrinsic requirement. The authored viewBox remains a
|
||||
// migration capacity and is preserved separately on the migrated document.
|
||||
const probe = clone(workflow);
|
||||
delete probe.meta.viewBox;
|
||||
return compileWorkflow({ workflow: probe, qualityProfile });
|
||||
}
|
||||
|
||||
function requiredViewBoxFrom(result) {
|
||||
if (Array.isArray(result?.receipt?.requiredViewBox)) {
|
||||
return [...result.receipt.requiredViewBox];
|
||||
}
|
||||
const required = result?.diagnostics
|
||||
?.map((entry) => entry?.evidence?.requiredViewBox)
|
||||
.find((candidate) => Array.isArray(candidate) && candidate.length === 2);
|
||||
return required ? [...required] : null;
|
||||
}
|
||||
|
||||
function expandableViewBox(result) {
|
||||
if (result.ok || !result.diagnostics?.length) return null;
|
||||
if (!result.diagnostics.every((entry) => entry.code === 'workflow/viewbox-capacity')) return null;
|
||||
return requiredViewBoxFrom(result);
|
||||
}
|
||||
|
||||
function result({
|
||||
ok,
|
||||
document,
|
||||
fromSchemaVersion = 1,
|
||||
preExistingDiagnostics = [],
|
||||
migrationDiagnostics = [],
|
||||
newSchemaDiagnostics = [],
|
||||
changedCoordinates = [],
|
||||
oldRequiredViewBox = null,
|
||||
newRequiredViewBox = null,
|
||||
}) {
|
||||
return {
|
||||
ok,
|
||||
...(document ? { document } : {}),
|
||||
fromSchemaVersion,
|
||||
toSchemaVersion: TARGET_SCHEMA_VERSION,
|
||||
preExistingDiagnostics,
|
||||
migrationDiagnostics,
|
||||
newSchemaDiagnostics,
|
||||
changedCoordinates,
|
||||
oldRequiredViewBox,
|
||||
newRequiredViewBox,
|
||||
};
|
||||
}
|
||||
|
||||
export function migrateWorkflowDocument(inputWorkflow) {
|
||||
if (!inputWorkflow || typeof inputWorkflow !== 'object' || Array.isArray(inputWorkflow)) {
|
||||
return result({
|
||||
ok: false,
|
||||
migrationDiagnostics: [diagnostic({
|
||||
code: 'migration/source-document',
|
||||
message: 'Workflow migration requires one parsed JSON object.',
|
||||
supportedFixes: ['provide one workflow schema v1 JSON document'],
|
||||
})],
|
||||
});
|
||||
}
|
||||
// Migration has no quality override: the authored policy (or effective
|
||||
// standard default) must validate the document after it leaves this process.
|
||||
const qualityProfile = inputWorkflow.meta?.quality_profile || 'standard';
|
||||
|
||||
const workflow = clone(inputWorkflow);
|
||||
const preExistingDiagnostics = schemaDiagnostics(workflow);
|
||||
if (preExistingDiagnostics.length) {
|
||||
return result({
|
||||
ok: false,
|
||||
fromSchemaVersion: workflow.schema_version,
|
||||
preExistingDiagnostics,
|
||||
});
|
||||
}
|
||||
if (workflow.schema_version === TARGET_SCHEMA_VERSION) {
|
||||
const compiled = compileWorkflow({ workflow: clone(workflow), qualityProfile });
|
||||
const requiredViewBox = requiredViewBoxFrom(compiled);
|
||||
if (!compiled.ok) {
|
||||
return result({
|
||||
ok: false,
|
||||
fromSchemaVersion: TARGET_SCHEMA_VERSION,
|
||||
preExistingDiagnostics: compiled.diagnostics,
|
||||
oldRequiredViewBox: requiredViewBox,
|
||||
newRequiredViewBox: requiredViewBox,
|
||||
});
|
||||
}
|
||||
return result({
|
||||
ok: true,
|
||||
document: workflow,
|
||||
fromSchemaVersion: TARGET_SCHEMA_VERSION,
|
||||
oldRequiredViewBox: requiredViewBox,
|
||||
newRequiredViewBox: requiredViewBox,
|
||||
});
|
||||
}
|
||||
if (workflow.schema_version !== 1) {
|
||||
return result({
|
||||
ok: false,
|
||||
fromSchemaVersion: workflow.schema_version,
|
||||
migrationDiagnostics: [diagnostic({
|
||||
code: 'migration/source-schema-version',
|
||||
message: 'Workflow migration to schema v2 requires a schema v1 or v2 source.',
|
||||
subject: { path: '/schema_version' },
|
||||
evidence: { actual: workflow.schema_version, expected: [1, 2] },
|
||||
supportedFixes: ['use an unchanged schema v1 workflow or an already migrated schema v2 workflow as the source'],
|
||||
})],
|
||||
});
|
||||
}
|
||||
|
||||
const legacy = compileWorkflow({ workflow: clone(workflow), qualityProfile });
|
||||
const legacyProbe = legacyLayoutProbe(workflow, qualityProfile);
|
||||
if (!legacyProbe.ok) {
|
||||
return result({
|
||||
ok: false,
|
||||
preExistingDiagnostics: legacy.ok ? [] : legacy.diagnostics,
|
||||
migrationDiagnostics: legacyProbe.diagnostics,
|
||||
});
|
||||
}
|
||||
const legacyRequirement = legacyRequirementProbe(workflow, qualityProfile);
|
||||
const oldRequiredViewBox = requiredViewBoxFrom(legacyRequirement)
|
||||
|| requiredViewBoxFrom(legacyProbe)
|
||||
|| requiredViewBoxFrom(legacy);
|
||||
const preExistingLayoutDiagnostics = legacy.ok ? [] : legacy.diagnostics;
|
||||
|
||||
let planned = compileWorkflow({ workflow: intrinsicWorkflow(workflow), qualityProfile });
|
||||
if (!planned.ok) {
|
||||
// Old absolute pins can be invalid at the new rank centers before their X
|
||||
// coordinates are mapped. Obtain the same rank plan from an automatic-route
|
||||
// projection, then validate every authored pin again after mapping.
|
||||
planned = compileWorkflow({ workflow: planningWorkflow(workflow), qualityProfile });
|
||||
}
|
||||
if (!planned.ok) {
|
||||
return result({
|
||||
ok: false,
|
||||
preExistingDiagnostics: preExistingLayoutDiagnostics,
|
||||
newSchemaDiagnostics: planned.diagnostics,
|
||||
oldRequiredViewBox,
|
||||
newRequiredViewBox: requiredViewBoxFrom(planned),
|
||||
});
|
||||
}
|
||||
|
||||
let mappedCandidate;
|
||||
try {
|
||||
mappedCandidate = createMappedWorkflowCandidate(
|
||||
workflow,
|
||||
legacyProbe.receipt.columns,
|
||||
planned.receipt.columns,
|
||||
);
|
||||
} catch (error) {
|
||||
return result({
|
||||
ok: false,
|
||||
preExistingDiagnostics: preExistingLayoutDiagnostics,
|
||||
migrationDiagnostics: [diagnostic({
|
||||
code: 'migration/rank-mapping',
|
||||
message: 'Could not construct a stable horizontal rank mapping.',
|
||||
evidence: { reason: error.message },
|
||||
supportedFixes: ['report the workflow and compiler receipts to the Archify maintainers'],
|
||||
})],
|
||||
oldRequiredViewBox,
|
||||
newRequiredViewBox: requiredViewBoxFrom(planned),
|
||||
});
|
||||
}
|
||||
|
||||
const { document: migrated, changedCoordinates } = mappedCandidate;
|
||||
|
||||
let compiled = compileWorkflow({ workflow: migrated, qualityProfile });
|
||||
const requiredExpansion = migrated.meta.viewBox ? expandableViewBox(compiled) : null;
|
||||
if (requiredExpansion) {
|
||||
const current = migrated.meta.viewBox;
|
||||
const expanded = [
|
||||
Math.max(current[0], requiredExpansion[0]),
|
||||
Math.max(current[1], requiredExpansion[1]),
|
||||
];
|
||||
if (expanded[0] > current[0] || expanded[1] > current[1]) {
|
||||
migrated.meta.viewBox = expanded;
|
||||
compiled = compileWorkflow({ workflow: migrated, qualityProfile });
|
||||
}
|
||||
}
|
||||
|
||||
const newRequiredViewBox = requiredViewBoxFrom(compiled) || requiredViewBoxFrom(planned);
|
||||
if (!compiled.ok) {
|
||||
return result({
|
||||
ok: false,
|
||||
preExistingDiagnostics: preExistingLayoutDiagnostics,
|
||||
newSchemaDiagnostics: compiled.diagnostics,
|
||||
changedCoordinates,
|
||||
oldRequiredViewBox,
|
||||
newRequiredViewBox,
|
||||
});
|
||||
}
|
||||
|
||||
const migratedSchemaDiagnostics = schemaDiagnostics(migrated);
|
||||
if (migratedSchemaDiagnostics.length) {
|
||||
return result({
|
||||
ok: false,
|
||||
preExistingDiagnostics: preExistingLayoutDiagnostics,
|
||||
newSchemaDiagnostics: migratedSchemaDiagnostics,
|
||||
changedCoordinates,
|
||||
oldRequiredViewBox,
|
||||
newRequiredViewBox,
|
||||
});
|
||||
}
|
||||
|
||||
return result({
|
||||
ok: true,
|
||||
document: migrated,
|
||||
preExistingDiagnostics: preExistingLayoutDiagnostics,
|
||||
changedCoordinates,
|
||||
oldRequiredViewBox,
|
||||
newRequiredViewBox,
|
||||
});
|
||||
}
|
||||
|
||||
export function serializeMigratedWorkflow(workflow) {
|
||||
return `${JSON.stringify(workflow, null, 2)}\n`;
|
||||
}
|
||||
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "archify",
|
||||
"version": "3.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "JSON-IR diagram renderers (architecture / workflow / sequence / dataflow / lifecycle).",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"archify": "./bin/archify.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"generate:viewer": "node ../scripts/generate-viewer.mjs",
|
||||
"check:viewer": "node ../scripts/generate-viewer.mjs --check",
|
||||
"generate:brand-marks": "node scripts/generate-brand-marks.mjs",
|
||||
"check:brand-marks": "node scripts/generate-brand-marks.mjs --check",
|
||||
"generate:validators": "node scripts/generate-validators.mjs",
|
||||
"check:validators": "node scripts/generate-validators.mjs --check",
|
||||
"check:release-identity": "node ../scripts/check-release-identity.mjs",
|
||||
"build:gallery": "node ../scripts/build-gallery.mjs ../docs",
|
||||
"build:guide": "node ../scripts/build-guide.mjs ../docs/guide.html",
|
||||
"build:start": "node ../scripts/build-start.mjs ../docs/start.html",
|
||||
"build:readme-showcase": "node ../scripts/build-readme-showcase.mjs",
|
||||
"test:webm": "node test/webm-artifact.smoke.mjs && node --test test/site-language-integration.mjs",
|
||||
"test:browser": "node ../scripts/run-browser-tests.mjs",
|
||||
"test": "npm run check:viewer && npm run check:brand-marks && npm run check:validators && npm run check:release-identity && node test/golden.mjs && node ../scripts/run-tests.mjs",
|
||||
"render:examples": "node scripts/render-examples.mjs ../examples"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"parse5": "7.3.0",
|
||||
"saxes": "6.0.0",
|
||||
"simple-icons": "16.28.0"
|
||||
},
|
||||
"overrides": {
|
||||
"fast-uri": "^3.1.7"
|
||||
}
|
||||
}
|
||||
Vendored
+430
@@ -0,0 +1,430 @@
|
||||
const RAW_RECIPES = [
|
||||
{
|
||||
id: 'system-overview', type: 'architecture', proof: 'web-app',
|
||||
presentation: { preset: 'classic', motion: 'static' },
|
||||
start: {
|
||||
en: { descriptionPrompt: 'Use Archify to turn this plain-language system description into a high-level architecture diagram: [describe the users, core components, primary path, external dependencies, and boundaries]. No repository is required. Ask only for missing facts that would materially change the diagram, mark any remaining unknowns instead of inventing them, and keep one obvious primary path across 8–12 core components.' },
|
||||
zh: { descriptionPrompt: '用 Archify 把下面这段自然语言系统描述画成高层架构图:[在这里描述用户、核心组件、主要路径、外部依赖和边界]。不需要代码库。只追问会实质影响图的缺失信息,其余不确定内容要标明而不是编造;保留 8–12 个核心组件和一条一眼可见的主路径。' },
|
||||
},
|
||||
signals: [['system overview', 12], ['architecture', 10], ['components', 6], ['services', 4], ['repository', 5], ['trust boundary', 8], ['架构', 10], ['系统总览', 12], ['组件', 6], ['服务', 4], ['仓库', 5], ['信任边界', 8]],
|
||||
en: {
|
||||
title: 'System overview', question: 'What exists, who owns it, and how is it connected?',
|
||||
summary: 'A bounded map of core components, external dependencies, primary paths, and trust boundaries.',
|
||||
useWhen: 'Onboarding, design reviews, repository orientation, or explaining a service landscape.',
|
||||
avoidWhen: 'The audience needs exact call order, state transitions, or row-level data lineage.',
|
||||
include: ['8–12 core components', 'one primary path', 'external dependencies', 'trust boundaries'],
|
||||
prompt: 'Analyze this repository, then use Archify to create a high-level architecture diagram. Show 8–12 core runtime components, one primary request or data path, external dependencies, ownership or trust boundaries, and put supporting detail in cards instead of adding more edges.',
|
||||
},
|
||||
zh: {
|
||||
title: '系统总览', question: '系统里有什么、归谁负责、彼此如何连接?',
|
||||
summary: '用一张有边界的图展示核心组件、外部依赖、主路径和信任边界。',
|
||||
useWhen: '适合新人上手、方案评审、仓库梳理和服务全景说明。',
|
||||
avoidWhen: '如果重点是精确调用顺序、状态流转或字段级血缘,请换其他配方。',
|
||||
include: ['8–12 个核心组件', '一条主路径', '外部依赖', '归属或信任边界'],
|
||||
prompt: '分析这个仓库,然后用 Archify 生成高层系统架构图。展示 8–12 个核心运行时组件、一条主要请求或数据路径、外部依赖、归属或信任边界;支持性细节放进卡片,不要继续堆连线。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'deployment-ownership', type: 'architecture', proof: 'deployment-ownership',
|
||||
presentation: { preset: 'blueprint', motion: 'trace' },
|
||||
signals: [['deployment topology', 14], ['region', 7], ['vpc', 9], ['cluster', 6], ['availability zone', 8], ['ownership', 7], ['cloud deployment', 12], ['部署拓扑', 14], ['区域', 6], ['集群', 6], ['可用区', 8], ['资源归属', 9], ['跨区', 8]],
|
||||
en: {
|
||||
title: 'Deployment ownership', question: 'Where does each workload run, and what crosses a boundary?',
|
||||
summary: 'A deployment-focused map of regions, networks, clusters, workloads, stores, and cross-boundary mechanisms.',
|
||||
useWhen: 'Cloud reviews, production readiness, multi-region planning, or infrastructure ownership handoffs.',
|
||||
avoidWhen: 'Deployment facts are unknown or the real question is application behavior rather than placement.',
|
||||
include: ['regions and networks', 'workload ownership', 'stateful services', 'named boundary crossings'],
|
||||
prompt: 'Use Archify to draw the production deployment topology. Group resources by region, network, cluster, and owner; show workloads and stateful services; label every cross-boundary mechanism. Do not invent deployment facts—mark unknown areas explicitly. If the user wants a fail-closed deployment review, ask before setting meta.engineering_profile to deployment-ownership; otherwise leave the engineering profile unset.',
|
||||
},
|
||||
zh: {
|
||||
title: '部署与归属', question: '每个工作负载运行在哪里,哪些连接跨越了边界?',
|
||||
summary: '围绕 Region、网络、集群、工作负载、存储和跨边界机制组织部署图。',
|
||||
useWhen: '适合云上评审、生产就绪、多区域规划和基础设施交接。',
|
||||
avoidWhen: '部署事实不清楚,或真正问题是应用行为而不是资源位置时不要使用。',
|
||||
include: ['区域与网络', '工作负载归属', '有状态服务', '明确的跨边界机制'],
|
||||
prompt: '用 Archify 绘制生产部署拓扑。按区域、网络、集群和负责人分组,展示工作负载与有状态服务,并标注每一种跨边界机制。不要编造部署事实,不确定的区域要明确标出。如果用户需要失败即阻断的部署评审,先征得确认,再把 meta.engineering_profile 设为 deployment-ownership;否则不要启用工程画像。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'agent-tool-call', type: 'workflow', proof: 'agent-tool-call',
|
||||
presentation: { preset: 'signal-flow', motion: 'trace' },
|
||||
start: {
|
||||
en: { descriptionPrompt: 'Use Archify workflow mode to turn this description into a diagram: [paste the actors, main steps, decisions, approvals, and exception paths]. Use lanes for distinct owners, keep one unmistakable happy path, and mark missing ownership or unresolved branches instead of inventing them.' },
|
||||
zh: { descriptionPrompt: '用 Archify 工作流模式把下面的描述画成图:[粘贴参与者、主要步骤、决策、审批和异常路径]。不同负责方使用独立泳道,保留一条明确的成功主路径,缺失的负责人或未定分支要标明而不是编造。' },
|
||||
},
|
||||
signals: [['agent tool call', 16], ['tool call', 12], ['approval gate', 10], ['human in the loop', 9], ['mcp', 7], ['planner', 6], ['agent loop', 10], ['智能体工具调用', 16], ['工具调用', 12], ['审批门', 10], ['人在回路', 9], ['规划器', 6], ['智能体循环', 10]],
|
||||
en: {
|
||||
title: 'Agent tool-call loop', question: 'How does an agent plan, get permission, act, recover, and report?',
|
||||
summary: 'A lane-based agent loop with policy gates, tool execution, exception recovery, evidence, and final response.',
|
||||
useWhen: 'Explaining agent runtimes, MCP/tool orchestration, approvals, retries, or observability.',
|
||||
avoidWhen: 'The goal is only to show static agent components or exact API message timing.',
|
||||
include: ['request and planning', 'policy or approval gate', 'tool execution', 'exception and evidence paths'],
|
||||
prompt: 'Use Archify workflow mode to explain this agent tool-call loop. Separate user surface, agent runtime, policy boundary, exception handling, tool execution, and observability into lanes. Make the successful path primary and show approval, retry, blocked, and evidence paths explicitly.',
|
||||
},
|
||||
zh: {
|
||||
title: '智能体工具调用', question: '智能体如何规划、获批、执行、恢复并汇报?',
|
||||
summary: '用泳道表达策略门、工具执行、异常恢复、证据和最终回复。',
|
||||
useWhen: '适合解释 Agent Runtime、MCP/工具编排、审批、重试和可观测性。',
|
||||
avoidWhen: '如果只想看静态组件,或重点是精确 API 消息时序,请换其他配方。',
|
||||
include: ['请求与规划', '策略或审批门', '工具执行', '异常与证据路径'],
|
||||
prompt: '用 Archify 工作流模式解释这段智能体工具调用。把用户界面、Agent Runtime、策略边界、异常处理、工具执行和可观测性分成泳道;突出成功主路径,并明确展示审批、重试、阻塞和证据路径。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'delivery-workflow', type: 'workflow', proof: 'delivery-workflow',
|
||||
presentation: { preset: 'classic', motion: 'trace' },
|
||||
signals: [['ci/cd', 14], ['release workflow', 14], ['deployment pipeline', 11], ['pull request', 7], ['staging', 7], ['rollback', 8], ['发布流程', 14], ['流水线', 9], ['上线', 7], ['预发', 7], ['回滚', 8], ['审批发布', 10]],
|
||||
en: {
|
||||
title: 'Delivery workflow', question: 'How does a change move safely from commit to production?',
|
||||
summary: 'A delivery flow with build, checks, environments, approvals, smoke tests, rollback, and ownership lanes.',
|
||||
useWhen: 'CI/CD design, release reviews, deployment governance, or onboarding developers to delivery.',
|
||||
avoidWhen: 'The question is where infrastructure runs or what states a deployment object can occupy.',
|
||||
include: ['trigger and build', 'blocking checks', 'approval and environments', 'rollback and verification'],
|
||||
prompt: 'Use Archify workflow mode to draw this delivery process from commit to production. Separate developer, CI, approval, environment, and exception lanes; mark blocking checks, smoke tests, ownership, and the rollback path. Keep one unmistakable happy path.',
|
||||
},
|
||||
zh: {
|
||||
title: '研发交付流程', question: '一次变更如何安全地从提交走到生产?',
|
||||
summary: '展示构建、检查、环境、审批、冒烟、回滚和负责人泳道。',
|
||||
useWhen: '适合 CI/CD 设计、发布评审、部署治理和研发新人上手。',
|
||||
avoidWhen: '如果重点是基础设施位置或部署对象的状态集合,请换架构图或生命周期图。',
|
||||
include: ['触发与构建', '阻断检查', '审批与环境', '回滚与验证'],
|
||||
prompt: '用 Archify 工作流模式绘制从代码提交到生产发布的流程。拆分开发者、CI、审批、环境和异常泳道;标出阻断检查、冒烟测试、负责人和回滚路径,并保留一条一眼可见的成功主路径。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'incident-runbook', type: 'workflow', proof: 'incident-runbook',
|
||||
presentation: { preset: 'signal-flow', motion: 'trace' },
|
||||
signals: [['incident response', 15], ['runbook', 12], ['outage', 9], ['triage', 8], ['mitigation', 8], ['escalation', 7], ['事故处置', 15], ['故障', 9], ['应急预案', 12], ['排障', 9], ['缓解', 7], ['升级响应', 8]],
|
||||
en: {
|
||||
title: 'Incident runbook', question: 'How do responders detect, triage, mitigate, verify, and escalate?',
|
||||
summary: 'An operational workflow that separates signals, responders, mitigation, communications, and recovery proof.',
|
||||
useWhen: 'Incident playbooks, on-call handoffs, reliability reviews, and tabletop exercises.',
|
||||
avoidWhen: 'The audience needs live metrics or a post-incident component topology instead of response actions.',
|
||||
include: ['detection signal', 'triage owner', 'mitigation and rollback', 'verification and communication'],
|
||||
prompt: 'Use Archify workflow mode to turn this incident runbook into responder lanes. Show detection, triage, mitigation, escalation, communication, rollback, and recovery verification. Separate decision gates from actions and make missing ownership visible.',
|
||||
},
|
||||
zh: {
|
||||
title: '事故处置 Runbook', question: '响应者如何发现、分诊、缓解、验证并升级?',
|
||||
summary: '把信号、响应者、缓解动作、沟通和恢复证据拆成可执行流程。',
|
||||
useWhen: '适合故障预案、On-call 交接、稳定性评审和桌面演练。',
|
||||
avoidWhen: '如果受众需要实时指标仪表盘或事故后的组件拓扑,而不是响应动作,请换其他视图。',
|
||||
include: ['发现信号', '分诊负责人', '缓解与回滚', '恢复验证与沟通'],
|
||||
prompt: '用 Archify 工作流模式把事故处置预案画成响应者泳道。展示发现、分诊、缓解、升级、沟通、回滚和恢复验证;把决策门与操作分开,并让缺失的负责人清晰可见。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'api-request', type: 'sequence', proof: 'cache-miss',
|
||||
presentation: { preset: 'classic', motion: 'trace' },
|
||||
start: {
|
||||
en: { descriptionPrompt: 'Use Archify sequence mode to draw this interaction: [paste the participants, calls, returns, fallback, and asynchronous side effects]. Keep message order unambiguous, labels short, and unknown behavior explicit. No repository is required.' },
|
||||
zh: { descriptionPrompt: '用 Archify 时序模式绘制下面的交互:[粘贴参与者、调用、返回、回退和异步副作用]。确保消息顺序无歧义、标签简短,并明确标注未知行为。不需要代码库。' },
|
||||
},
|
||||
signals: [['api request', 14], ['request response', 12], ['call chain', 11], ['cache miss', 13], ['jwt', 8], ['who calls whom', 12], ['api 请求', 14], ['请求响应', 12], ['调用链', 11], ['缓存未命中', 13], ['谁调用谁', 12], ['鉴权链路', 9]],
|
||||
en: {
|
||||
title: 'API request chain', question: 'Who calls whom, in what order, and what returns?',
|
||||
summary: 'A time-ordered request path with authentication, cache fallback, persistence, return traffic, and async trace.',
|
||||
useWhen: 'API documentation, debugging request latency, auth reviews, or explaining cache fallback.',
|
||||
avoidWhen: 'Order is unimportant and the audience only needs the stable service topology.',
|
||||
include: ['callers and callees', 'request and return messages', 'fallback or error path', 'async side effects'],
|
||||
prompt: 'Use Archify sequence mode to show this request from caller to final response. Include authentication, cache hit or miss, persistence fallback, return messages, and asynchronous trace or event emission. Keep message labels short and order unambiguous.',
|
||||
},
|
||||
zh: {
|
||||
title: 'API 请求链', question: '谁调用谁、顺序如何、最终返回什么?',
|
||||
summary: '按时间展示鉴权、缓存回退、持久化、返回流量和异步追踪。',
|
||||
useWhen: '适合 API 文档、请求耗时排查、鉴权评审和缓存回退说明。',
|
||||
avoidWhen: '如果顺序不重要,受众只需要稳定的服务拓扑,请用架构图。',
|
||||
include: ['调用方与被调用方', '请求与返回消息', '回退或错误路径', '异步副作用'],
|
||||
prompt: '用 Archify 时序模式展示从调用方到最终响应的完整请求。包含鉴权、缓存命中或未命中、持久化回退、返回消息,以及异步 Trace 或事件上报;消息标签保持简短,顺序必须明确。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'async-roundtrip', type: 'sequence', proof: 'async-roundtrip',
|
||||
presentation: { preset: 'signal-flow', motion: 'trace' },
|
||||
signals: [['async roundtrip', 14], ['webhook', 10], ['callback', 10], ['acknowledgement', 8], ['timeout', 7], ['retry message', 8], ['异步回调', 14], ['回调', 10], ['确认消息', 8], ['超时', 7], ['消息重试', 9], ['webhook', 10]],
|
||||
en: {
|
||||
title: 'Async roundtrip', question: 'What happens after the initial request returns?',
|
||||
summary: 'A sequence view of enqueue, acknowledgement, background work, callbacks, retries, timeout, and final consistency.',
|
||||
useWhen: 'Webhooks, jobs, queues, payment callbacks, eventual consistency, or async API contracts.',
|
||||
avoidWhen: 'The primary question is topic topology and consumer ownership rather than time order.',
|
||||
include: ['initial acknowledgement', 'queue or scheduler', 'background work', 'callback, retry, and timeout'],
|
||||
prompt: 'Use Archify sequence mode to explain this asynchronous roundtrip. Show the initial acknowledgement, enqueue or scheduling step, background processing, callback or polling, retry and timeout behavior, and the point where the caller can observe final consistency.',
|
||||
},
|
||||
zh: {
|
||||
title: '异步往返链路', question: '初始请求返回之后,后台还会发生什么?',
|
||||
summary: '按时间展示入队、确认、后台处理、回调、重试、超时和最终一致。',
|
||||
useWhen: '适合 Webhook、后台任务、队列、支付回调、最终一致和异步 API 契约。',
|
||||
avoidWhen: '如果重点是 Topic 拓扑和消费者归属,而不是时间顺序,请用事件数据流配方。',
|
||||
include: ['初始确认', '队列或调度器', '后台处理', '回调、重试与超时'],
|
||||
prompt: '用 Archify 时序模式解释这段异步往返链路。展示初始确认、入队或调度、后台处理、回调或轮询、重试与超时,以及调用方何时能观察到最终一致结果。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'data-lineage', type: 'dataflow', proof: 'product-analytics',
|
||||
presentation: { preset: 'classic', motion: 'trace' },
|
||||
signals: [['data lineage', 15], ['etl', 12], ['warehouse', 9], ['pii', 11], ['governance', 9], ['analytics pipeline', 12], ['数据血缘', 15], ['数据管道', 11], ['数仓', 9], ['治理', 9], ['隐私数据', 10], ['用户同意', 9]],
|
||||
en: {
|
||||
title: 'Data lineage', question: 'Where does data come from, how does it change, and who consumes it?',
|
||||
summary: 'A governed path from sources through consent, transforms, sensitive stores, warehouse, and consumers.',
|
||||
useWhen: 'Analytics architecture, ETL/ELT review, PII assessment, warehouse design, or model feature lineage.',
|
||||
avoidWhen: 'The audience needs request timing or operational task ownership rather than data assets.',
|
||||
include: ['sources and assets', 'transform stages', 'classification or consent', 'stores and consumers'],
|
||||
prompt: 'Use Archify dataflow mode to map this data lineage. Name every data asset and transform, show consent or classification boundaries, distinguish streaming from batch paths, and identify stores plus downstream consumers. Do not use unlabeled flows.',
|
||||
},
|
||||
zh: {
|
||||
title: '数据血缘', question: '数据从哪里来、如何变化、最终被谁消费?',
|
||||
summary: '从来源经过同意、转换、敏感存储、数仓直到消费者的治理路径。',
|
||||
useWhen: '适合分析架构、ETL/ELT 评审、PII 评估、数仓设计和特征血缘。',
|
||||
avoidWhen: '如果受众需要请求时序或操作负责人,而不是数据资产,请换其他配方。',
|
||||
include: ['数据来源与资产', '转换阶段', '分类或同意边界', '存储与消费者'],
|
||||
prompt: '用 Archify 数据流模式梳理这段数据血缘。为每个数据资产和转换命名,展示用户同意或数据分类边界,区分流式与批处理路径,并标明存储和下游消费者;所有数据流都必须有标签。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'event-stream', type: 'dataflow', proof: 'event-stream',
|
||||
presentation: { preset: 'signal-flow', motion: 'trace' },
|
||||
start: {
|
||||
en: { descriptionPrompt: 'Use Archify dataflow mode to map this data journey: [paste the sources, data assets, transforms, stores, boundaries, and consumers]. Label every flow, distinguish streaming from batch where relevant, and mark unknown classifications or ownership instead of inventing them.' },
|
||||
zh: { descriptionPrompt: '用 Archify 数据流模式梳理下面的数据路径:[粘贴来源、数据资产、转换、存储、边界和消费者]。为每条数据流标注名称,在有意义时区分流式与批处理,未知的分类或归属要标明而不是编造。' },
|
||||
},
|
||||
signals: [['event stream', 15], ['kafka topology', 14], ['topic', 8], ['consumer group', 11], ['dead letter', 10], ['dlq', 10], ['事件流', 15], ['kafka 拓扑', 14], ['主题', 7], ['消费者组', 11], ['死信', 10], ['事件地铁图', 12]],
|
||||
en: {
|
||||
title: 'Event-stream topology', question: 'Which events move through which topics, processors, groups, and failure paths?',
|
||||
summary: 'A stream map of producers, topics, ordered processors, consumer groups, state, replay, and DLQ.',
|
||||
useWhen: 'Kafka/event-platform design, stream processing reviews, ownership, replay, and failure handling.',
|
||||
avoidWhen: 'Topic names, consumer groups, and delivery semantics are not known—use a generic workflow instead.',
|
||||
include: ['producers and event names', 'topics and ordering', 'processors and consumer groups', 'state, replay, and DLQ'],
|
||||
prompt: 'Use Archify dataflow mode to draw this event-stream topology. Name producers, events, topics, ordered processors, consumer groups, state stores, replay paths, and the DLQ. Show ownership and delivery semantics only when supported by evidence.',
|
||||
},
|
||||
zh: {
|
||||
title: '事件流拓扑', question: '哪些事件经过哪些 Topic、处理器、消费者组和失败路径?',
|
||||
summary: '展示生产者、Topic、有序处理器、消费者组、状态、重放和 DLQ。',
|
||||
useWhen: '适合 Kafka/事件平台设计、流处理评审、归属、重放和失败处理。',
|
||||
avoidWhen: '如果 Topic、消费者组和投递语义都不清楚,请先用通用工作流,不要编造事件拓扑。',
|
||||
include: ['生产者与事件名', 'Topic 与顺序', '处理器与消费者组', '状态、重放与 DLQ'],
|
||||
prompt: '用 Archify 数据流模式绘制这段事件流拓扑。命名生产者、事件、Topic、有序处理器、消费者组、状态存储、重放路径和 DLQ;只有在证据充分时才标注归属和投递语义。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'object-lifecycle', type: 'lifecycle', proof: 'agent-run',
|
||||
presentation: { preset: 'classic', motion: 'trace' },
|
||||
start: {
|
||||
en: { descriptionPrompt: 'Use Archify lifecycle mode to model this object: [paste its states, transition events, waits, retries, cancellation, and terminal outcomes]. Separate active, waiting, recoverable-failure, and terminal states, and never hide an ending. No repository is required.' },
|
||||
zh: { descriptionPrompt: '用 Archify 生命周期模式建模这个对象:[粘贴它的状态、转换事件、等待、重试、取消和终态]。分开执行、等待、可恢复失败和终态,不要隐藏任何结束方式。不需要代码库。' },
|
||||
},
|
||||
signals: [['state machine', 15], ['object lifecycle', 14], ['status transition', 11], ['terminal state', 9], ['retry state', 8], ['状态机', 15], ['生命周期', 13], ['状态流转', 11], ['终态', 9], ['等待态', 8], ['重试状态', 8]],
|
||||
en: {
|
||||
title: 'Object lifecycle', question: 'Which states exist, what events move between them, and how does it end?',
|
||||
summary: 'A state model with active work, waits, retries, cancellation, failure, and explicit terminal outcomes.',
|
||||
useWhen: 'Tasks, orders, tickets, subscriptions, jobs, agent runs, or any durable object with status.',
|
||||
avoidWhen: 'The object has no durable state and the real question is participant interaction over time.',
|
||||
include: ['start and active states', 'event-labelled transitions', 'wait and retry states', 'all terminal outcomes'],
|
||||
prompt: 'Use Archify lifecycle mode to model this object. Separate main progress, waiting or interruption states, and terminal outcomes. Label transitions with events, include retry, cancellation, timeout, success, and failure where real, and never hide an ending.',
|
||||
},
|
||||
zh: {
|
||||
title: '对象生命周期', question: '有哪些状态、什么事件触发流转、最终如何结束?',
|
||||
summary: '展示执行、等待、重试、取消、失败以及明确终态的状态模型。',
|
||||
useWhen: '适合任务、订单、工单、订阅、作业、Agent Run 等带持久状态的对象。',
|
||||
avoidWhen: '对象没有持久状态,真正问题是参与者随时间的交互时,请使用时序图。',
|
||||
include: ['开始与执行态', '带事件的转换', '等待与重试态', '所有终态'],
|
||||
prompt: '用 Archify 生命周期模式建模这个对象。分开主进度、等待或中断状态和终态;用事件标注转换,并在真实存在时展示重试、取消、超时、成功和失败,不能隐藏任何结束方式。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'deployment-lifecycle', type: 'lifecycle', proof: 'deployment-lifecycle',
|
||||
presentation: { preset: 'signal-flow', motion: 'trace' },
|
||||
signals: [['deployment lifecycle', 15], ['release state', 10], ['promotion state', 9], ['approval status', 8], ['rollback state', 10], ['部署生命周期', 15], ['发布状态', 10], ['晋级', 7], ['审批状态', 8], ['回滚状态', 10]],
|
||||
en: {
|
||||
title: 'Deployment lifecycle', question: 'What state is a release in, and what can happen next?',
|
||||
summary: 'A deployment state model covering queued, building, verifying, approval, promotion, rollback, and terminal outcomes.',
|
||||
useWhen: 'Release controllers, GitOps reconciliation, environment promotion, or deployment status APIs.',
|
||||
avoidWhen: 'The question is the human/CI sequence of delivery actions rather than the deployment object state.',
|
||||
include: ['queued and running states', 'verification and approval', 'promotion and rollback', 'success, failure, cancellation'],
|
||||
prompt: 'Use Archify lifecycle mode to model the deployment object. Show queued, building, verifying, waiting for approval, promoting, rolling back, and every terminal outcome. Label the events and guards that permit each transition.',
|
||||
},
|
||||
zh: {
|
||||
title: '部署生命周期', question: '一次发布当前处于什么状态,下一步可能发生什么?',
|
||||
summary: '覆盖排队、构建、验证、审批、晋级、回滚和终态的部署状态模型。',
|
||||
useWhen: '适合发布控制器、GitOps 对账、环境晋级和部署状态 API。',
|
||||
avoidWhen: '如果重点是人员与 CI 的交付动作顺序,而不是部署对象状态,请用交付工作流。',
|
||||
include: ['排队与执行态', '验证与审批', '晋级与回滚', '成功、失败与取消'],
|
||||
prompt: '用 Archify 生命周期模式建模部署对象。展示排队、构建、验证、等待审批、晋级、回滚以及所有终态,并标注允许每次状态转换的事件和守卫条件。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'layout-repair', type: 'architecture', proof: 'web-app',
|
||||
presentation: { preset: 'classic', motion: 'static' },
|
||||
signals: [
|
||||
['layout repair', 20], ['repair order', 20], ['fix order', 20],
|
||||
['viewport overflow', 20], ['overflow', 16], ['scrollheight', 16], ['scrollwidth', 16],
|
||||
['overlap', 16], ['label overlap', 20], ['edge through node', 20], ['crossing', 8],
|
||||
['via', 7], ['waypoint', 14],
|
||||
['布局修复', 20], ['修复顺序', 20], ['视口溢出', 20], ['溢出', 16], ['滚动高度', 16], ['滚动宽度', 16],
|
||||
['重叠', 16], ['标签重叠', 20], ['连线穿过节点', 20], ['连线穿节点', 20], ['交叉', 8],
|
||||
['途经点', 14], ['路径点', 14], ['拐点', 14],
|
||||
],
|
||||
en: {
|
||||
title: 'Layout repair', question: 'Why does the existing diagram overflow, overlap, or route through nodes, and what should be fixed first?',
|
||||
summary: 'Repair an existing diagram in its current mode using validation diagnostics, explicit waypoint semantics, and a measured desktop viewport budget.',
|
||||
useWhen: 'An existing architecture, workflow, sequence, dataflow, or lifecycle diagram needs layout repair; keep its diagram type and presentation settings.',
|
||||
avoidWhen: 'The task is choosing a new diagram type. Do not change topology, delete meaningful labels, or hide overflow just to pass checks.',
|
||||
include: [
|
||||
'repair order: schema → overlap → direction → crossings → labels',
|
||||
'via contract: absolute [x, y] intermediate points; route = [start, ...via, end]',
|
||||
'desktop viewport budget for the complete page, including header and necessary cards',
|
||||
'validate after each edit, then inspect the final HTML in a browser',
|
||||
],
|
||||
prompt: 'Use Archify to repair this existing diagram while preserving its diagram type, topology, meaningful labels, and presentation settings. Follow references/authoring-contract.md in this order: (1) schema and missing/invalid meta.quality_profile; (2) node overlap or out-of-range placement; (3) edge-through-node and endpoint-direction errors; (4) crossings, ambiguous corridors, border runs, excessive detours, and route rhythm; (5) labels: label-to-node, label-to-label, then label-to-route clearance. Run validate after every edit and use diagnostics[] code, subject, evidence, and supportedFixes; apply one diagnosed geometry control at a time. Where the current schema supports via, it is an ordered array of absolute SVG [x, y] intermediate points: the route is [start, ...via, end], with start/end supplied by the node anchors. Explicit via points override automatic routing; they are not offsets or a request for automatic obstacle avoidance. For an orthogonal repair, align adjacent points on the same x or y and make the first/final segment respect fromSide/toSide; use only controls supported by the current diagram mode. Follow references/delivery-contract.md for the viewport budget: check 1440×900, 1600×1000, 1920×1080, and 2048×1320; require document.documentElement.scrollWidth <= window.innerWidth. Prefer document.documentElement.scrollHeight <= window.innerHeight, but preserve a Reader-declared readable vertical page scroll when browser-check explicitly accepts it after reaching the projected-text floor. Budget the entire page, including header, diagram, and necessary cards. Repair every other overflow by removing only redundant content or compacting spacing first; do not hide overflow, clip content, introduce an internal diagram scroller, stretch the SVG, or shrink typography to force a pass. Let finalize run the required browser evidence; run perceptual visual review only when requested or escalated by the delivery contract.',
|
||||
},
|
||||
zh: {
|
||||
title: '布局修复', question: '现有图为什么仍然溢出、重叠或连线穿过节点,应该先修什么?',
|
||||
summary: '保留现有图表模式,依据验证诊断、途经点语义和实测桌面视口预算修复布局。',
|
||||
useWhen: '已有架构图、工作流、时序图、数据流或生命周期图需要修复布局;保留原来的图表类型和表现设置。',
|
||||
avoidWhen: '任务是为新图选择类型时不要使用。不要为了通过检查改变拓扑、删除有意义的标签或隐藏溢出。',
|
||||
include: [
|
||||
'修复顺序:schema → 重叠 → 方向 → 交叉 → 标签',
|
||||
'via 契约:绝对 [x, y] 中间点;路径 = [start, ...via, end]',
|
||||
'包含标题和必要卡片的整页桌面视口预算',
|
||||
'每次修改后 validate,最终在浏览器中检查 HTML',
|
||||
],
|
||||
prompt: '用 Archify 修复这张现有图,保留图表类型、拓扑、有意义的标签和表现设置。遵循 references/authoring-contract.md 的顺序:(1) schema 错误及缺失或无效的 meta.quality_profile;(2) 节点重叠或越界;(3) 连线穿过节点及端点方向错误;(4) 交叉、含混的共享通道、贴边走线、过度绕行和转弯节奏;(5) 标签与节点、标签与标签、标签与连线的间距。每次修改后运行 validate,依据 diagnostics[] 的 code、subject、evidence 和 supportedFixes,每次只应用一项有诊断依据的几何控制。当前 schema 支持 via 时,它是按顺序排列的绝对 SVG [x, y] 中间点数组:路径为 [start, ...via, end],起终点由节点锚点提供。显式 via 会覆盖自动路由,不是偏移量,也不会请求自动绕障。修复正交走线时,相邻点应共享 x 或 y,首尾线段应遵守 fromSide/toSide;只使用当前模式支持的控制字段。视口预算遵循 references/delivery-contract.md:检查 1440×900、1600×1000、1920×1080 和 2048×1320,要求 document.documentElement.scrollWidth <= window.innerWidth。优先满足 document.documentElement.scrollHeight <= window.innerHeight;如果 browser-check 明确确认 Reader 已达到投影文字下限并接受可读的页面纵向滚动,则保留该滚动。预算覆盖整页,包括标题、主图和必要卡片。其他溢出先通过移除冗余内容或压缩间距修复,不得靠隐藏溢出、裁切、内部滚动区、拉伸 SVG 或缩小字体强行通过。让 finalize 执行必需的浏览器证据;只在用户要求或 delivery contract 规定的升级条件下进行感知视觉审阅。',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const SCENARIO_RECIPES = Object.freeze(RAW_RECIPES.map((recipe) => Object.freeze({
|
||||
...recipe,
|
||||
presentation: Object.freeze({ ...recipe.presentation }),
|
||||
...(recipe.start ? { start: Object.freeze({
|
||||
en: Object.freeze({ ...recipe.start.en }),
|
||||
zh: Object.freeze({ ...recipe.start.zh }),
|
||||
}) } : {}),
|
||||
signals: Object.freeze(recipe.signals.map((signal) => Object.freeze(signal.slice()))),
|
||||
en: Object.freeze({ ...recipe.en, include: Object.freeze(recipe.en.include.slice()) }),
|
||||
zh: Object.freeze({ ...recipe.zh, include: Object.freeze(recipe.zh.include.slice()) }),
|
||||
})));
|
||||
|
||||
export function detectGuideLanguage(value = '') {
|
||||
return /[\u3400-\u9fff]/u.test(value) ? 'zh' : 'en';
|
||||
}
|
||||
|
||||
export function startPromptsFor(recipe, lang = 'en') {
|
||||
const language = lang === 'zh' ? 'zh' : 'en';
|
||||
const copy = recipe[language];
|
||||
const descriptionPrompt = recipe.start?.[language]?.descriptionPrompt;
|
||||
if (!descriptionPrompt) {
|
||||
throw new Error(`Scenario recipe ${JSON.stringify(recipe.id)} does not define a ${language} start prompt.`);
|
||||
}
|
||||
const repositoryPrompt = recipe.type === 'architecture'
|
||||
? copy.prompt
|
||||
: language === 'zh'
|
||||
? `先检查这个仓库里的相关证据,然后${copy.prompt}不要编造代码无法支持的行为。`
|
||||
: `Inspect this repository for evidence, then ${copy.prompt.charAt(0).toLowerCase()}${copy.prompt.slice(1)} Do not invent behavior that the code does not support.`;
|
||||
return { descriptionPrompt, repositoryPrompt };
|
||||
}
|
||||
|
||||
function normalized(value) {
|
||||
return String(value || '').normalize('NFKC').toLowerCase().replace(/[\s_]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function localized(recipe, lang) {
|
||||
const copy = recipe[lang === 'zh' ? 'zh' : 'en'];
|
||||
return {
|
||||
id: recipe.id,
|
||||
type: recipe.type,
|
||||
proof: recipe.proof,
|
||||
presentation: { ...recipe.presentation },
|
||||
...copy,
|
||||
include: copy.include.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
export function listScenarioRecipes(lang = 'en') {
|
||||
return SCENARIO_RECIPES.map((recipe) => localized(recipe, lang));
|
||||
}
|
||||
|
||||
function scoreRecipe(recipe, query) {
|
||||
const text = normalized(query);
|
||||
if (!text) return { recipe, score: 0, matched: [] };
|
||||
if (text === recipe.id || text === recipe.id.replace(/-/g, ' ')) {
|
||||
return { recipe, score: 100, matched: [recipe.id] };
|
||||
}
|
||||
let score = 0;
|
||||
const matched = [];
|
||||
for (const [signal, weight] of recipe.signals) {
|
||||
if (text.includes(normalized(signal))) {
|
||||
score += weight;
|
||||
matched.push(signal);
|
||||
}
|
||||
}
|
||||
return { recipe, score, matched };
|
||||
}
|
||||
|
||||
export function recommendScenario(query, options = {}) {
|
||||
const lang = options.lang === 'zh' || options.lang === 'en' ? options.lang : detectGuideLanguage(query);
|
||||
const ranked = SCENARIO_RECIPES.map((recipe) => scoreRecipe(recipe, query))
|
||||
.sort((left, right) => right.score - left.score || SCENARIO_RECIPES.indexOf(left.recipe) - SCENARIO_RECIPES.indexOf(right.recipe));
|
||||
const winner = ranked[0].score > 0 ? ranked[0] : { recipe: SCENARIO_RECIPES[0], score: 0, matched: [] };
|
||||
const confidence = winner.score >= 14 ? 'high' : winner.score >= 7 ? 'medium' : 'low';
|
||||
return {
|
||||
ok: true,
|
||||
mode: 'recommendation',
|
||||
lang,
|
||||
query: String(query || ''),
|
||||
confidence,
|
||||
matchedSignals: winner.matched.slice(),
|
||||
recommendation: localized(winner.recipe, lang),
|
||||
alternatives: ranked.filter((entry) => entry.recipe.id !== winner.recipe.id && entry.score > 0)
|
||||
.slice(0, 2)
|
||||
.map((entry) => ({ ...localized(entry.recipe, lang), score: entry.score })),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatScenarioList(lang = 'en') {
|
||||
const isZh = lang === 'zh';
|
||||
const heading = isZh
|
||||
? `Archify 场景配方(${SCENARIO_RECIPES.length})`
|
||||
: `Archify scenario recipes (${SCENARIO_RECIPES.length})`;
|
||||
const intro = isZh
|
||||
? '先选择你要回答的问题,再选择图表类型。可运行:archify guide "你的场景"'
|
||||
: 'Choose the question before the diagram type. Run: archify guide "your scenario"';
|
||||
return [heading, '', intro, '', ...listScenarioRecipes(lang).flatMap((recipe) => [
|
||||
`${recipe.id} [${recipe.type}] ${recipe.title}`,
|
||||
` ${recipe.question}`,
|
||||
])].join('\n');
|
||||
}
|
||||
|
||||
export function formatScenarioRecommendation(result) {
|
||||
const isZh = result.lang === 'zh';
|
||||
const recipe = result.recommendation;
|
||||
const labels = isZh ? {
|
||||
heading: '推荐', question: '要回答的问题', use: '适合', avoid: '不要这样用', include: '必须包含', presentation: '表现建议', prompt: '可直接复制的提示词', alternatives: '其他可能', confidence: '置信度',
|
||||
} : {
|
||||
heading: 'Recommendation', question: 'Question answered', use: 'Use when', avoid: 'Avoid when', include: 'Must include', presentation: 'Presentation', prompt: 'Copy-ready prompt', alternatives: 'Other possible fits', confidence: 'Confidence',
|
||||
};
|
||||
const lines = [
|
||||
`${labels.heading}: ${recipe.title} [${recipe.type}]`,
|
||||
`${labels.confidence}: ${result.confidence}`,
|
||||
`${labels.question}: ${recipe.question}`,
|
||||
'',
|
||||
`${labels.use}: ${recipe.useWhen}`,
|
||||
`${labels.avoid}: ${recipe.avoidWhen}`,
|
||||
`${labels.include}: ${recipe.include.join(isZh ? '、' : '; ')}`,
|
||||
`${labels.presentation}: ${recipe.presentation.preset} · ${recipe.presentation.motion}`,
|
||||
'',
|
||||
`${labels.prompt}:`,
|
||||
recipe.prompt,
|
||||
];
|
||||
if (result.alternatives.length) {
|
||||
lines.push('', `${labels.alternatives}: ${result.alternatives.map((item) => `${item.title} [${item.type}]`).join(' · ')}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function publicGuideData() {
|
||||
return SCENARIO_RECIPES.map((recipe) => ({
|
||||
...localized(recipe, 'en'),
|
||||
en: recipe.en,
|
||||
zh: recipe.zh,
|
||||
signals: recipe.signals.map(([signal, weight]) => [signal, weight]),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Architecture layout repair
|
||||
|
||||
Use this after actual visual review finds several tangled routes. A successful machine receipt does not settle composition. Work on the existing candidate, retaining all required components, relationships, labels, evidence, boundaries, and node sizes.
|
||||
|
||||
## Choose the repair scope
|
||||
|
||||
If the main and secondary chains already read clearly, repair the isolated defect locally. If a main chain is blocked, several routes tangle, or a local fix moves the defect onto another route, reflow the connected scene in one edit. Preserve all semantics and user-fixed geometry; agent-generated positions and route controls may change. After moving nodes, remove stale generated route overrides so automatic routing can use the new placement.
|
||||
|
||||
The receipt's `directCorridorBlockers`, when present, names nodes between an edge's aligned endpoints. This is geometric evidence, not proof that the edge is the main path or a new validation failure. Trace the reader's actual main path first. When a listed blocker interrupts that path, reposition the connected group instead of adding another detour.
|
||||
|
||||
## One coherent repair
|
||||
|
||||
1. Trace the affected relationships and their endpoints in the JSON. Before choosing coordinates, write the reader’s main path as an ordered list of existing edges, then identify shared state and real feedback cycles. Every neighboring pair in that main path must have the relationship being explained; place other components on branches beside their actual owner. Use `validate architecture <candidate.json> --layout-json --repo-root <root>` once if the compact receipt and screenshot do not reveal the needed route or label geometry; omit `--repo-root` only for a design without repository evidence. Do not guess repeated waypoint coordinates.
|
||||
2. Place those connected main-path neighbors in reading order. Put shared state between its readers/writers, on an adjacent row if necessary, so one writer does not need a line across the whole execution area. Arrange a feedback cycle in its actual edge order around an open rectangle, with other consumers beside their owner. For example, if the edges are `client → API → dispatcher → worker → collector → runner → dispatcher`, put the first four on the upper row, collector below worker, and runner below dispatcher: the return then uses the lower row and a short upward edge. This illustrates adjacency, not a graph or set of coordinates to copy; use only relationships present in the candidate. Preserve every edge and its actual direction.
|
||||
3. Keep automatic endpoints for the new placement. Constrain a side only when a branch or return needs a specific corridor; check that corridor against every affected relationship, including storage branches. If that local constraint introduces another conflict, return to the connected placement instead of cycling through side combinations. Only use detailed `via` or label coordinates for a remaining measured defect. Keep external actors outside the resolved boundary rectangle, including its padding; not listing a node in `wraps` does not visually exclude it. Keep an internal relationship and its label inside the shared boundary unless crossing it conveys a real fact; do not imply an external hop merely to avoid another route.
|
||||
4. When `composition/label-gap` reports a measured minimum, enlarge that clear gap or move the connected group onto another readable row. Keep the full label beside its own route; placing it in distant empty space does not repair the relationship. Compact unused gaps while preserving measured label space and the previous text size. Keep the main interaction and all required nodes readable in the default desktop view; do not trade crossings for a large blank canvas, tiny text, or a chain that doubles back without a semantic reason.
|
||||
|
||||
Run the complete `finalize` once after the edit, then `visual-check` on the successful artifact and inspect its desktop captures. When replacing an already reviewed artifact, use one fresh `--out-dir` for both commands as described in [the delivery contract](delivery-contract.md#a-new-candidate-at-an-existing-output-path). Trace the main path, each secondary chain, and every affected arrow and label. A bounded second repair may address a remaining specific defect. If it still fails visual acceptance, retain the candidate and report the concrete gap; do not count it as a successful repair or continue blind coordinate changes.
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
# Authoring contract
|
||||
|
||||
Read this reference only after the Fast authoring path calls for more detail. The schemas and examples remain authoritative.
|
||||
|
||||
## Composition repair
|
||||
|
||||
When correcting an authored overview's abstraction, map every affected role, relationship direction, protocol, boundary, condition, and source reference to its surviving node or relationship before regrouping. A startup citation does not prove a message protocol. Preserve each claim's inspected evidence. Keep user-supplied or agreed topology fixed; fewer routes alone do not justify merging. A boundary around one node requires an explicit isolation fact and must not merely repeat its label.
|
||||
|
||||
## Label repair
|
||||
|
||||
When a relationship label collides, move the label, adjust the route or spacing, then shorten the wording while preserving meaning. Omit wording only when both endpoints fully imply it and it conveys no protocol, action, direction, synchronous or asynchronous behavior, or cross-boundary mechanism. Spacing means clear gap rather than center distance; measured mask width takes precedence. The first-draft gap budget is in [Layout and routing](authoring-defaults.md#layout-and-routing).
|
||||
|
||||
For a disproportionate sublabel, keep its exact role or protocol concise and place the supplementary fact in a note or card. Preserve every required responsibility, protocol, and boundary fact. Use the first-draft node-width budget in [Layout and routing](authoring-defaults.md#layout-and-routing).
|
||||
|
||||
## Schema lookup
|
||||
|
||||
Read both the mode schema and `schemas/common.schema.json`. The mode schemas use `$ref`, so the common file is where shared enums live.
|
||||
|
||||
- `componentType`: `frontend`, `backend`, `database`, `cloud`, `security`, `messagebus`, `external`
|
||||
- `variant`: `default`, `emphasis`, `security`, `dashed`
|
||||
- Relationship IDs use the shared identifier pattern and must be unique in their collection.
|
||||
|
||||
Do not invent fields. Before writing any new field, enum, or constrained text, read its schema definition, including common `$ref` targets. In particular, check boundary kinds, repository identity, and source-reference shapes. An example demonstrates structure; it does not enumerate every valid value. Author fresh IDs, wording, facts, and layout.
|
||||
|
||||
## Workflow layout contracts
|
||||
|
||||
Use schema v2 for new workflows and keep schema v1 when an existing source must
|
||||
retain fixed geometry. In both versions, `col` stays in `0..5` and semantic
|
||||
edge labels are never deleted as a spacing repair. Do not change only
|
||||
`schema_version` when absolute coordinates exist: follow the canonical
|
||||
[migration and layout-receipt contract](../renderers/workflow/README.md#migration-and-layout-receipt).
|
||||
The complete normative invariants live in the workflow renderer's
|
||||
[layout contracts](../renderers/workflow/README.md#layout-contracts).
|
||||
For sequential stages stacked in one container, use one v2 lane and group,
|
||||
omit `meta.viewBox`, and center nodes around the lane content with symmetric
|
||||
`yOffset` values such as `-90 / 0 / 90`. Keep semantic edge labels and act on
|
||||
compiler diagnostics.
|
||||
|
||||
## Legend contract
|
||||
|
||||
Omit `meta.legend` for the truthful default: `auto` lists only semantic kinds
|
||||
present in typed IR. Use `mode: "all"` for a renderer reference or
|
||||
`mode: "hidden"` to remove the full legend. Under `entries`, only keys listed
|
||||
by the selected mode schema are valid; each key accepts `label`, `visible`, or
|
||||
both. `visible: true` may show an unused supported convention, while
|
||||
`visible: false` hides it. `hidden` cannot be overridden.
|
||||
|
||||
A label override changes reader wording only. Never infer a kind from prose or
|
||||
use the legend to compensate for missing nodes, states, messages, or flows.
|
||||
Long labels are measured and wrap into deterministic rows. Architecture's
|
||||
implicit automatic viewBox grows from that same measured footprint. For
|
||||
backwards compatibility, a legacy document with no `meta.legend` may omit an
|
||||
implicit auto legend that cannot fit its explicit viewBox; this never changes
|
||||
its typed topology. Adding `meta.legend` makes the presentation intentional and
|
||||
strict: if its resolved labels cannot fit the authored viewBox, shorten or hide
|
||||
them, or widen the viewBox using the emitted diagnostic.
|
||||
|
||||
## Language consistency
|
||||
|
||||
Choose one primary authored language. An explicit user choice wins; otherwise
|
||||
use the language of the request, or the conversation's dominant language when
|
||||
the request itself is language-neutral. Separately choose the Viewer locale.
|
||||
Always write the matching `meta.locale` as a well-formed language tag: `"en"`
|
||||
for English, `"zh-CN"` for Simplified Chinese, `"es"` for Spanish, or any
|
||||
other tag for another language. The renderer consumes the authored locale without inferring language
|
||||
from diagram strings. Documents that omit it remain valid and default to
|
||||
English.
|
||||
|
||||
`meta.locale` controls only renderer-owned reader surfaces: `<html lang>`, the
|
||||
document-title suffix, default SVG description and focus labels, default legend
|
||||
labels, and fixed Viewer controls, statuses, accessibility names, and errors.
|
||||
It never translates authored content. Apply the primary language separately to
|
||||
titles, subtitles, node and relationship copy, boundaries, lanes, groups,
|
||||
legend label overrides, and cards. A bilingual diagram still
|
||||
chooses one primary locale for the Viewer; follow an explicit primary-language
|
||||
request, then prompt order or conversation dominance.
|
||||
|
||||
`en` and `zh-CN` are built-in Viewer catalogs and need nothing further. For
|
||||
every other `meta.locale`, also set `meta.translations`: an object mapping the
|
||||
renderer's canonical message keys (`catalogKeys()` in
|
||||
`renderers/shared/i18n.mjs`) to translated strings whose `{placeholder}` tokens
|
||||
match the English source exactly. Reuse suitable translations from `examples/locales/` or a previously reviewed
|
||||
catalog; Spanish uses `examples/locales/es.json`. Translate missing keys or adapt terminology when the diagram needs it;
|
||||
use the English source to check keys and placeholders. Example catalogs may
|
||||
lag new Viewer keys; validation reports those gaps and uses English for them. A key that is missing, unrecognized, or has mismatched
|
||||
placeholders falls back to its English string — `validate`/`render`/`deliver`
|
||||
report the resulting coverage to stderr — rather than breaking the render or
|
||||
silently shipping an untranslated string as if it were translated.
|
||||
|
||||
For older dev inputs using only `meta.locale: "es"`, copy the Spanish catalog
|
||||
into `meta.translations` before rendering again. Existing standalone HTML
|
||||
keeps its embedded translations.
|
||||
|
||||
For a requested language you cannot supply `meta.translations` for, do not
|
||||
write a `meta.locale` with no built-in catalog and no translations. Keep every
|
||||
reader-facing authored string in the requested language, omit `meta.locale` so
|
||||
the renderer safely uses English, and explicitly tell the user that fixed
|
||||
Viewer UI and `<html lang>` remain English and the artifact is not fully localized.
|
||||
The fallback applies only to renderer-owned surfaces; it never
|
||||
permits authored copy to fall back to English. Do not silently substitute
|
||||
`zh-CN` for another language or Chinese locale, and do not machine-translate
|
||||
`meta.translations` values without disclosing that they are unreviewed.
|
||||
|
||||
Keep exact product names, code identifiers, commands, protocols, API paths, and
|
||||
environment names intact. Those terms may remain English inside localized copy,
|
||||
but surrounding explanatory prose must still use the selected language.
|
||||
Renderer-owned default legend labels follow `meta.locale`; author a
|
||||
`meta.legend.entries.*.label` override only when the diagram needs different
|
||||
domain wording, and keep that authored override in the primary language.
|
||||
|
||||
## Visual preset default
|
||||
|
||||
Omit `meta.visual_preset` by default. The renderer then opens the diagram in
|
||||
`classic` for both light and dark color modes. Color mode and visual preset are
|
||||
independent viewer state: switching Light / Dark must preserve the current
|
||||
preset. Author `signal-flow`, `blueprint`, or `editorial` only when the user
|
||||
explicitly requests that visual style.
|
||||
|
||||
## Engineering profile default
|
||||
|
||||
Omit `meta.engineering_profile` for an ordinary system architecture. Region,
|
||||
cluster, and security boundary wording do not by themselves enable an
|
||||
engineering profile. Enable `deployment-ownership` only when the user
|
||||
explicitly asks for a production deployment topology, ownership handoff, or
|
||||
fail-closed deployment review and the source facts are known. Once enabled,
|
||||
do not remove the engineering profile merely to pass validation; repair the
|
||||
authored facts or report the diagnostics truthfully.
|
||||
|
||||
## Title hierarchy
|
||||
|
||||
Use one concise title and let the diagram carry the explanation. Omit
|
||||
`meta.subtitle` by default, and never use it to restate the title, nodes, edges,
|
||||
or cards. Include one short supporting line only when the user explicitly asks
|
||||
for a subtitle; an omitted or blank subtitle must not leave an empty visual row
|
||||
in the generated viewer.
|
||||
|
||||
## Executable geometry rules
|
||||
|
||||
Generate one responsive artifact for laptops and external displays, preserving the authored SVG/viewBox, proportions, semantic geometry, and normal document flow. Use meaningful content rows and the Reader-declared readable page-scroll behavior when the complete diagram needs more height; viewport fitting does not authorize alternate topology or smaller typography.
|
||||
|
||||
- Node anchors start at side midpoints. `left`/`right` change the horizontal endpoint; `top`/`bottom` change the vertical endpoint. For an automatic Architecture relationship, unobstructed facing ports whose axis offset is under 16px may share one horizontal or vertical axis when both endpoints retain the 16px corner gutter. If exactly one endpoint belongs to a spread group, only its unshared counterpart moves; relationships spread at both endpoints keep their distinct ports and outside bridge unless a reciprocal facing pair can jointly use separate straight lanes while preserving endpoint spacing, labels, and all surrounding route and obstacle clearances.
|
||||
- A side is a direction contract. The first and final route segment must be perpendicular and outward/inward in the named direction.
|
||||
- In architecture, data-flow, and lifecycle diagrams, explicit `route: "straight"` requests one direct segment, which may be diagonal when endpoint sides are not pinned. The artifact checker preserves this intent; explicit sides, opaque-node clearance, and other quality gates still apply. `via` takes precedence and retains existing rules, including data-flow's requirement for orthogonal via segments.
|
||||
- Automatic Port Spread is a default renderer behavior for architecture, workflow, data-flow, and lifecycle diagrams. Shared automatic endpoints spread deterministically and symmetrically with a 16px corner gutter. It does not apply to sequence messages, single relationships, or explicit `via`, `channelX`, `channelY`, `labelAt`, or non-`auto` routes.
|
||||
- Showcase route rhythm: every nonzero segment must be at least 8px; every interior segment must be at least 16px. When spread ports are nearly parallel, the router uses a 24px endpoint stub and a 16px outside bridge instead of manufacturing a tiny dogleg.
|
||||
- Showcase route compactness: an explicit Architecture route fails with `composition/excessive-route-detour` when its orthogonal length is at least 2.5 times an obstacle-aware legal route, adds at least 200px, and sends a control point at least 96px beyond the content envelope. The evidence records both lengths, ratio, excess, bounds, and excursion. Remove an unnecessary `via` or move the diagnosed corridor inward instead of enlarging the canvas. Related relationships that overlap on the same outer corridor by at least 32px are treated as an intentional bus and remain valid.
|
||||
- Shared endpoint corridors are allowed only when they remain semantically unambiguous. Unrelated collinear overlap of 8px or more fails showcase.
|
||||
- Container borders are intentional pass-through geometry, but a long edge running along a structural border is not.
|
||||
- An edge crossing an unrelated opaque node is always a hard failure, independent of quality profile.
|
||||
|
||||
### Explicit `via` coordinates
|
||||
|
||||
Use the resolved departure anchor `S = [sx, sy]` and arrival anchor
|
||||
`T = [tx, ty]`. Anchors start at side midpoints, but automatic routing and
|
||||
Port Spread can move them as described above; do not assume an anchor copied
|
||||
from an automatic route is the anchor of a newly authored explicit route.
|
||||
Explicit `via` routes do not receive automatic Port Spread.
|
||||
|
||||
For the first waypoint `F = via[0]` and last waypoint `L = via[via.length - 1]`,
|
||||
use these alignments and directions (SVG y increases downward):
|
||||
|
||||
| Side | Departure (`fromSide`): `S` → `F` | Arrival (`toSide`): `L` → `T` |
|
||||
| --- | --- | --- |
|
||||
| `top` | `F[0] === sx`, `F[1] < sy` | `L[0] === tx`, `L[1] < ty` |
|
||||
| `bottom` | `F[0] === sx`, `F[1] > sy` | `L[0] === tx`, `L[1] > ty` |
|
||||
| `left` | `F[1] === sy`, `F[0] < sx` | `L[1] === ty`, `L[0] < tx` |
|
||||
| `right` | `F[1] === sy`, `F[0] > sx` | `L[1] === ty`, `L[0] > tx` |
|
||||
|
||||
For example, given a bottom departure anchor `S = [180, 160]` and a left
|
||||
arrival anchor `T = [360, 260]`, this relationship fragment leaves downward
|
||||
and enters the target rightward:
|
||||
|
||||
```json
|
||||
{
|
||||
"from": "source",
|
||||
"to": "target",
|
||||
"fromSide": "bottom",
|
||||
"toSide": "left",
|
||||
"via": [[180, 200], [300, 200], [300, 260]]
|
||||
}
|
||||
```
|
||||
|
||||
The full path is `[180, 160] → [180, 200] → [300, 200] → [300, 260] → [360, 260]`.
|
||||
Changing only the first waypoint to `[200, 200]` makes the departure diagonal;
|
||||
changing it to `[180, 120]` keeps its x aligned but leaves upward through the
|
||||
source instead of outward from its bottom. Both violate `fromSide: "bottom"`
|
||||
and produce `clean-flow/endpoint-side-direction`. The example establishes
|
||||
endpoint direction only: keep the full route clear of unrelated nodes and
|
||||
apply the other geometry rules above.
|
||||
|
||||
### Spacing and labels
|
||||
|
||||
In showcase Architecture, an unpinned connection label keeps its default position
|
||||
when clear. If it collides, the renderer tries a bounded set of nearby positions
|
||||
along the existing route, avoiding nodes, boundary titles, other labels and
|
||||
other routes within the resolved canvas. Explicit `labelAt`, `labelDx`, `labelDy`
|
||||
or `labelSegment` (including zero) disables this fallback. Routes and topology
|
||||
stay unchanged; if no nearby position is clear, validation reports the original
|
||||
collision. Inspect resolved labels with `--layout-json` before adding controls.
|
||||
Standard placement retains its existing behavior.
|
||||
|
||||
Spacing recommendations mean clear gap between boxes, not center distance. A 200px center distance between 165px-wide nodes leaves only 35px of clear gap.
|
||||
|
||||
For a relationship label, require:
|
||||
|
||||
```text
|
||||
clear gap > label mask width + 8px breathing room
|
||||
label mask width ≈ 6.5px × ASCII units + 13px
|
||||
CJK characters count as two units
|
||||
```
|
||||
|
||||
Relationship labels are semantic data. If the gap is too small, move the label,
|
||||
adjust the route or spacing, then shorten the wording while preserving meaning.
|
||||
Omit only wording already fully implied by both endpoints and carrying no
|
||||
protocol, action, direction, synchronous/asynchronous behavior, or
|
||||
cross-boundary mechanism. Preserve every meaningful label.
|
||||
Deleting it is not a spacing repair. If a relationship starts unlabeled because
|
||||
its endpoints fully imply it, explain why the wording is redundant; this is a
|
||||
semantic authoring choice, not a spacing repair. In workflow v2, let the compiler
|
||||
allocate its measured mask before applying a diagnosed `labelAt`,
|
||||
`labelDx`/`labelDy`, or `labelSegment`. Apply one diagnosed geometry control at
|
||||
a time unless several edges share a constrained channel. In that case, plan the smallest coupled change from measured geometry and
|
||||
validate it together. Architecture/workflow provide layout evidence through
|
||||
`validate <type> <candidate.json> --layout-json`; for other types, use validation
|
||||
diagnostics and the rendered SVG geometry.
|
||||
Before adding manual routes, check whether unnecessary agent-added controls
|
||||
disable automatic port spread; preserve user-required route intent. Use the
|
||||
measured clearance rules above rather than guessing coordinates.
|
||||
|
||||
### Repair evidence
|
||||
|
||||
For architecture, `validate architecture <input.json> --layout-json` exposes the
|
||||
resolved component boxes, boundary frames, connection points, and label positions.
|
||||
A measurable rejected layout also returns these fields, with `ok: false`,
|
||||
`contract: "archify-architecture-layout-v1"`, diagnostics, and exit 1. This is
|
||||
repair evidence, not artifact acceptance; it writes no HTML. Malformed input or
|
||||
an implementation failure retains the ordinary failure receipt without layout.
|
||||
|
||||
Use the measured failing side for `layout/boundary-out-of-bounds`. Left/top
|
||||
negative coordinates need an inward move; increasing viewBox width/height only
|
||||
addresses right/bottom overflow. Boundaries may wrap members across rows. Keep
|
||||
real membership intact and recheck connected routes after moving members.
|
||||
|
||||
Automatic architecture canvases include route points as well as nodes, frames,
|
||||
and labels. An authored viewBox remains authoritative. In showcase,
|
||||
`layout/route-out-of-bounds` identifies clipped route points; negative coordinates
|
||||
need an inward route, while right/bottom overflow can also use a larger authored
|
||||
canvas. Recheck desktop readability after enlarging a canvas.
|
||||
|
||||
When several crossing/corridor diagnoses involve the same nodes, consider their
|
||||
placement together before adding route controls. Apply one coherent repair and
|
||||
validate it; independent label nudges cannot fix a shared layout bottleneck.
|
||||
Compare diagnostics by code, subject, and stage instead of total count alone.
|
||||
|
||||
### Repair order
|
||||
|
||||
1. Fix missing/invalid `meta.quality_profile` and schema errors.
|
||||
2. Fix node overlap or out-of-range placement.
|
||||
3. Fix edge-through-node and endpoint-direction errors.
|
||||
4. Fix crossings, ambiguous corridors, border runs, excessive detours, and route rhythm.
|
||||
5. Fix label-to-node, label-to-label, then label-to-route clearance.
|
||||
6. Fix labels that leave the canvas: move the label with `labelAt`/`labelDx`/`labelDy`/`labelSegment`, or widen `meta.viewBox`. Suggested `labelDx`/`labelDy` values replace the authored field; they are not added to it.
|
||||
|
||||
Run `validate` after every edit. Consume `diagnostics[]` by stable `code`, exact `subject`, measured `evidence`, and `supportedFixes`. If the diagnostic gives `labelAt`, use that point instead of estimating another offset.
|
||||
|
||||
## Mode placement
|
||||
|
||||
### Architecture
|
||||
|
||||
Choose overview or mechanism detail using [Composition and meaning](authoring-defaults.md#composition-and-meaning). Use one obvious primary reading path, which may step across meaningful rows when the requested topology needs room. Keep the overview readable at its chosen abstraction; expand implementation details when they answer the reader's question. Group only real ownership, trust, process, or deployment boundaries. Boundaries do not replace relationships.
|
||||
|
||||
Grid placement is preferred when the schema supports it. Free positions are appropriate for a bounded exception, not for prose-level coordinate planning. Keep external actors outside the system boundary when that is factually true.
|
||||
|
||||
### Workflow
|
||||
|
||||
Lanes express responsibility or phase. Columns `0..5` express logical
|
||||
progression. Start new workflows on `readable-v2`; retain `fixed-v1` only for
|
||||
legacy geometry compatibility. Keep the happy path monotonic, preserve semantic
|
||||
edge labels, and route retries and exception returns outside the main lane
|
||||
corridor.
|
||||
|
||||
#### Workflow viewport repair
|
||||
|
||||
When `viewer/viewport-overflow` includes `workflowLanes`, inspect the tallest
|
||||
rendered frames and their node span before changing the source. Measurements
|
||||
are CSS pixels; space above/below nodes includes lane titles and routing, so it
|
||||
is not a removable-space budget. Frame IDs identify rendered lane indices.
|
||||
|
||||
Run `validate workflow <source.json> --layout-json` and match those frames to
|
||||
source lanes and nodes. Check whether many steps share the last logical column
|
||||
and use large `yOffset` values. Readable-v2 currently reserves symmetric space
|
||||
around offsets and shares the base content height between lanes, so increasing
|
||||
one offset can enlarge otherwise sparse lanes.
|
||||
|
||||
Where the source's ownership and explicit geometry permit, redistribute steps
|
||||
across logical columns and meaningful lanes, keeping the main path monotonic.
|
||||
Preserve every required node, relationship, label and semantic check. If ownership
|
||||
or absolute pins prevent reflow, report that constraint instead of merging lanes
|
||||
or moving pins automatically. Validate the changed JSON, deliver a fresh HTML,
|
||||
then rerun browser checks and inspect the first screen; a static pass alone does
|
||||
not settle viewport fit. These are repair directions, not guaranteed coordinates.
|
||||
|
||||
### Sequence
|
||||
|
||||
Participants are ordered by conversation role. Messages own their vertical order. Use return/async/security variants for meaning, not decoration; sequence does not use Automatic Port Spread.
|
||||
|
||||
### Dataflow
|
||||
|
||||
Stages express transformation or custody. Rows separate parallel streams. Label only data contracts, classifications, or cross-boundary movement that is not obvious.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
Schema v2 (new diagrams): each populated lane is one row, `main` first,
|
||||
`terminal` last, others in `lanes[]` order. `col` `0..4` is one shared x grid,
|
||||
so a state placed in the column of the state it leaves gets a straight vertical
|
||||
transition. Every transition, including the main path, is authored; there is no
|
||||
implied rail. The renderer sizes the canvas, widens a column gap for a
|
||||
same-row label, and routes automatic transitions orthogonally through row gaps.
|
||||
Keep labels short: a gap carrying several parallel lines has little room.
|
||||
|
||||
Schema v1 (legacy): main phases use columns `0..4`; event and terminal bands
|
||||
use columns `0..2`, and event/terminal column `N` aligns with main column
|
||||
`N + 2`. Every lane other than `main` and `terminal` shares one middle band;
|
||||
states in the same column there need distinct `yOffset` values.
|
||||
|
||||
In both versions a recoverable failure needs a real transition back to an
|
||||
active state. A card saying “retry” is not topology.
|
||||
|
||||
## Repository evidence
|
||||
|
||||
When the diagram must reflect real code, inspect repository entrypoints,
|
||||
runtime boundaries, storage, transports, and deployment configuration before
|
||||
authoring. Record only evidence you actually verified. `--repo-root <path>` is
|
||||
accepted by `render`, `validate`, `deliver`, and `preview` for every diagram
|
||||
type, by architecture `compare`, and by workflow `migrate`; every mode verifies `meta.repository` and
|
||||
node `sources` the same way. Migrating a source-backed workflow requires the same
|
||||
`--repo-root` so its candidate is verified before replacing the destination.
|
||||
Never infer runtime causality from file proximity
|
||||
or naming alone.
|
||||
|
||||
Declare `meta.repository.url` and one full 40-character `revision`, then attach
|
||||
`sources` to the mode's node collection (Architecture `components[]`, Workflow
|
||||
and Data Flow `nodes[]`, Sequence `participants[]`, Lifecycle `states[]`) with
|
||||
repository-relative `path`, optional `line`, `end_line`, and `label`.
|
||||
Verification reads blobs at that commit, independently of working-tree edits.
|
||||
Verification ignores local Git replacement refs, including those selected by
|
||||
`GIT_REPLACE_REF_BASE`, and always reads the original objects at the pinned SHA.
|
||||
It does not change repository configuration or delete replacement refs.
|
||||
A matching local origin, available commit, bounded path,
|
||||
blob, and valid line range are required in every link mode. Verification is
|
||||
local and makes no remote requests; it establishes neither public availability
|
||||
nor the current reader's access rights.
|
||||
|
||||
`link_mode` defaults to `web`. GitHub and Gitee HTTPS repository URLs generate
|
||||
revision-pinned links; their public hosts select the provider automatically.
|
||||
Optional `provider: "github"` or `"gitee"` must agree with the host. Existing
|
||||
GitHub declarations and default delivery receipt fields remain compatible.
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://gitee.com/team/service",
|
||||
"revision": "0123456789abcdef0123456789abcdef01234567",
|
||||
"provider": "gitee"
|
||||
}
|
||||
```
|
||||
|
||||
For an internal or unsupported forge, select `link_mode: "local-only"`. The
|
||||
Viewer retains SRC markers, searchable file paths, line ranges, and revision
|
||||
labels without repository or source hyperlinks. The evidence receipt adds
|
||||
`linkMode: "local-only"`. `url` remains required as the expected origin identity;
|
||||
local-only disables links, not identity verification. A repository without an
|
||||
origin is not supported.
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "http://git.internal:3000/Platform/Services/service",
|
||||
"revision": "0123456789abcdef0123456789abcdef01234567",
|
||||
"link_mode": "local-only"
|
||||
}
|
||||
```
|
||||
|
||||
Local-only accepts HTTP(S), `git@host:path`, and `ssh://git@host[:port]/path`
|
||||
addresses, including nested namespaces. Declare a credential-free address;
|
||||
HTTP(S) credentials on the checkout's origin are ignored for identity and
|
||||
redacted from diagnostics. Hostnames compare case-insensitively; repository
|
||||
paths retain case except for the existing GitHub behavior. A trailing slash
|
||||
normalizes away. Only GitHub and Gitee normalize a terminal `.git` and match
|
||||
standard HTTPS/443 with Git SSH/22. For other hosts, use the actual clone address:
|
||||
transport, port, `.git` suffix, and remote-relative versus absolute paths must
|
||||
match. For example, `git@host:Team/repo` differs from
|
||||
`ssh://git@host/Team/repo`; `git@host:/Team/repo` matches the latter. SCP-style
|
||||
paths preserve literal percent escapes, while URI paths decode them. SSH host
|
||||
aliases and forge-specific browse/clone prefixes are not guessed.
|
||||
GitLab/Gitea/Forgejo/Bitbucket web links are not implemented in this version;
|
||||
use local-only until a tested link provider is available. Unknown web providers
|
||||
fail with a diagnostic rather than emitting a guessed link.
|
||||
|
||||
## Hand-placed fallback
|
||||
|
||||
Use only when no renderer can run. Start from `assets/template.html`, keep semantic CSS classes, preserve the inline SVG/accessibility structure, and run the delivery visual checklist. Never introduce inline literal colors that break dark/light parity.
|
||||
|
||||
## Node icons
|
||||
|
||||
For domain-specific diagrams, set an optional `icon` on architecture components,
|
||||
workflow/dataflow nodes, sequence participants, or lifecycle states. Choose
|
||||
`calendar`, `clock`, `person`, `briefcase`, `flag`, or `moon` for everyday concepts;
|
||||
the complete catalog (including existing technical and lifecycle symbols) is
|
||||
`common.schema.json#/$defs/nodeIcon`. Use `icon: "none"` to hide the corner symbol.
|
||||
Omitting `icon` keeps the type-based default. These inline SVG symbols are
|
||||
renderer-owned and export with the diagram; URLs and raw SVG are not accepted.
|
||||
|
||||
Icon selection changes only the corner symbol. The node's type still determines
|
||||
color and semantic grouping; brand marks remain independent. For a holiday
|
||||
workflow, pair `type: "backend", icon: "calendar"` with
|
||||
`meta.legend.entries.backend.label: "假期"`, and use `icon: "briefcase"` plus
|
||||
an appropriate legend label for make-up work. Keep the node label meaningful:
|
||||
icons are decorative and are hidden from assistive technology.
|
||||
|
||||
See [holiday planning](../examples/holiday-planning.workflow.json) for a complete workflow example.
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Authoring defaults
|
||||
|
||||
Read once before writing a fresh candidate. An existing frozen candidate going straight to `finalize` needs this only if repair changes its authorship.
|
||||
|
||||
## Composition and meaning
|
||||
|
||||
For Architecture, default to a system overview led by the main user journey unless the user asks for a narrower mechanism, module map, or deployment topology. Group cooperating roles in accurately named subsystems when that still explains the requested interaction. Separate roles when grouping would hide control ownership, a trust or persistence boundary, lifecycle behavior, or another distinction the reader asked about. Keep secondary and opt-in capabilities in concise sourced notes unless their path matters to the requested question. Name a deliberately narrower scope in the title.
|
||||
|
||||
Preserve every requested responsibility, relationship direction, protocol, and behavior-changing condition. Show approval, authorization, and state-transition gates on the affected node or relationship; a card alone cannot qualify an otherwise unconditional arrow. Keep source evidence with each asserted claim. Boundaries express real isolation, ownership, runtime, or persistence facts. Cards answer additional reader questions; they do not replace required topology. There is no node, edge, source, card, or boundary quota. If an authored overview needs regrouping after failure, use [Composition repair](authoring-contract.md#composition-repair); user-supplied or agreed topology remains fixed.
|
||||
|
||||
Relationship labels carry meaning. Give them clear space and preserve action, protocol, direction, async behavior, or cross-boundary meaning. A label may start absent only when both endpoints already fully imply it; a collision calls for spacing or routing repair. See [Label repair](authoring-contract.md#label-repair) when measured evidence reports a collision.
|
||||
|
||||
## Layout and routing
|
||||
|
||||
Place Architecture nodes by their actual connections before assigning coordinates; the router cannot rearrange boxes, so placement decides whether lines stay straight. Classify each relationship first, then place:
|
||||
|
||||
- **Main path**: the reader's main journey, neighbors adjacent in reading order. Let a medium path step through meaningful rows instead of making a shallow horizontal strip.
|
||||
- **Branch or store**: directly above or below the node that owns, reads, or writes it, centered on that node so the edge is one straight segment. Keep all stores and branches of one row on the same side of it.
|
||||
- **Return** (back to an earlier main-path node): put its source on the side of the main path with no branches or stores, so it runs through an empty corridor instead of crossing them.
|
||||
- **Second entrance** into a node that already has an incoming edge: place the new source so it reaches that node from another side, usually directly below or above it.
|
||||
- **Fan-out**: a side with k relationships needs at least `32 + 14 × (k − 1)`px (four need 74px). Spread a hub's counterparts over two or three sides, or enlarge the hub. Center a parent on its children and align a child with its only parent.
|
||||
|
||||
Before writing positions, trace each non-main relationship: its straight or one-bend corridor must not pass another node or cross another relationship. If it does, move the endpoint that is off the main path. Start the main actor and its first connected step together near the canvas origin; use content rows for vertical rhythm. Omit `meta.viewBox` for a fresh Architecture so the Reader measures intrinsic height. Keep supplied fixed geometry authoritative.
|
||||
|
||||
Start with automatic routes and endpoint sides. Pin a side only for a necessary branch, return, or supplied geometry. Reserve `via`, `channelX`, `channelY`, and label coordinates for measured defects. Before writing positions, budget each labeled main-path edge at `6.5px × ASCII units + 21px` of clear gap, counting CJK as two units; use its own label length, not a row-wide fixed gap. Size Architecture sublabels for their preferred 9px text at `5.4px × text units + 8px`, with CJK counting twice; keep supporting copy concise without dropping required facts. Do not trade readability or meaning for fewer crossings. The [Geometry reference](authoring-contract.md#executable-geometry-rules) has measured spacing, port, canvas, and route rules for a diagnosed layout problem.
|
||||
|
||||
## Evidence and schema
|
||||
|
||||
For a real repository, follow [Repository authoring](repository-authoring.md) while inspecting source. Freeze its credential-free origin and 40-character commit in `meta.repository`, attach inspected repository-relative `sources` to each key semantic node, and pass `--repo-root` to the first `finalize`. Each reference proves only the fact visible at that location. Follow material relationships and conditions to their actual source; do not reuse a startup citation as protocol or persistence evidence.
|
||||
|
||||
Examples show field shape, not legal values or source facts. Read the mode schema and shared definition before adding a field, enum, or constrained text. In particular, inspect Architecture boundary kinds. Keep longer evidence in a card while retaining the fact. See [Schema lookup](authoring-contract.md#schema-lookup) for details.
|
||||
|
||||
## Presentation and modes
|
||||
|
||||
Use one primary authored language from the user's choice or the request/conversation. Set `meta.locale` for built-in English (`en`) or Simplified Chinese (`zh-CN`); for other languages, including Spanish (`es`), supply `meta.translations` with reusable UI translations, or disclose the fixed Viewer UI and `<html lang>` English fallback. Keep exact product, code, protocol, command, API, and environment names while localizing surrounding explanation. See [Language consistency](authoring-contract.md#language-consistency) for bilingual cases.
|
||||
|
||||
Omit `meta.visual_preset` for classic, `meta.subtitle` for a title-only header, `meta.legend` for truthful auto, and `meta.engineering_profile` for an ordinary system overview. Explicit styles and a subtitle require a user request. Use legend or deployment ownership under the [legend](authoring-contract.md#legend-contract) or [engineering profile](authoring-contract.md#engineering-profile-default) contracts. Branding is optional and explicit when a node names a real product; [Brand marks](brand-marks.md) gives lookup and capture rules for that branch. Never let a badge replace semantic type, label, or relationship facts. Set required `meta.output` to a portable POSIX-relative `.html` path within the working directory; see [Output path contracts](delivery-contract.md#output-path-contracts) for native path exceptions.
|
||||
|
||||
For new Workflow use schema v2, preserving v1 for a fixed legacy source; use its [layout contracts](../renderers/workflow/README.md#layout-contracts) when lane or group geometry needs detail. For Sequence start with fixed columns; use `spread` when a wide viewBox leaves unused horizontal space or meaningful labels need width. For new Lifecycle use schema v2: every lane is its own row (`main` first, `terminal` last) and `col` `0..4` is the same x in every row, so place an interruption or exit in the column of the state it leaves; author the main path as transitions, omit `viewBox`, and keep transition labels short; a recoverable failure needs a real transition back. Read [Mode placement](authoring-contract.md#mode-placement) when a mode-specific placement or viewport problem needs more detail.
|
||||
|
||||
`finalize` performs the browser gate. Keep the complete drawing comfortably readable on desktop, with zero horizontal overflow. Use meaningful vertical rows and intrinsic-height page scroll when necessary. The 6px projected-text check is a failure floor; at 1440px, aim for ordinary context text around 7.5px or larger. See [Automated browser evidence](delivery-contract.md#automated-browser-evidence) when viewport evidence fails.
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
# Brand marks
|
||||
|
||||
Use a brand mark only when a real product, provider, model family, channel, or
|
||||
service identity helps the reader. Semantic `type` still explains what the node
|
||||
does; `brand` explains whose product it is.
|
||||
|
||||
## Agent decision path
|
||||
|
||||
1. Search the built-in catalogue when the request names a recognizable brand:
|
||||
|
||||
```bash
|
||||
node bin/archify.mjs brands "Claude" --json
|
||||
```
|
||||
|
||||
2. Put the returned canonical ID in the node, participant, or state:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "planner",
|
||||
"type": "backend",
|
||||
"label": "Claude",
|
||||
"brand": "claude"
|
||||
}
|
||||
```
|
||||
|
||||
3. If there is no catalogue match and the user supplied the official website,
|
||||
capture its icon explicitly:
|
||||
|
||||
```bash
|
||||
node bin/archify.mjs brands capture "https://partner.example.com" --json
|
||||
```
|
||||
|
||||
Put the command's digest-pinned `brand` value in the authored node:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "partner",
|
||||
"type": "external",
|
||||
"label": "Partner portal",
|
||||
"brand": {
|
||||
"url": "https://partner.example.com",
|
||||
"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. If there is no match and no user-provided URL, omit `brand`. Do not invent a
|
||||
URL or silently assign a visually similar company.
|
||||
|
||||
Known-brand URLs resolve to the bundled vector instead of using the network.
|
||||
For discovered icon `href` attributes, capture decodes the basic named references
|
||||
`amp`, `quot`, `apos`, `lt`, `gt` (and their defined uppercase aliases), plus
|
||||
decimal and hexadecimal numeric references, once before URL resolution. Thus
|
||||
`/icon.png?v=1&size=32` requests `/icon.png?v=1&size=32`. URL percent escapes
|
||||
remain intact; nested escapes are not decoded recursively. This bounded decoder
|
||||
does not add a general HTML parser or support every named HTML entity.
|
||||
HTML reads stop at an explicit head ending outside comments, raw-text elements
|
||||
and quoted attributes, including when those tokens span network chunks. The
|
||||
256 KiB head limit and capture deadline still apply; a larger body after the
|
||||
head is not read for icon discovery.
|
||||
Unknown URL capture accepts only bounded raster image formats, blocks
|
||||
credentials, nonstandard public ports, and private or link-local destinations,
|
||||
uses bounded concurrency and one total deadline, and returns the captured
|
||||
content digest. Later render and validate operations require that exact digest;
|
||||
blocked, unavailable, changed, oversized, or unsafe content fails closed instead
|
||||
of silently changing the artifact.
|
||||
|
||||
Page, icon and redirect requests send `Accept-Encoding: identity`. Capture does
|
||||
not decompress response bodies: a successful response declaring another content
|
||||
coding is closed and rejected explicitly. This keeps the existing byte limits
|
||||
and pinned digest tied to the unencoded representation. A later usable icon may
|
||||
still succeed; otherwise an encoding error is retained instead of being hidden
|
||||
by an unrelated favicon 404.
|
||||
|
||||
The final artifact never fetches a brand asset when opened. Preset vectors and
|
||||
digest-verified captured site icons remain embedded in SVG, PNG, WebP, JPEG,
|
||||
Share Card, and WebM exports.
|
||||
|
||||
Use `node bin/archify.mjs brands --json` to inspect all canonical IDs, aliases,
|
||||
categories, domains, and provenance. Current categories cover AI, cloud,
|
||||
engineering, data, collaboration, business systems, channels, languages, and
|
||||
frameworks.
|
||||
+588
@@ -0,0 +1,588 @@
|
||||
# Delivery contract
|
||||
|
||||
## Failed finalize and candidate repair
|
||||
|
||||
`finalize` stops at the first non-passing gate. Use compact stdout or `evidence.summaryReceipt`; read its full sidecar only when the summary lacks evidence needed for a coherent repair. A receipt with four artifact checks is basic validation, not showcase acceptance: require all nine checks, zero composition errors, and zero warnings. Fix `meta.quality_profile` and schema errors before geometry.
|
||||
|
||||
For a validation failure, edit the existing JSON in the connected neighborhood named by diagnostics before rerunning a command. Preserve requested semantics, meaningful labels, source evidence, and fixed or agreed topology. Several routes sharing nodes call for one placement repair; read [Architecture layout repair](architecture-layout-repair.md) for that case. Reflow a blocked main path rather than nudging unrelated labels. Keep unrelated geometry when its composition already reads clearly. Use `--layout-json` before editing only when compact evidence lacks needed measurements. Workflow v2 uses its stable compiler receipt, not solver internals, as authoring evidence.
|
||||
|
||||
After the edit, rerun the complete `finalize` command with `--quality showcase` and, for repository-backed work, `--repo-root <repo-root>`. If the output path already has browser evidence from another candidate, use a fresh `--out-dir <output-stem>.review-<revision>` for both the new `finalize` and any `visual-check`. Omit an earlier `--candidate-sha256` after editing because it binds the previous candidate. Compare diagnostics by code, subject, stage, and evidence, never by declining error count alone. If an issue survives two focused repairs, inspect measured geometry or the relevant contract; after one evidence-based retry, report the concrete gap.
|
||||
|
||||
Use standalone `validate` only for focused diagnosis, passing `--repo-root` for repository-backed work. Its passing receipt marks `candidateFrozen: true`; run `nextAction.arguments`, replacing only `<output.html>`, without editing, revalidating, or rereading the candidate. Retry later environmental or evidence failures against those frozen bytes. A measured reason to edit creates a new candidate and calls for the complete `finalize` without the old hash.
|
||||
|
||||
## Validate and deliver
|
||||
|
||||
`render` and direct renderer entry points print classified authoring failures
|
||||
to stderr as readable diagnostics and exit 1. Input read/JSON parse failures
|
||||
use `input/read` or `input/json-parse`; output filesystem failures use
|
||||
`output/write` and identify the output path. Schema and layout failures keep
|
||||
their existing rule codes. Use the advertised `validate --json` or
|
||||
`deliver --json` interface for a machine receipt; `render` has no `--json` flag.
|
||||
Unexpected implementation failures retain debugging information in human
|
||||
mode and remain `internal/unclassified` in machine receipts.
|
||||
|
||||
Each delivered output has two artifact-specific metadata paths. When an output
|
||||
stem is too long for those derived filenames, Archify shortens it and appends a
|
||||
stable hash:
|
||||
|
||||
- `<output-stem>.delivery.json` records the latest completed attempt.
|
||||
- `<output-stem>.delivery-pending.json` is the recovery journal for an attempt
|
||||
in progress.
|
||||
|
||||
For a literal artifact stem that already matches Archify's reserved bounded-name
|
||||
marker, a pre-namespace raw provenance sidecar remains a read fallback when the
|
||||
encoded sidecar is absent. A pre-namespace raw pending journal is an independent
|
||||
fail-closed barrier: it blocks checks and redelivery even when an encoded pending
|
||||
journal also exists, and neither journal is silently replaced.
|
||||
|
||||
One directory-wide `.archify-delivery-lock.json` serializes every delivery that
|
||||
resolves into the same physical output directory. This deliberately prevents
|
||||
case, Unicode-normalization, Windows short-name, and symbolic-link aliases from
|
||||
creating independent owners for one filesystem location. The tradeoff is that
|
||||
deliveries to different artifact names in one directory also run serially;
|
||||
provenance and pending journals remain artifact-specific.
|
||||
|
||||
For migration safety, Archify also detects and preserves a legacy
|
||||
`<output-stem>.delivery-lock.json` beside the requested artifact. An existing
|
||||
legacy entry is a fail-closed recovery barrier. While a new delivery owns the
|
||||
directory mutex, it also holds temporary legacy-format fences for the requested
|
||||
spelling and an existing artifact's physical target spelling. It acquires the
|
||||
directory lock first, then all required compatibility fences before writing a
|
||||
journal or artifact, and removes the directory lock before those fences during
|
||||
release. This blocks an older Archify binary using
|
||||
either known spelling from entering the delivery. A legacy fence whose raw
|
||||
HEAD-era filename exceeds the host component limit is omitted because the old
|
||||
binary could not create that lock or deliver that artifact on the host either.
|
||||
|
||||
`deliver` acquires the lock by exclusive `open(..., "wx")` before creating or
|
||||
replacing the recovery journal. A successful exclusive create yields an
|
||||
internal opaque ownership capability bound to that attempt. Journal creation,
|
||||
failed-provenance recording, pair commit, rollback, journal finalization, and
|
||||
lock release each verify the current capability inside the operation that
|
||||
would mutate shared state. A rejected contender does not create a journal or
|
||||
write failed provenance.
|
||||
|
||||
An existing directory or legacy lock is handled without automatic recovery:
|
||||
|
||||
| Observed lock state | Required `deliver` result |
|
||||
| --- | --- |
|
||||
| No directory entry | Attempt exclusive creation; only its success grants ownership. |
|
||||
| Valid schema-v1 lock whose PID is running, or whose death cannot be established | Exit 1 with `delivery/concurrent-attempt`; preserve every shared path. |
|
||||
| Valid schema-v1 lock whose PID is known to have exited | Exit 1 with `delivery/lock-stale`; preserve the lock, artifact, journal, and current provenance exactly. |
|
||||
| Unreadable, malformed, symlink, dangling symlink, directory, or other non-regular lock entry | Exit 1 with `delivery/lock-invalid`; preserve the entry and every other shared path. |
|
||||
| An acquired capability no longer matches the current lock or journal | Exit 1 with `delivery/ownership-lost`; stop all shared-path mutation. |
|
||||
| The matching owner cannot remove its lock | Exit 1 with `delivery/lock-release`; preserve the lock. |
|
||||
|
||||
A `delivery/lock-stale` diagnostic identifies the absolute output and lock
|
||||
paths plus the original PID and receipt ID. Recovery is deliberately explicit
|
||||
and serial: stop all delivery attempts for that physical output directory,
|
||||
confirm that no active delivery owns it and that the reported stale entry has
|
||||
not been replaced, remove only the reported lock, then rerun `deliver`. Do not
|
||||
remove an artifact, current provenance, or pending journal as part of
|
||||
stale-lock recovery.
|
||||
|
||||
The lock protocol targets Node.js 18 or later on a local filesystem with
|
||||
cooperating Archify processes. PID, receipt, and file-identity comparisons are
|
||||
defensive checks, not an atomic compare-and-swap. Compatibility fences cover
|
||||
the requested spelling and an existing physical-target spelling; they cannot
|
||||
enumerate arbitrary hard-link names or previously unknown filesystem aliases,
|
||||
so mixed-version delivery through such aliases remains out of scope. This
|
||||
contract does not claim distributed-lock correctness on NFS, SMB, or other
|
||||
network filesystems, and it cannot prevent an external process that ignores
|
||||
the protocol from replacing shared paths.
|
||||
|
||||
Every no-clobber HTML publisher (`render`, `deliver`, `compare`, and `preview`)
|
||||
captures the requested directory entry, canonical write slot, physical parent,
|
||||
and existing target type, device/inode identity, and mode before staging, then
|
||||
revalidates that snapshot immediately before replacement. An existing write
|
||||
target must be a regular file with exactly one hard-link name. A target with
|
||||
multiple hard-link names fails closed with `output/target-hardlinked`: replacing
|
||||
the requested name cannot update unknown sibling names as one publication.
|
||||
Hard links remain supported for read identity and input/alias collision checks;
|
||||
they are unsupported only as write targets. A symbolic link to a single-link regular
|
||||
file remains supported: publication preserves the symbolic-link entry and
|
||||
applies the same protocol to its resolved target. Directory, FIFO, socket,
|
||||
device, changing mode, new claimant, and indeterminate identity cases fail
|
||||
before replacement.
|
||||
|
||||
Publication is no-clobber and recoverable, not crash-atomic replacement of an
|
||||
existing target. To avoid overwriting a claimant that appears after the last
|
||||
identity check, Archify first retains the bound old file in a private recovery
|
||||
backup, removes the public name through identity-bound quarantine, and then
|
||||
creates the new public name with an exclusive hard link. A caught failure rolls
|
||||
back when the public slot and recovery binding still permit it. A process
|
||||
interruption between those namespace operations can instead leave the public
|
||||
path absent while the verified previous bytes remain in an adjacent private
|
||||
recovery backup. Single-artifact publication records the original slot/alias
|
||||
identity and backup inode, mode, SHA-256, and byte count in private
|
||||
`.archify-remove-*/publication-recovery-v1.json`, beside `previous`. To make a
|
||||
specific interrupted publication visible again, stop concurrent writers and run:
|
||||
|
||||
```bash
|
||||
node bin/recover-output.mjs /absolute/path/to/.archify-remove-<id> --json
|
||||
```
|
||||
|
||||
This is explicit recovery, not a directory scanner. Before linking, the helper
|
||||
checks for a changed parent or alias, an altered/hardlinked record or backup,
|
||||
digest or inode mismatch, and any existing public target. It restores only by
|
||||
no-clobber hard link, so a new claimant is preserved rather than overwritten;
|
||||
it never recursively removes unknown entries. A completed recovery is
|
||||
idempotent. The record is evidence to be independently verified, not an
|
||||
authority to restore arbitrary private bytes: the helper accepts it only from
|
||||
the recorded generated child of the original physical target parent, with the
|
||||
same directory identity. Name the exact directory reported by the interrupted
|
||||
process and inspect an uncertain record manually. A non-cooperating process can
|
||||
still swap pathnames after those checks and before Node.js `linkSync`; Node does
|
||||
not expose a descriptor-bound link operation. Post-link identity verification
|
||||
then fails closed and retains recovery evidence, rather than claiming recovery
|
||||
or deleting an uncertain name. If recovery itself is interrupted after the
|
||||
link, the old public bytes and private backup can both remain; a later recovery
|
||||
run preserves the public target and needs explicit operator resolution. The
|
||||
record is fsynced before the old public name is retired on platforms supporting
|
||||
directory sync, and the tested guarantee is recovery after a killed process;
|
||||
this is not a claim of power-loss, storage-controller, NFS, or SMB durability.
|
||||
Paired flows retain their backup in private transaction staging. For `deliver`,
|
||||
the pending journal and lock keep strict checkers fail-closed. The portable
|
||||
Node.js filesystem API has no pathname
|
||||
compare-and-swap that both replaces an existing name atomically and refuses to
|
||||
overwrite a late claimant: `rename` would close the visibility gap only by
|
||||
overwriting that claimant.
|
||||
|
||||
After ownership is established, `deliver` creates the journal before rendering
|
||||
and keeps it through the recoverable HTML/sidecar pair commit. It removes the
|
||||
journal only after that commit completes. A validation, render, or pair-commit
|
||||
failure, or a process interruption, may therefore leave a journal. The journal
|
||||
is a safety barrier: `check`, `browser-check`, and `visual-check` fail closed when any directory
|
||||
entry exists at the journal or lock path, including an unreadable file,
|
||||
symlink, or dangling symlink. Run deliveries targeting the same physical output
|
||||
directory serially; one attempt must finish or be recovered before another
|
||||
begins.
|
||||
|
||||
A successful sidecar has `schemaVersion: 1`, `status: "current"`,
|
||||
`command: "deliver"`, a unique `receiptId`, the diagram `type`, an absolute
|
||||
`input` path, an absolute `output` path matching the inspected
|
||||
artifact, and specification/artifact SHA-256 and byte counts. Checkers treat a
|
||||
missing, malformed, unsupported, or inconsistent field as invalid. They also
|
||||
reject a sidecar symlink, including a dangling one. A checker binds provenance
|
||||
to the artifact bytes it actually checks and verifies that binding again before
|
||||
reporting success; a concurrent byte change fails. The provenance directory
|
||||
entry itself must be a single-link regular file: `deliver` and strict check fail
|
||||
closed with `delivery/provenance-hardlink-unsupported` when it has another hard
|
||||
link, without scanning for or guessing the sibling name.
|
||||
|
||||
If a currently verified owner fails after an older HTML exists, Archify writes
|
||||
a new `status: "failed"` sidecar and leaves the journal until recovery is
|
||||
complete. An unreadable old HTML does not prevent that marker; its artifact hash
|
||||
and byte count may be absent. If the sidecar is locked or otherwise unwritable,
|
||||
Archify keeps the prior sidecar rather than deleting evidence, and the journal
|
||||
prevents checkers from trusting it. A rejected concurrent, stale, or invalid
|
||||
lock attempt does not write failed provenance. If ownership is lost, Archify
|
||||
reports `delivery/ownership-lost`, does not overwrite or remove the successor's
|
||||
artifact, provenance, journal, or lock, and does not claim recorded failed
|
||||
provenance; a failure receipt may report `provenance: "unrecorded"`. If every
|
||||
metadata path is unavailable, the same unrecorded status applies; no tool can
|
||||
preserve that fact across processes. Restore metadata-path access and complete
|
||||
a successful `deliver` before trusting the output.
|
||||
|
||||
Artifacts with no sidecar, journal, or lock remain supported for backward
|
||||
compatibility and for the lower-level `render` command. Their checker receipts
|
||||
report `provenance: "unknown"`; use `--require-provenance` to turn that state
|
||||
into a non-zero failure when the workflow requires a successfully delivered
|
||||
artifact:
|
||||
|
||||
```bash
|
||||
node bin/archify.mjs check <output.html> --require-provenance
|
||||
node bin/archify.mjs browser-check <output.html> --json --require-provenance
|
||||
```
|
||||
|
||||
## Output path contracts
|
||||
|
||||
Archify intentionally separates durable authored paths from command-line paths:
|
||||
|
||||
- Required authored `meta.output` is a portable POSIX-relative path such as
|
||||
`reports/diagram.html`. It uses `/`, ends in a non-empty `.html` basename,
|
||||
and cannot contain an absolute or drive-relative prefix, URI, backslash,
|
||||
empty or dot segment, control character, unpaired UTF-16 surrogate, Windows
|
||||
alternate-data-stream separator or invalid filename character, trailing dot
|
||||
or space, DOS device name, or a component over either the 255-byte UTF-8 or
|
||||
255-code-unit UTF-16 limit. It resolves from the current working directory
|
||||
and must remain physically inside that directory, with an `.html` target,
|
||||
after symbolic links are followed. The durable output/archive profile also
|
||||
conservatively rejects a Windows 8.3 short-name shape such as `PROGRA~1`;
|
||||
descriptive repo/Git POSIX paths use a separate profile and are exempt.
|
||||
- Explicit CLI output arguments use the active host's native syntax. They may
|
||||
be relative or absolute, use native separators, and resolve outside the
|
||||
current working directory. On Windows, ordinary drive-absolute, UNC, and
|
||||
relative paths (including ordinary `.` and `..` navigation) are supported.
|
||||
A system-resolved 8.3 spelling of an existing file or directory is accepted
|
||||
when Archify can prove its physical identity; this native alias support does
|
||||
not relax the durable output/archive profile's 8.3-shaped-name rejection.
|
||||
Extended-length paths are limited to raw backslash-only `\\?\C:\...` and
|
||||
`\\?\UNC\server\share\...` forms without dot segments; device namespaces,
|
||||
malformed roots, drive-relative paths such as `C:file.html`, current-drive
|
||||
roots such as `\file.html`, alternate data streams, reserved device names,
|
||||
invalid or trailing filename characters, and overlong components fail
|
||||
closed. POSIX CLI paths retain POSIX filename rules rather than inheriting
|
||||
Windows spelling restrictions. Every host rejects NUL, unpaired surrogates,
|
||||
and components that exceed its supported bound.
|
||||
|
||||
These contracts are not interchangeable: an explicit CLI output does not hide
|
||||
an invalid durable `meta.output` (including a missing value), and `validate`
|
||||
checks the authored output even when it does not publish to that path. A
|
||||
workflow v1-to-v2 migration may explicitly receive a portable durable
|
||||
replacement through `migrate workflow old.json new.json --to-schema 2 --output
|
||||
reports/diagram.html`; that value is written only to its separate verified v2
|
||||
destination. This migration-candidate exception does not repair the source or
|
||||
bypass any non-output schema or compiler error. For every other repair, add a
|
||||
portable POSIX-relative `.html` path to `meta.output`; no schema-version change
|
||||
is otherwise required.
|
||||
|
||||
Run `finalize` directly on a complete first candidate and after every repair edit. Its embedded validation checks the candidate before delivery; use standalone `validate` only for focused diagnosis. After an edit, omit any earlier `--candidate-sha256`, which binds the previous candidate. CLI HTML output paths must end in
|
||||
`.html`, including after symbolic-link resolution. Compare receipt paths must
|
||||
end in `.json`. A type mismatch fails before writing with
|
||||
`output/cli-extension` or `output/cli-resolved-extension`. These checks prevent
|
||||
accidental file-type overwrites; they do not sandbox explicit CLI directories
|
||||
or prevent replacement of an existing artifact of the expected type.
|
||||
|
||||
Use final verified delivery only after the candidate is frozen:
|
||||
|
||||
```bash
|
||||
node bin/archify.mjs deliver <type> <candidate.json> <output.html> --quality showcase --json
|
||||
```
|
||||
|
||||
Deliver reads the specification once, writes those exact bytes to a private same-directory candidate snapshot, renders that snapshot, runs the complete artifact checker, and only replaces the target after all artifact checks pass. The JSON receipt includes SHA-256 and byte counts for both `specification` and `artifact`.
|
||||
|
||||
For the ordinary agent handoff path, prefer the finalizer:
|
||||
|
||||
```bash
|
||||
node bin/archify.mjs finalize <type> <candidate.json> <output.html> --quality showcase --json
|
||||
```
|
||||
|
||||
`finalize` invokes verified `deliver` once, reuses its embedded showcase
|
||||
validation result, then runs strict `check --require-provenance` and
|
||||
`browser-check --require-provenance`. It stops at the first failed or skipped stage
|
||||
and preserves that stage's full receipt. Its stdout is one compact JSON
|
||||
object with gate statuses, bounded actionable diagnostics, artifact identity,
|
||||
and evidence paths. The same compact object is written atomically to
|
||||
`<output-stem>.finalize-summary.json`; use that file for normal failure repair.
|
||||
Complete stage receipts and timings remain available for auditing in
|
||||
`<output-stem>.finalize.json`. With `--out-dir`, both files are written there;
|
||||
`--receipt <path.json>` overrides the full receipt path and derives a distinct
|
||||
`<path>-summary.json`. Read the full receipt only when the compact summary is
|
||||
truncated and its shown subjects and evidence cannot identify a coherent local
|
||||
repair, or when complete audit evidence was explicitly requested. The compact
|
||||
receipt reports `visualReview: "not-requested"`; the automated gate does not create images or require a perceptual reviewer. A compact `visualReviewRecommendation` retains positive crossover and route-detour metrics from the strict check so the author can apply the review escalation below without reading the full receipt. A recommendation does not change the machine exit code or claim that review happened. Its `affectedRoutes` identifies crossing pairs and detours (up to eight of each, with a truncation flag); the full strict-check `composition.routeReview` retains all affected relationships. Use these IDs to trace the routes in the captured default viewport. Detours may include `directCorridorBlockers`, identifying nodes between aligned endpoints. These are geometric review clues, not new validation failures or inferred main-path semantics. For a blocked main path or several tangled routes, follow [Architecture layout repair](architecture-layout-repair.md) and reflow the connected scene before tuning individual sides or labels. Preserve every semantic fact; retain unrelated positions only when their surrounding composition is already accepted.
|
||||
|
||||
For a measured automatic Architecture with a large unused leading area, the
|
||||
compact receipt may include `layoutReviewRecommendation`. Its
|
||||
`composition.leadingSpace` evidence accounts for nodes, boundary titles,
|
||||
routes and labels. Check whether that space is intentional; if not, reposition
|
||||
the connected scene while preserving meaning and user-fixed geometry, then
|
||||
finalize again. This suggestion changes no gate or exit status and requires
|
||||
no screenshot. A fixed canvas or uncertain measurement receives no suggestion.
|
||||
|
||||
A passing finalizer receipt is sufficient evidence for all four gates. Merely
|
||||
naming the gates or requiring each one to pass does not require replaying their
|
||||
standalone commands. Replay an individual command only when the request
|
||||
explicitly requires separate executions or focused failure diagnosis needs it.
|
||||
|
||||
The individual commands remain authoritative and backward compatible. Use
|
||||
them directly for focused diagnosis, recovery, or when only one gate is
|
||||
required. A finalize failure does not relax any gate and does not turn a
|
||||
preserved older artifact into a current successful delivery.
|
||||
|
||||
`finalize` overlaps private Chrome startup with delivery and strict checking.
|
||||
It loads the artifact only after those gates pass and current provenance is
|
||||
verified. The browser gate retains every viewport, theme, and stability check;
|
||||
the browser closes at completion or an earlier failure. Its full stage receipt
|
||||
records `execution: "in-process"` and the equivalent standalone `command` for
|
||||
replay. Use total finalize duration to compare performance because Chrome
|
||||
startup overlaps the earlier stages.
|
||||
|
||||
The pair commit is recoverable, not a claim that two filesystem paths change
|
||||
atomically or are durable across power loss. Journal finalization is part of
|
||||
that commit: a caught failure while verifying or removing the journal rolls
|
||||
back the replaced files when possible and while ownership remains current. If
|
||||
ownership is lost, the old attempt immediately stops renaming, rolling back,
|
||||
finalizing the journal, recording failure provenance, or cleaning up shared
|
||||
paths. Any private staging or recoverable backups remain available and are
|
||||
identified by the failure diagnostic. If restoration fails for another reason,
|
||||
the failure receipt likewise identifies retained backups for recovery. A
|
||||
process interruption can leave the journal, backups, or private staging behind;
|
||||
checkers then fail closed. Follow the reported recovery evidence before rerunning
|
||||
`deliver` serially on the same output. A failed attempt exits non-zero and never invokes an opener; it never
|
||||
authorizes visual evidence collection.
|
||||
|
||||
If exclusive creation succeeds but lock initialization fails, Archify may
|
||||
record failed provenance and remove the incomplete lock only while its
|
||||
capability still identifies that exact entry. A replacement is preserved.
|
||||
Filesystem and cleanup errors are reported separately from an active concurrent
|
||||
delivery. An active, stale, unrecognized, or otherwise preserved lock
|
||||
independently prevents checkers from accepting the prior artifact. Fix the
|
||||
reported filesystem error before retrying, and use another physical output
|
||||
directory if the lock path contains unrelated data.
|
||||
|
||||
Lock release is part of delivery completion. If the artifact/provenance pair
|
||||
has committed and the journal has finalized but the matching lock cannot be
|
||||
removed, `deliver` exits 1 with `delivery/lock-release`, preserves the lock,
|
||||
does not print a success receipt, and does not invoke an opener. The preserved
|
||||
lock keeps strict checkers fail-closed. Only after pair commit, journal
|
||||
finalization, and lock release all succeed may `deliver` exit zero, print its
|
||||
success receipt, or run `--open`.
|
||||
|
||||
Run strict `check` after `deliver` exits zero. Run `browser-check` or optional
|
||||
`visual-check` only after that strict check exits zero. A failed marker,
|
||||
recovery journal, or delivery lock makes every checker fail before accepting
|
||||
the preserved HTML; report the diagnostics and complete a successful recovery
|
||||
delivery before collecting new browser evidence.
|
||||
|
||||
The delivery interface exposes four separate claims:
|
||||
|
||||
1. `deliver` proves deterministic artifact checks and byte identity.
|
||||
2. `browser-check` collects required automated browser evidence from the exact artifact without capturing images.
|
||||
3. `visual-check` optionally adds artifact-bound screenshots and a contact sheet.
|
||||
4. Perceptual visual review records a human or image-capable reviewer's judgment.
|
||||
|
||||
Passing one claim never implies the others. Never claim that the deterministic receipt includes browser or perceptual review evidence.
|
||||
|
||||
## Recovering a failed comparison
|
||||
|
||||
`compare` commits an HTML artifact and its JSON receipt as a pair. If that commit
|
||||
fails, it attempts to restore the previous files. A complete rollback removes
|
||||
the temporary directory as usual.
|
||||
|
||||
If a previous file cannot be restored, compare exits non-zero with
|
||||
`delta/commit-rollback-failed` and retains the recovery directory. In the JSON
|
||||
failure receipt, `diagnostics[].evidence.recoveryDirectory` identifies that
|
||||
directory and `recoveryFiles` lists `{ backup, target }` paths for the files whose
|
||||
restoration failed. Human-readable diagnostics also print the recovery paths.
|
||||
|
||||
Resolve the filesystem error, inspect the current targets, and restore each
|
||||
listed backup to its corresponding target before retrying. Keep the recovery
|
||||
directory until both previous files have been recovered and verified; it can
|
||||
also contain rejected candidate files, which must not be mistaken for backups.
|
||||
Successful comparisons and failures before commit retain their normal cleanup.
|
||||
|
||||
## Automated browser evidence
|
||||
|
||||
`finalize` runs the required browser gate against the exact trusted HTML without
|
||||
rerendering or modifying it. For focused diagnosis, the equivalent standalone
|
||||
command is:
|
||||
|
||||
```bash
|
||||
node bin/archify.mjs browser-check <output.html> --json --require-provenance
|
||||
```
|
||||
|
||||
The zero-dependency command uses Chrome/Chromium through the DevTools pipe. It
|
||||
measures light-theme containment at 1440×900, 1600×1000, 1920×1080, and
|
||||
2048×1320, and verifies the requested light theme at all four viewports, the dark
|
||||
theme at both endpoints, and READ/Still runtime states. A requested theme that
|
||||
resolves to a different theme fails with measured evidence. It creates one
|
||||
`<output-stem>.browser-check.json` receipt and no screenshots or contact sheet.
|
||||
Pass `--out-dir <dir>` to place the receipt in a separate evidence directory.
|
||||
The receipt binds the artifact SHA-256 and byte count, identifies
|
||||
`evidenceKind: "automated-browser"`, and reports
|
||||
`visualReview: "not-requested"`.
|
||||
|
||||
Horizontal overflow always fails. Normal document-level vertical scrolling is
|
||||
accepted only with a renderer-declared contract and measured readable text.
|
||||
Automatic canvases declare `data-reader-fit="intrinsic-height"`; their adaptive
|
||||
Reader must reach its readable width and expose `data-reader-overflow="authored"`.
|
||||
Architecture with an explicit `meta.viewBox` instead declares
|
||||
`data-diagram-type="architecture"` and `data-reader-fit="authored-height"`:
|
||||
its SVG coordinates, aspect ratio and existing Reader width behavior stay
|
||||
unchanged. Its full SVG must remain inside the diagram panel without internal
|
||||
scrolling or clipping, and the document must permit vertical scrolling.
|
||||
The receipt records `verticalScrollAccepted: true` and
|
||||
`overflowDisposition: "readable-vertical-scroll"`. Missing or unknown declarations,
|
||||
explicit viewBoxes in other modes, unreadable text, horizontal overflow,
|
||||
clipping and Viewer chrome collisions remain failures. Do not add an internal
|
||||
diagram scroller or hide overflow.
|
||||
|
||||
`browser_evidence` in the handoff records only the outcome of this automated
|
||||
command:
|
||||
|
||||
- `passed` maps from exit 0 and receipt `status: "pass"` after every required measurement completes and passes.
|
||||
- `failed` maps from exit 1 and receipt `status: "fail"` when the inspection finds a defect, the command fails, or a runtime error leaves the evidence incomplete.
|
||||
- `skipped` maps only from exit 2 and receipt `status: "skipped"` when Chrome/Chromium is unavailable and the inspection does not run.
|
||||
|
||||
Runtime failures leave incomplete evidence and must not be normalized to
|
||||
`skipped`. They do not invalidate an already successful deterministic delivery.
|
||||
Retry an environmental failure in a browser-capable execution context when
|
||||
practical. Keep the packaged transport unchanged unless the failure reproduces
|
||||
through that seam in a capable environment.
|
||||
|
||||
A provenance failure exits before browser inspection and persists a failed
|
||||
browser-check receipt bound to the attempted artifact. If the failure receipt
|
||||
cannot be written, the diagnostic names that incomplete evidence.
|
||||
|
||||
Both browser commands inspect the exact delivered HTML without modifying or rerendering it.
|
||||
|
||||
## Sequence width review
|
||||
|
||||
A passing `finalize` may report `layoutReviewRecommendation.action: "inspect-sequence-width"`. Its `evidence` measures the fixed participant columns' unused right-hand space after accounting for message labels, notes and segment titles. This advice adds no warning, failure, screenshot requirement or automatic geometry change.
|
||||
|
||||
For a newly authored candidate with omitted `meta.column_fit` and no user-fixed column geometry, save the candidate, set only `meta.column_fit` to `"spread"`, and rerun the complete `finalize` once with `--out-dir <folder>/width-review`. Keep participant order, messages and their y positions, labels, notes, sources and canvas dimensions. If that attempt fails, restore the candidate and finalize it with `--out-dir <folder>/width-restore`; report the remaining layout suggestion rather than iterating. Preserve an explicitly fixed layout or a supplied legacy candidate and disclose the suggestion without changing it. This review is about horizontal composition; a passing receipt still does not claim perceptual approval.
|
||||
|
||||
## Optional capture evidence
|
||||
|
||||
`visual-check` remains backward compatible for a requested or escalated
|
||||
perceptual review:
|
||||
|
||||
```bash
|
||||
node bin/archify.mjs visual-check <output.html> --summary --require-provenance
|
||||
```
|
||||
|
||||
`--summary` returns compact JSON with all diagnostics and absolute paths to the complete receipt, contact sheet, and every screenshot. For a chosen visual review, inspect the relevant captures; capture success is not perceptual approval. `--json` retains the full receipt output for existing consumers. Both modes run the same checks and keep the same exit status. If cleanup fails after publication, the summary retains the final failure diagnostics and `publication` recovery details; the linked receipt records the earlier committed evidence.
|
||||
|
||||
It performs the same automated browser measurements, captures light/dark
|
||||
screenshots at 1440×900 and 2048×1320, and writes four viewport PNG sidecars,
|
||||
one relative-path HTML contact sheet, and one JSON receipt. `--out-dir <dir>` moves all of these sidecars together.
|
||||
Open the HTML contact sheet in a browser or inspect the viewport PNGs with an image reader. Its receipt reports `visualReview: "pending"` because captures do not themselves
|
||||
make a perceptual judgment. Capture and provenance failures follow the ownership rules below.
|
||||
|
||||
The receipt, contact sheet, and four PNGs form one owned evidence set. Before
|
||||
capture, `visual-check` freezes every requested directory entry, its
|
||||
canonical write slot and physical parent, and the target's absent/file state,
|
||||
type, device/inode identity, and mode. Hard-linked evidence targets are not safe
|
||||
write targets. All candidate files are created exclusively inside one random,
|
||||
private staging directory beneath the physical evidence directory; the receipt
|
||||
is published last. Each staged candidate must have exactly one hard-link name
|
||||
before publication. The no-clobber publish link temporarily gives the staged
|
||||
and final names a link count of two; unlinking the verified staged name must
|
||||
leave the final entry with a link count of one. An unexpected external hard
|
||||
link fails closed and its alias is never removed.
|
||||
|
||||
Chrome inspects one identity- and content-checked copy of the captured artifact
|
||||
in a private local temporary directory, so browser file loading does not depend
|
||||
on UNC or long-path support. The six publication candidates remain on the
|
||||
evidence volume. Both temporary directories are cleaned without recursively
|
||||
deleting unknown contents; retained entries include their recovery locations.
|
||||
|
||||
Immediately before committing anything, `visual-check` re-resolves and verifies
|
||||
the complete six-path set. An absent-path claimant, existing-path replacement,
|
||||
symbolic-link or dangling-link retarget, parent-topology change, hard link, or
|
||||
indeterminate identity fails closed with `viewer/evidence-path-conflict`. The
|
||||
claimant and every other final evidence path remain untouched. Cleanup removes
|
||||
only this run's staged or published entries after rechecking their captured
|
||||
identities; a changed or unknown entry is preserved.
|
||||
|
||||
An existing visual evidence set is replaceable only when a regular
|
||||
`visual-check` receipt proves ownership of the same artifact and evidence
|
||||
directory, and its exact sidecar manifest matches every existing contact-sheet
|
||||
or PNG byte count and SHA-256 digest. A missing, malformed, unknown, mismatched,
|
||||
or incomplete ownership record never authorizes deletion. Failed and skipped
|
||||
runs retire prior screenshots/contact sheets only as part of the same verified
|
||||
transaction when that ownership proof succeeds; otherwise they preserve all
|
||||
unknown evidence and report `viewer/evidence-path-conflict`.
|
||||
|
||||
This rule also applies when Chrome is unavailable or provenance fails before
|
||||
browser inspection: neither path may blindly delete stale-looking evidence. A
|
||||
verified owned set may be recoverably retired before publishing a skipped or
|
||||
failed receipt; unowned evidence remains intact. These outcomes do not invalidate an already
|
||||
successful deterministic delivery and do not turn a perceptual visual review
|
||||
into passed or failed. Retry an environmental failure through the supported
|
||||
command in a browser-capable execution context when practical. Keep the
|
||||
packaged transport unchanged unless the failure reproduces through that seam in
|
||||
a capable environment.
|
||||
|
||||
`browser-check` applies the same private-snapshot, identity, ownership, and no-clobber rules to its single JSON receipt. Its namespace is separate from `visual-check`, so a browser-only rerun cannot remove capture evidence.
|
||||
|
||||
## A new candidate at an existing output path
|
||||
|
||||
Browser evidence belongs to exact artifact bytes. After editing a candidate whose previous HTML already has browser evidence, choose a fresh evidence directory before running the next `finalize`; this preserves the old receipts and captures without an avoidable ownership-conflict retry:
|
||||
|
||||
```bash
|
||||
node bin/archify.mjs finalize architecture candidate.json diagram.html --quality showcase --repo-root <root> --out-dir diagram.review-2 --json
|
||||
node bin/archify.mjs visual-check diagram.html --out-dir diagram.review-2 --summary --require-provenance
|
||||
```
|
||||
|
||||
Keep the requested HTML path stable. Use a new revision directory for each changed candidate, and retain the same directory for retries of unchanged bytes. For a diagram without repository evidence, omit `--repo-root`. Let the commands create their output directory. A prior validation failure that produced no HTML or browser evidence needs no new directory. Never remove unknown evidence to make a retry pass.
|
||||
|
||||
## Optional opening
|
||||
|
||||
Add `--open` only when the user wants an immediate local preview. It runs after
|
||||
the verified pair commit has completed, its recovery journal has been removed,
|
||||
and the delivery lock has been released successfully. It uses one argument-array
|
||||
OS opener with a five-second bound on macOS and Linux, and a fifteen-second bound
|
||||
for PowerShell startup on Windows. The receipt records `open.status`; failed or
|
||||
unavailable launch attempts also include normalized `open.failure` details.
|
||||
Keep it off for CI, unattended agents, and non-interactive environments.
|
||||
Failure or unsupported opening does not invalidate delivery; its
|
||||
status proves only whether the local opener invocation succeeded.
|
||||
|
||||
## Last-Good Live Preview
|
||||
|
||||
For an active desktop authoring loop only:
|
||||
|
||||
```bash
|
||||
node bin/archify.mjs preview <type> <input>.json <output>.html --quality showcase
|
||||
```
|
||||
|
||||
Preview watches one explicit input on loopback, binds each stable digest to a private snapshot, and advances only after the existing verified delivery pipeline passes. Invalid, half-written, deleted, or superseded input leaves the previous verified revision on screen and on disk. Identical bytes do not rebuild or reload.
|
||||
|
||||
The preview runtime ships inside the zero-dependency Skill ZIP and must work without `node_modules`.
|
||||
|
||||
Never start it by default. Do not use it for CI, unattended agents, remote sharing, or mobile use. `--no-open` is only for a user who will open the printed local URL or for loop testing. Stop it with Ctrl-C before handoff. The first Ctrl-C drains the active delivery without publishing it; a second Ctrl-C forces shutdown of both delivery processes and HTTP connections, including incomplete requests. Shutdown preserves the last verified artifact and removes only staging files whose ownership can be verified. If delivery is interrupted before its receipt reaches Preview, unconfirmed files and recovery material may remain in the private staging directory; shutdown does not recursively delete unknown contents. Server state, port, source path, diagnostics, error text, and reload tokens must never enter the generated artifact or any export.
|
||||
|
||||
## Perceptual review
|
||||
|
||||
The automated path ends with the deterministic browser gate and reports
|
||||
`visual_review: not_requested`. Ordinary generation does not require screenshots
|
||||
or an image-reading step, including newly authored or repositioned Architecture.
|
||||
Perceptual review is optional; use it for an explicit request or a concrete visual
|
||||
investigation. Possible reasons include:
|
||||
|
||||
- the compact finalizer includes `visualReviewRecommendation` for crossings or detours (advisory, not a delivery gate);
|
||||
- the user explicitly requests an aesthetic or visual review;
|
||||
- a template, renderer, or Viewer change needs visual regression evidence;
|
||||
- a novel layout or browser diagnostic leaves low confidence;
|
||||
- the run is selected for sampled audit or dogfood.
|
||||
|
||||
For the default standalone desktop viewer, measure 1440×900, 1600×1000, 1920×1080, and 2048×1320. Require `document.documentElement.scrollWidth <= window.innerWidth` at every checked size. Prefer `scrollHeight <= window.innerHeight`; accept page-level vertical scrolling only through the Reader-declared readable exception defined above. At the largest checked viewport, inspect the rendered composition for a conspicuous empty lower band: the main panel and necessary conclusion cards should use the available height as a balanced whole, not collapse into a shallow strip. For unexpected overflow, repair the authored composition by removing only genuinely redundant content or compacting spacing before shrinking nodes, labels, or the main panel. Do not hide overflow, clip content, introduce an internal diagram scroller, or reduce node/label typography to make the measurement pass. Narrow/mobile containment may retain vertical page scrolling.
|
||||
|
||||
For an escalation, run `visual-check` on the current finalized artifact, inspect
|
||||
its contact sheet with a capable image reader or human, and check both endpoint
|
||||
themes, the default READ view, line crossings/corridors, label masks, node/card
|
||||
fit, focus/search/passport closure, and export cleanliness. This review is
|
||||
supplementary and never changes `browser_evidence`. An unconstrained browser
|
||||
glance can support perceptual review only.
|
||||
|
||||
Report one truthful optional-review status:
|
||||
|
||||
- `visual_review: not_requested` — no review trigger applies; this is not a visual acceptance claim.
|
||||
- `visual_review: passed` — only after inspecting the rendered artifact.
|
||||
- `visual_review: skipped (image reader unavailable)` — a requested or triggered review could not run.
|
||||
- `visual_review: failed` — with the concrete visible defect.
|
||||
|
||||
For an escalated review, use `correction_rounds: 0`, `correction_rounds: 1`, or
|
||||
`correction_rounds: 2`; never exceed two focused correction rounds. When review
|
||||
is not requested, use `correction_rounds: 0`. Never report
|
||||
`visual_review: passed` without inspecting the artifact. If perceptual review
|
||||
changes the candidate, rerun `finalize` because the previous specification and
|
||||
artifact receipts are no longer current.
|
||||
|
||||
## Handoff receipt
|
||||
|
||||
Return:
|
||||
|
||||
```text
|
||||
diagram_type: architecture|workflow|sequence|dataflow|lifecycle
|
||||
output: /absolute/path/to/file.html
|
||||
specification_sha256: <receipt value>
|
||||
artifact_sha256: <receipt value>
|
||||
validation: 9/9 showcase, 0 errors, 0 warnings
|
||||
browser_evidence: passed|failed|skipped
|
||||
visual_review: not_requested|passed|skipped (image reader unavailable)|failed
|
||||
correction_rounds: 0|1|2
|
||||
```
|
||||
|
||||
Derive `browser_evidence` only from the latest artifact-bound `browser-check`
|
||||
receipt, normally the stage embedded by `finalize`. Record optional capture or
|
||||
manual browser work separately with its artifact binding, viewport/theme scope,
|
||||
and observations; never use it or `visual_review` to overwrite the automated
|
||||
status.
|
||||
|
||||
Opening, preview status, Share Cards, and other viewer exports are not validation claims.
|
||||
|
||||
Finalize receipt publication uses the same identity-bound, no-clobber publisher and explicit recovery records described above. The full and summary receipts must be distinct from the candidate, artifact, delivery metadata, and browser receipt. Their targets must be absent or single-link regular files; symlink receipt entries and hardlinked targets fail closed. A later claimant or changed parent stops publication and remains untouched. Default receipt names share the physical artifact namespace and are bounded for the host filename limit. The two receipts are published individually, not as a crash-atomic pair; only a completed passing command is a successful handoff.
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# Repository-backed architecture authoring
|
||||
|
||||
Use this reference when a diagram must explain a real repository. The source is
|
||||
the authority for responsibilities, calls, boundaries, and persistence. The
|
||||
diagram is complete when the requested meaning is covered and every asserted
|
||||
fact has supporting source evidence.
|
||||
|
||||
## Explore on demand
|
||||
|
||||
1. **Freeze identity.** From the target repository, record `git rev-parse
|
||||
HEAD`, `git remote get-url origin`, and `git status --short`. Remove HTTP(S)
|
||||
userinfo (including usernames, passwords, and tokens) before recording the
|
||||
origin or placing it in the candidate. Preserve its transport, port, path and
|
||||
`.git` suffix; do not rewrite an internal SSH origin as HTTPS. Pin the credential-free URL and
|
||||
forty-character revision in `meta.repository`. Use `link_mode: "local-only"`
|
||||
for an SSH origin, unsupported forge, intentionally local-only source links,
|
||||
or a local fixture whose HTTPS URL is only a repository identity; retain the
|
||||
URL and revision. Web links require a supported GitHub or Gitee HTTPS origin. If the
|
||||
worktree is dirty, record the changed paths. Repository evidence is verified
|
||||
against committed bytes at the pinned revision, not working-tree edits:
|
||||
inspect a clean checkout at that revision for any cited changed path. Do not
|
||||
present uncommitted bytes as evidence for `HEAD`; `local-only` does not record
|
||||
a verifiable snapshot of those bytes.
|
||||
|
||||
2. **Map the slice.** Use project instructions, manifests, entry points,
|
||||
registrations, and deployment configuration to locate candidate runtime
|
||||
units. Read the entry, configuration, and modules relevant to the request.
|
||||
Follow imports and call sites
|
||||
until the requested responsibility reaches its actual input, output, or
|
||||
side effect. Read a small connected slice instead of scanning the repository
|
||||
for a convenient label.
|
||||
|
||||
3. **Trace ownership.** Derive runtime and I/O relationships from the observed
|
||||
actor, operation, and target at their call sites; deployment and trust
|
||||
relationships use the corresponding configuration or enforcement evidence. Distinguish the controller requesting
|
||||
an operation from the runtime that executes it and the store receiving bytes.
|
||||
For a file or database edge, the source must identify its actual reader or
|
||||
writer; a responsibility statement such as “maintains tasks” does not prove
|
||||
direct I/O. Keep these facts with the source locations while reading, without
|
||||
a separate planning artifact. Choose which distinctions need separate
|
||||
nodes using [Composition and meaning](authoring-defaults.md#composition-and-meaning);
|
||||
discovering an implementation role does not automatically add it to the overview.
|
||||
A configured provider, an injected adapter, a local stub, and a durable
|
||||
service are different claims; label the one the source supports.
|
||||
|
||||
4. **Record evidence while reading.** Keep exact repository-relative paths and
|
||||
inclusive line ranges for each component and meaningful relationship. Follow
|
||||
actual branches, retries, fallbacks, and error handling. A function that is
|
||||
exported or configured but never called by the normal path is an optional
|
||||
capability, not a required runtime edge. For a claim about authoritative
|
||||
state change or control ownership, trace to the actual write or execution
|
||||
site and the conditions that permit it; an upstream caller alone does not
|
||||
establish those conditions.
|
||||
|
||||
5. **Name uncertainty.** Write unresolved questions beside the claim they
|
||||
affect: for example, “`writeFile` is called here; durability is unknown.”
|
||||
Resolve a question by reading the next relevant source range or preserve it
|
||||
as an explicit unknown. Never turn a label, package description, or config
|
||||
value into an unobserved service or behavior.
|
||||
|
||||
Stop exploring when every requested responsibility, relationship, and boundary
|
||||
has supporting source entailment and the remaining unknowns cannot change that
|
||||
coverage. There is no node, edge, citation, view, card, or boundary count to
|
||||
hit. Do not add a summary step merely to signal completion.
|
||||
|
||||
Batch independent relevant files when known. Each additional read should answer
|
||||
an unresolved question that can change the diagram. Reuse concise facts and
|
||||
their source ranges already verified in this task; across revisions, recheck
|
||||
the affected entry points, configuration, dependencies, and evidence.
|
||||
|
||||
## Choose an example by structure
|
||||
|
||||
Select the main example in the [Type router](../SKILL.md#type-router) before
|
||||
loading its content, using the request and repository metadata already needed
|
||||
for source inspection. Selection fits the existing read batch and needs no extra
|
||||
message, command, or repository-wide scan. For mixed or unclear tasks, use the
|
||||
requested responsibilities and entry points as they become known in normal
|
||||
inspection; keep their actual roles. Read another example when a necessary
|
||||
capability remains unexplained. Examples teach shape, not facts: a library need
|
||||
not acquire filesystem nodes, and finished showcases still follow the
|
||||
first-draft automatic-routing rule.
|
||||
|
||||
## Author from evidence
|
||||
|
||||
Use the mode's complete JSON shape, including repository identity,
|
||||
components, and connections; every repository-backed component needs supporting source
|
||||
references, while boundaries or cards are added only when they
|
||||
answer a real reader question. Let automatic routes and automatic
|
||||
viewBox sizing work first. Keep the primary path readable, put exception paths
|
||||
beside their owner, and leave filesystem stores outside a control boundary when
|
||||
the source shows a separate responsibility.
|
||||
|
||||
An existing example teaches field shape, not facts or arbitrary values. It does
|
||||
not authorize a new boundary kind, a long note, a viewBox size, or a route
|
||||
control. Consult the specific mode schema and `schemas/common.schema.json`
|
||||
whether or not the selected example already contains the field; use the
|
||||
schema's enum, length, identifier, and repository rules. Architecture
|
||||
boundaries currently use `kind: "region"` or `kind: "security-group"`; source
|
||||
references use `path`, `line`, and optional `end_line`.
|
||||
|
||||
Repository-backed components need concise, truthful `sources` references. Preserve
|
||||
control ownership when summarizing filesystem I/O: the code that reads or writes
|
||||
a file owns that action, while a pure in-memory transform receives and returns
|
||||
values. This fact-check does not require a separate overview node for every helper. Use the existing examples for valid field shape, then replace all
|
||||
identifiers, wording, source paths, and claims with inspected repository facts.
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# Update awareness
|
||||
|
||||
Read this file when a `finalize` or standalone `deliver` receipt has `update.noticeRequired: true`.
|
||||
|
||||
Keep one compact line in the final response, in the user's language, with `installedVersion`, `availableVersion`, and the official `releaseNotes` link. Say that the installed Skill has not changed and that the user can ask to snooze or ignore the reminder. If `source` is `cache`, say that a previous check at `checkedAt` found the update. A process message or tool output does not replace this final line.
|
||||
For `severity: "security"`, label it as a security update without making installation automatic or urgent by default.
|
||||
|
||||
You may translate the fixed local `noticeText`. Never quote, summarize, or translate the remote manifest's summary.
|
||||
|
||||
When the user explicitly asks to pause or stop this reminder, run `node scripts/check-update.mjs --snooze "<eventKey>"` (seven days) or `--ignore "<eventKey>"` (this exact release only) from the Skill directory with the receipt's `update.eventKey`, then report the returned status. Never run them on your own initiative; `--ack` is a no-op. These commands do not install an update, and a newer release notifies again.
|
||||
|
||||
The notice is information, not permission. Keep the installed version unchanged. This workflow never downloads, installs, or executes an update, and silence is never consent.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Viewer Runtime reference
|
||||
|
||||
Read this only when the user asks for a reader-facing capability. Ordinary generation does not require implementing or re-documenting these features; they are already in the generated HTML.
|
||||
|
||||
## Exploration
|
||||
|
||||
- Diagram Guide lists current actions and shortcuts.
|
||||
- Reading Depth starts at READ at the default 100% scale, reveals FULL detail at 175%, and falls back to MAP only below 100%. Focus, route, and semantic interactions reveal their exact facts at any scale.
|
||||
- Semantic Lens summarizes selected node/relationship kinds without changing authored geometry.
|
||||
- Intent Trace previews a fine-pointer or keyboard target before committed focus.
|
||||
- Node Finder searches labels and stable IDs.
|
||||
- Semantic Passport opens on focus, shows authored upstream/downstream facts, supports a copyable deep link, has an explicit close action, closes on true outside activation and Escape, and never enters canonical export.
|
||||
- Semantic Radar mirrors the visible viewport and authored graph without becoming a second source of truth.
|
||||
- Direct Relationship Pin makes a unique compiled relationship operable while preserving the authored line and stable relationship identity. It must fail closed on conflicting source/target/label/ID metadata.
|
||||
- Route Probe resolves exactly two endpoints over authored directed relationships. It never infers a route from geometry.
|
||||
|
||||
## Motion and presentation
|
||||
|
||||
`meta.animation: "trace"` enables a finite reader-controlled Live/Still trace. Static is the default. Still, reduced motion, page hiding, print, and canonical export preserve complete static meaning. Presentation Stage changes viewer chrome and framing, never authored geometry. This is not a mobile product feature; narrow layouts get containment only.
|
||||
|
||||
## Canonical exports
|
||||
|
||||
The export menu can copy/download full-diagram PNG, download JPEG/WebP, download a dual-theme SVG, and record a trace-enabled WebM. Viewer state—Guide, Lens, finder, focus, route, camera, radar, presentation, motion ownership, and temporary overlays—must be removed from canonical export.
|
||||
|
||||
### Route Share Card
|
||||
|
||||
After a real directed Route Probe resolves, the reader may use **Export → Route Share Card**. It reuses the exact ordered route snapshot and the shared Share Card seam: `format=share-card`, `variant=route`. The isolated clone may use only static `data-share-route-*` decoration. It is download-only, fails closed for stale/unreachable/conflicting routes, and never becomes the canonical artifact.
|
||||
|
||||
### Reach Share Card
|
||||
|
||||
After a non-empty authored reachability query, the reader may use **Export → Reach Share Card**. It consumes the already resolved upstream/downstream node and edge set without rerunning traversal: `format=share-card`, `variant=reach`. The isolated clone may use only static `data-share-reach-*` decoration. It is download-only. Call it authored reachability—not impact, blast radius, breakage, or runtime causality.
|
||||
|
||||
## Truth boundary
|
||||
|
||||
Viewer exports are communication assets. They do not replace the checked HTML, the deterministic delivery receipt, or a real visual review. Do not add a hosted service, storage surface, dependency, schema branch, or mobile product surface for these viewer-only capabilities.
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/** Grid placement for architecture IR (#8). Not auto-layout — fixed cell math only. */
|
||||
|
||||
export const DEFAULT_GRID = {
|
||||
mode: 'grid',
|
||||
origin: [40, 80],
|
||||
cols: 4,
|
||||
gapX: 30,
|
||||
gapY: 40,
|
||||
cellW: 130,
|
||||
cellH: 64,
|
||||
};
|
||||
|
||||
export function gridLayout(arch) {
|
||||
const raw = arch.layout;
|
||||
if (!raw || raw.mode !== 'grid') return null;
|
||||
return { ...DEFAULT_GRID, ...raw };
|
||||
}
|
||||
|
||||
export function resolveComponentPos(component, grid) {
|
||||
if (Array.isArray(component.pos) && component.pos.length === 2) {
|
||||
return component.pos;
|
||||
}
|
||||
if (!grid) return [NaN, NaN];
|
||||
if (!Number.isInteger(component.row) || !Number.isInteger(component.col)) {
|
||||
return [NaN, NaN];
|
||||
}
|
||||
const [ox, oy] = grid.origin;
|
||||
const stepX = grid.cellW + grid.gapX;
|
||||
const stepY = grid.cellH + grid.gapY;
|
||||
return [ox + component.col * stepX, oy + component.row * stepY];
|
||||
}
|
||||
|
||||
export function validateGridPlacement(arch, grid, problems) {
|
||||
if (!grid) return;
|
||||
if (arch.layout !== undefined && arch.layout.mode !== 'grid') {
|
||||
problems.push('layout.mode must be "grid" when layout is set (free placement omits layout entirely).');
|
||||
return;
|
||||
}
|
||||
const seen = new Map();
|
||||
for (const c of arch.components ?? []) {
|
||||
const hasPos = Array.isArray(c.pos) && c.pos.length === 2;
|
||||
const hasCell = Number.isInteger(c.row) && Number.isInteger(c.col);
|
||||
if (hasPos) continue; // pos wins; row/col are optional hints only
|
||||
if (!hasPos && !hasCell) {
|
||||
problems.push(`Component "${c.id}" needs pos [x,y] or grid row/col when layout.mode is "grid".`);
|
||||
continue;
|
||||
}
|
||||
if (c.row < 0 || c.col < 0) {
|
||||
problems.push(`Component "${c.id}" row/col must be non-negative integers.`);
|
||||
continue;
|
||||
}
|
||||
if (c.col >= grid.cols) {
|
||||
problems.push(`Component "${c.id}" col ${c.col} exceeds layout.cols ${grid.cols} (valid: 0..${grid.cols - 1}).`);
|
||||
}
|
||||
const key = `${c.row},${c.col}`;
|
||||
if (seen.has(key)) {
|
||||
problems.push(`Components "${seen.get(key)}" and "${c.id}" share grid cell row ${c.row} col ${c.col}.`);
|
||||
} else {
|
||||
seen.set(key, c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
import { normalizeRoutePoints, rectsOverlap, segmentRectClearanceWithin } from '../shared/geometry.mjs';
|
||||
import { createSpatialGrid } from '../shared/spatial-grid.mjs';
|
||||
|
||||
// A bounded fallback for an unpinned label whose usual position collides.
|
||||
// It never routes an edge, moves a node, expands the canvas, or rewrites input.
|
||||
export function placeAutomaticLabels({
|
||||
labels, routes, components, titles, viewBox, placementBottom = viewBox[1], fallbackRing = true, keepFallbackNearRoute = false,
|
||||
gridSweep = false,
|
||||
}) {
|
||||
const placed = [...labels];
|
||||
const obstacles = [...components, ...titles];
|
||||
const segments = routes.flatMap(({ relationIndex, points }) => {
|
||||
const normalized = normalizeRoutePoints(points);
|
||||
return normalized.slice(1).map((end, index) => ({ relationIndex, start: normalized[index], end }));
|
||||
});
|
||||
const inside = rect => (
|
||||
rect.x >= 0 && rect.y >= 0
|
||||
&& rect.x + rect.width <= viewBox[0] && rect.y + rect.height <= viewBox[1]
|
||||
);
|
||||
// The mask test asked every segment about every candidate position. Segments
|
||||
// go into a uniform grid once, and a candidate only asks the cells it covers.
|
||||
const SEGMENT_CELL = 120;
|
||||
const segmentGrid = createSpatialGrid(SEGMENT_CELL);
|
||||
for (const segment of segments) {
|
||||
const [sx, sy] = segment.start;
|
||||
const [ex, ey] = segment.end;
|
||||
segmentGrid.insert({
|
||||
minX: Math.min(sx, ex), maxX: Math.max(sx, ex),
|
||||
minY: Math.min(sy, ey), maxY: Math.max(sy, ey),
|
||||
}, segment);
|
||||
}
|
||||
const segmentsNear = (rect, margin) => segmentGrid.query({
|
||||
minX: rect.x - margin, maxX: rect.x + rect.width + margin,
|
||||
minY: rect.y - margin, maxY: rect.y + rect.height + margin,
|
||||
});
|
||||
const masksRoute = rect => {
|
||||
for (const segment of segmentsNear(rect, 4)) {
|
||||
if (segment.relationIndex === rect.relationIndex) continue;
|
||||
if (segmentRectClearanceWithin(segment, rect, 4) + 0.0001 < 4) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const overlapsLabel = (rect, index, gap = 0) => placed.some((other, otherIndex) => (
|
||||
otherIndex !== index && rectsOverlap(rect, other, gap)
|
||||
));
|
||||
const clear = (rect, index) => (
|
||||
inside(rect) && rect.y + rect.height <= placementBottom
|
||||
&& !obstacles.some(obstacle => rectsOverlap(rect, obstacle, 2))
|
||||
&& !overlapsLabel(rect, index, 2) && !masksRoute(rect)
|
||||
);
|
||||
const rectAt = (label, lx, ly) => ({
|
||||
...label, lx, ly, x: lx - label.width / 2, y: ly - 10,
|
||||
});
|
||||
// An opted-in grid layout runs parallel lines through shared gaps. Rank
|
||||
// every position along all of the label's own segments: beside the line
|
||||
// first, then centred on its own line (the plate interrupts only that
|
||||
// line), then stepping outward past neighbouring parallels.
|
||||
const gridCandidates = (label) => {
|
||||
const fractions = [0.5, 0.25, 0.75, 0.375, 0.625, 0.125, 0.875];
|
||||
const ranked = [];
|
||||
// A close parallel of another relationship on one side (a reciprocal
|
||||
// pair) makes a label on that side read as the neighbour's: prefer the
|
||||
// far side.
|
||||
const parallelOnLowSide = (a, b, axis) => {
|
||||
const across = 1 - axis;
|
||||
const [low, high] = [Math.min(a[axis], b[axis]), Math.max(a[axis], b[axis])];
|
||||
let nearest = null;
|
||||
for (const other of segments) {
|
||||
if (other.relationIndex === label.relationIndex) continue;
|
||||
if (Math.abs(other.start[across] - other.end[across]) > 0.0001) continue;
|
||||
const distance = other.start[across] - a[across];
|
||||
if (Math.abs(distance) < 0.0001 || Math.abs(distance) > 36) continue;
|
||||
const overlap = Math.min(high, Math.max(other.start[axis], other.end[axis]))
|
||||
- Math.max(low, Math.min(other.start[axis], other.end[axis]));
|
||||
if (overlap <= 0) continue;
|
||||
if (nearest === null || Math.abs(distance) < Math.abs(nearest)) nearest = distance;
|
||||
}
|
||||
return nearest !== null && nearest < 0;
|
||||
};
|
||||
for (const { start: a, end: b } of segments.filter(segment => segment.relationIndex === label.relationIndex)) {
|
||||
if (Math.abs(a[1] - b[1]) < 0.0001 && Math.abs(a[0] - b[0]) >= label.width + 16) {
|
||||
const [above, below] = parallelOnLowSide(a, b, 0) ? [1, 0] : [0, 1];
|
||||
for (const fraction of fractions) {
|
||||
const x = a[0] + (b[0] - a[0]) * fraction;
|
||||
if (Math.min(Math.abs(x - a[0]), Math.abs(x - b[0])) < label.width / 2 + 6) continue;
|
||||
ranked.push([above, x, a[1] - 10], [below, x, a[1] + 20], [2, x, a[1] + 3], [3, x, a[1] - 18], [3, x, a[1] + 28]);
|
||||
}
|
||||
} else if (Math.abs(a[0] - b[0]) < 0.0001 && Math.abs(a[1] - b[1]) >= label.height + 16) {
|
||||
const leftFirst = !parallelOnLowSide(a, b, 1);
|
||||
for (const fraction of fractions) {
|
||||
const y = a[1] + (b[1] - a[1]) * fraction;
|
||||
if (Math.min(Math.abs(y - a[1]), Math.abs(y - b[1])) < label.height / 2 + 6) continue;
|
||||
ranked.push([2, a[0], y + 3]);
|
||||
[6, 14, 22, 30, 38, 46, 54].forEach((offset, step) => {
|
||||
const tier = step === 0 ? 0 : step === 1 ? 1 : 2 + step;
|
||||
const left = [tier + (leftFirst ? 0 : 0.5), a[0] - label.width / 2 - offset, y + 3];
|
||||
const right = [tier + (leftFirst ? 0.5 : 0), a[0] + label.width / 2 + offset, y + 3];
|
||||
ranked.push(left, right);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return ranked.map((entry, order) => [...entry, order])
|
||||
.sort((left, right) => left[0] - right[0] || left[3] - right[3])
|
||||
.map(([, lx, ly]) => [lx, ly]);
|
||||
};
|
||||
|
||||
for (const [index, label] of placed.entries()) {
|
||||
const relation = label.relation;
|
||||
if (['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => relation[key] !== undefined)) continue;
|
||||
// Match actual defect thresholds before searching; a valid placement is
|
||||
// not a reason to restyle the diagram. New placements leave extra space.
|
||||
// The grid ranking already starts from the preferred position, so it
|
||||
// also re-ranks labels whose default spot is merely valid.
|
||||
if (!gridSweep && inside(label) && !components.some(component => rectsOverlap(label, component, -2))
|
||||
&& !titles.some(title => rectsOverlap(label, title))
|
||||
&& !overlapsLabel(label, index) && !masksRoute(label)) continue;
|
||||
if (gridSweep) {
|
||||
// Callers measure the plate one pixel higher than rectAt; test with a
|
||||
// pixel of slack so the chosen position also passes their clearance.
|
||||
const replacement = gridCandidates(label).map(([lx, ly]) => rectAt(label, lx, ly))
|
||||
.find(rect => clear({ ...rect, x: rect.x - 1, y: rect.y - 1, width: rect.width + 2, height: rect.height + 2 }, index));
|
||||
if (replacement) {
|
||||
placed[index] = replacement;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (const segment of segments.filter(segment => segment.relationIndex === label.relationIndex)) {
|
||||
const [a, b] = [segment.start, segment.end];
|
||||
let candidates = [];
|
||||
if (Math.abs(a[1] - b[1]) < 0.0001 && Math.abs(a[0] - b[0]) >= label.width + 16) {
|
||||
candidates = [0.5, 0.25, 0.75, 0.125, 0.875].flatMap(fraction => {
|
||||
const x = a[0] + (b[0] - a[0]) * fraction;
|
||||
if (Math.min(Math.abs(x - a[0]), Math.abs(x - b[0])) < 8) return [];
|
||||
return [
|
||||
[x, a[1] - 10],
|
||||
[x, a[1] + 20],
|
||||
[x, a[1] - 18],
|
||||
[x, a[1] + 28],
|
||||
];
|
||||
});
|
||||
} else if (Math.abs(a[0] - b[0]) < 0.0001 && Math.abs(a[1] - b[1]) >= label.height + 16) {
|
||||
candidates = [0.5, 0.25, 0.75, 0.125, 0.875].flatMap(fraction => {
|
||||
const y = a[1] + (b[1] - a[1]) * fraction;
|
||||
if (Math.min(Math.abs(y - a[1]), Math.abs(y - b[1])) < 8) return [];
|
||||
return [
|
||||
[a[0] - label.width / 2 - 6, y + 3],
|
||||
[a[0] + label.width / 2 + 6, y + 3],
|
||||
[a[0] - label.width / 2 - 14, y + 3],
|
||||
[a[0] + label.width / 2 + 14, y + 3],
|
||||
];
|
||||
});
|
||||
}
|
||||
const replacement = candidates.map(([lx, ly]) => rectAt(label, lx, ly))
|
||||
.find(rect => clear(rect, index));
|
||||
if (replacement) {
|
||||
placed[index] = replacement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (placed[index] !== label || !fallbackRing) continue;
|
||||
|
||||
// Dense but valid topologies can leave every point directly beside the
|
||||
// relationship occupied by another route. Search a small deterministic
|
||||
// ring around the current anchor and the relationship's segment centres.
|
||||
// A collision-free island above a node is not a readable edge label.
|
||||
// Architecture opts into keeping the mask within two label heights of
|
||||
// its own route; shared callers retain their existing policy. If no nearby
|
||||
// slot fits, retain the collision so validation can request more space.
|
||||
const ownSegments = segments.filter(segment => segment.relationIndex === label.relationIndex);
|
||||
const baseAnchors = [
|
||||
[label.lx, label.ly],
|
||||
...ownSegments.map(segment => [
|
||||
(segment.start[0] + segment.end[0]) / 2,
|
||||
(segment.start[1] + segment.end[1]) / 2,
|
||||
]),
|
||||
];
|
||||
const horizontalStep = label.width / 2 + 12;
|
||||
const ringOffsets = [
|
||||
[0, -28], [0, 38],
|
||||
[-horizontalStep, -28], [horizontalStep, -28],
|
||||
[-horizontalStep, 38], [horizontalStep, 38],
|
||||
[-(label.width + 20), -52], [label.width + 20, -52],
|
||||
[-(label.width + 20), 62], [label.width + 20, 62],
|
||||
[-(label.width + 20), -76], [label.width + 20, -76],
|
||||
[-(label.width + 20), 86], [label.width + 20, 86],
|
||||
];
|
||||
const fallback = baseAnchors.flatMap(([baseX, baseY]) => (
|
||||
ringOffsets.map(([dx, dy]) => rectAt(label, baseX + dx, baseY + dy))
|
||||
)).find(rect => clear(rect, index) && (!keepFallbackNearRoute || ownSegments.some(segment => (
|
||||
segmentRectClearanceWithin(segment, rect, label.height * 2) <= label.height * 2
|
||||
))));
|
||||
if (fallback) placed[index] = fallback;
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
|
||||
// The rect a single unpinned label would occupy given only its own route and
|
||||
// the nodes: what the planner reserves before the remaining routes are laid.
|
||||
// Only a placement beside the route itself is worth reserving; a label that
|
||||
// would already need the fallback ring is left to the final placement pass.
|
||||
export function reservedLabelRect({
|
||||
label, points, routes = [], labels = [], components, viewBox = [Infinity, Infinity], placementBottom = Infinity,
|
||||
}) {
|
||||
const [rect] = placeAutomaticLabels({
|
||||
labels: [{ ...label, relationIndex: -1 }, ...labels.map(other => ({ ...other, relationIndex: -2 }))],
|
||||
routes: [{ relationIndex: -1, points }, ...routes],
|
||||
components,
|
||||
titles: [],
|
||||
viewBox,
|
||||
placementBottom,
|
||||
fallbackRing: false,
|
||||
});
|
||||
return components.some(component => rectsOverlap(rect, component, -2)) ? null : rect;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1157
File diff suppressed because it is too large
Load Diff
+105
@@ -0,0 +1,105 @@
|
||||
# Data Flow Renderer
|
||||
|
||||
Render `diagram_type: "dataflow"` JSON files into the standard Archify HTML
|
||||
template.
|
||||
|
||||
```bash
|
||||
node archify/renderers/dataflow/render-dataflow.mjs input.dataflow.json output.html
|
||||
```
|
||||
|
||||
The renderer validates input against `archify/schemas/dataflow.schema.json`
|
||||
with the bundled standalone validator. No dependency installation is required.
|
||||
|
||||
If `output.html` is omitted, the renderer uses the required `meta.output` value
|
||||
from the JSON file.
|
||||
|
||||
## Input
|
||||
|
||||
Data-flow JSON files must set:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "dataflow",
|
||||
"meta": {
|
||||
"title": "Product Analytics Data Flow",
|
||||
"output": "product-analytics-dataflow.html",
|
||||
"viewBox": [940, 720]
|
||||
},
|
||||
"stages": [],
|
||||
"nodes": [],
|
||||
"flows": [],
|
||||
"cards": []
|
||||
}
|
||||
```
|
||||
|
||||
A complete worked example lives at
|
||||
`archify/examples/product-analytics.dataflow.json`.
|
||||
|
||||
The schema lives at:
|
||||
|
||||
```text
|
||||
archify/schemas/dataflow.schema.json
|
||||
```
|
||||
|
||||
## Legend
|
||||
|
||||
The default visual legend derives kinds from `flows[].variant` (omitting
|
||||
`variant` means `default`) and adds `database` only when a database node exists.
|
||||
Supported `meta.legend.entries` keys, in stable order, are `emphasis`,
|
||||
`security`, `dashed`, `database`, and `default`. Flow variants remain
|
||||
visual-only because Archify has no compiled edge-kind facts in this slice. A
|
||||
present `database` entry is different: it comes from exact
|
||||
`nodes[].type: "database"` facts, so it publishes the normal Semantic Legend
|
||||
count, accessible name, and keyboard interaction. Forcing `database` visible
|
||||
without a database node keeps it visual-only.
|
||||
|
||||
## Layout budget
|
||||
|
||||
| Constant | Value |
|
||||
|----------|-------|
|
||||
| viewBox | default `[940, 720]`; schema minimum `[360, 360]` |
|
||||
| Stages (2–5) | centers at x = 100 + stage×215; stage band 168 wide, header at y 46 |
|
||||
| Row tops (`row` 0–4) | y = 128, 242, 356, 470, 584 (plus `yOffset`) |
|
||||
| Default node | 112×58 |
|
||||
| Node area | x within `[24, width − 24]`; y within `[104, height − 74]` |
|
||||
| Node spacing | ≥10px between any two nodes (checked across stages and rows) |
|
||||
| Flow length | ≥34px between endpoints |
|
||||
| Legend row | y = height − 36 |
|
||||
|
||||
Route presets for flows: `straight`, `vertical-channel`, `bottom-channel`,
|
||||
`top-channel`, explicit `via` points, or the default `auto` (midpoint elbow).
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Use stages for data lifecycle boundaries: source, ingest, process, store,
|
||||
consume.
|
||||
- Place nodes by stage index and row index; do not hand-place raw SVG for the
|
||||
common case.
|
||||
- Use flow labels to name the data asset, not the transport primitive:
|
||||
`clickstream`, `identity map`, `normalized facts`, `feature vectors`.
|
||||
- Use `classification` for short sensitivity or governance context:
|
||||
`PII touch`, `non-PII`, `approved only`, `batch`, `read-only`.
|
||||
- Use `security` for PII, policy, consent, access-control, or restricted joins.
|
||||
- Use `emphasis` for the primary data path and `dashed` for async or batch
|
||||
derivations.
|
||||
- Keep labels short enough to fit in narrow previews.
|
||||
|
||||
Schema violations exit non-zero with path-prefixed messages annotated with the
|
||||
element's id or label. The renderer additionally fails when it can detect
|
||||
layout problems, including missing stages, duplicate node IDs, nodes outside
|
||||
the readable diagram area, node overlap, labels colliding with nodes or other
|
||||
labels, labels wider than their node, unknown flow endpoints, missing flow
|
||||
labels, unreadably short flows, flows crossing unrelated nodes (2px Clean Flow
|
||||
clearance), or stages that exceed the viewBox. Stage frames remain intentional
|
||||
pass-through containers. Text width
|
||||
is estimated CJK-aware: fullwidth glyphs count as two units.
|
||||
|
||||
Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
|
||||
X crossings then fail with `composition/proper-crossing`; default `standard`
|
||||
keeps them as artifact-receipt warnings. Collinear stage corridors are outside
|
||||
the proper-X rule, but a separate gate warns in `standard` and fails in
|
||||
`showcase` when unrelated flows overlap for at least 8px. Shared semantic
|
||||
endpoints, point touches, and shorter overlaps remain valid. Showcase also
|
||||
rejects any route segment below 8px and any interior turn segment below 16px;
|
||||
ordinary 8–15px endpoint stubs remain valid.
|
||||
@@ -0,0 +1,544 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
|
||||
import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
|
||||
import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
|
||||
import { resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
|
||||
import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth, nodeLabelLayout } from '../shared/text-fit.mjs';
|
||||
import { brandLabelFitWidth, brandMarkFor, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
|
||||
import { translateMessage as i18nText } from '../shared/i18n.mjs';
|
||||
import {
|
||||
asArray,
|
||||
isFinitePoint,
|
||||
rectsOverlap,
|
||||
cleanEndpointSideProblems,
|
||||
cleanFlowProblems,
|
||||
cleanCrossingProblems,
|
||||
cleanAmbiguousCorridorProblems,
|
||||
cleanBorderRunProblems,
|
||||
cleanRouteRhythmProblems,
|
||||
cleanLabelRouteClearanceProblems,
|
||||
cleanLabelCanvasContainmentProblems,
|
||||
suggestLabelObstacleFix,
|
||||
suggestLabelPairFix,
|
||||
anchor,
|
||||
automaticPortSpread,
|
||||
legacyDefaultFromSide as defaultFromSide,
|
||||
legacyDefaultToSide as defaultToSide,
|
||||
chosenSide,
|
||||
polylinePath,
|
||||
routePointsValue,
|
||||
authoredStraightRouteAttrs,
|
||||
labelPoint,
|
||||
componentFill,
|
||||
componentText,
|
||||
arrowClassMap,
|
||||
edgeLabelAccent
|
||||
} from '../shared/geometry.mjs';
|
||||
|
||||
const nodeTextFit = {
|
||||
sublabelPreferred: 7,
|
||||
sublabelMinimum: 6,
|
||||
tagPreferred: 7,
|
||||
tagMinimum: 6,
|
||||
};
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const { diagram: dataflow, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
|
||||
rendererDir: __dirname,
|
||||
diagramType: 'dataflow',
|
||||
defaultExample: 'product-analytics.dataflow.json'
|
||||
});
|
||||
|
||||
const viewBox = dataflow.meta?.viewBox || [940, 720];
|
||||
const layout = {
|
||||
stageY: 46,
|
||||
stageH: 36,
|
||||
stageBottomPad: 74,
|
||||
leftX: 100,
|
||||
colGap: 215,
|
||||
stageW: 168,
|
||||
nodeW: 112,
|
||||
nodeH: 58,
|
||||
rowYs: [128, 242, 356, 470, 584],
|
||||
labelH: 16
|
||||
};
|
||||
|
||||
function flowLabelSize(flow) {
|
||||
const longestLine = Math.max(textUnits(flow.label), textUnits(flow.classification || ''));
|
||||
return {
|
||||
width: Math.round(Math.max(34, longestLine * 4.9 + 12) * 10) / 10,
|
||||
height: flow.classification ? 27 : layout.labelH,
|
||||
};
|
||||
}
|
||||
|
||||
function stageX(index) {
|
||||
return layout.leftX + index * layout.colGap;
|
||||
}
|
||||
|
||||
function stageFrame(stage, index) {
|
||||
return {
|
||||
id: index,
|
||||
label: stage.label,
|
||||
kind: 'stage',
|
||||
x: stageX(index) - layout.stageW / 2,
|
||||
y: layout.stageY,
|
||||
width: layout.stageW,
|
||||
height: viewBox[1] - layout.stageY - layout.stageBottomPad,
|
||||
radius: 10,
|
||||
};
|
||||
}
|
||||
|
||||
const compositionFrames = asArray(dataflow.stages).map(stageFrame);
|
||||
|
||||
function measureNode(node) {
|
||||
const width = node.width || layout.nodeW;
|
||||
const height = node.height || layout.nodeH;
|
||||
const cx = stageX(node.stage);
|
||||
const y = layout.rowYs[node.row] + (node.yOffset || 0);
|
||||
return {
|
||||
...node,
|
||||
width,
|
||||
height,
|
||||
cx,
|
||||
cy: y + height / 2,
|
||||
x: cx - width / 2,
|
||||
y
|
||||
};
|
||||
}
|
||||
|
||||
const nodes = new Map(asArray(dataflow.nodes).map((node) => [node.id, measureNode(node)]));
|
||||
const nodeSteps = new Map();
|
||||
for (const [index, flow] of asArray(dataflow.flows).entries()) {
|
||||
if (!nodeSteps.has(flow.from)) nodeSteps.set(flow.from, index);
|
||||
if (!nodeSteps.has(flow.to)) nodeSteps.set(flow.to, index + 1);
|
||||
}
|
||||
for (const [index, node] of asArray(dataflow.nodes).entries()) {
|
||||
if (!nodeSteps.has(node.id)) nodeSteps.set(node.id, index);
|
||||
}
|
||||
|
||||
function validateDataflow() {
|
||||
const problems = [];
|
||||
if (nodes.size !== asArray(dataflow.nodes).length) problems.push('Node ids must be unique.');
|
||||
|
||||
const stageCount = asArray(dataflow.stages).length;
|
||||
for (const node of nodes.values()) {
|
||||
if (typeof node.stage !== 'number' || node.stage < 0 || node.stage >= stageCount) {
|
||||
problems.push(`Node "${node.id}" uses invalid stage ${node.stage} — valid stages are 0..${stageCount - 1}.`);
|
||||
}
|
||||
if (typeof node.row !== 'number' || node.row < 0 || node.row >= layout.rowYs.length) {
|
||||
problems.push(`Node "${node.id}" uses invalid row ${node.row} — valid rows are 0..${layout.rowYs.length - 1}.`);
|
||||
}
|
||||
if (!isFinitePoint(node.x, node.y, node.cx, node.cy)) {
|
||||
problems.push(`Node "${node.id}" produced non-finite coordinates — check stage, row, width, height, and yOffset are numbers.`);
|
||||
continue;
|
||||
}
|
||||
if (node.x < 24 || node.x + node.width > viewBox[0] - 24) {
|
||||
problems.push(`Node "${node.id}" exceeds the horizontal bounds of the viewBox — reduce node.width or increase meta.viewBox[0].`);
|
||||
}
|
||||
if (node.y < layout.stageY + layout.stageH + 22 || node.y + node.height > viewBox[1] - layout.stageBottomPad) {
|
||||
problems.push(`Node "${node.id}" exceeds the readable diagram area — keep y between ${layout.stageY + layout.stageH + 22} and ${viewBox[1] - layout.stageBottomPad} (adjust row/yOffset or increase meta.viewBox[1]).`);
|
||||
}
|
||||
const estLabelW = textUnits(node.label) * 6.2;
|
||||
if (estLabelW > node.width + 6) {
|
||||
problems.push(`Label "${node.label}" (~${Math.round(estLabelW)}px) is wider than node "${node.id}" (${node.width}px) — shorten the label or increase node.width.`);
|
||||
}
|
||||
const brandRailProblem = brandTopRailProblem(node, node.width, 8);
|
||||
if (brandRailProblem) problems.push(brandRailProblem);
|
||||
// sublabel and tag render as single unwrapped <text> elements; shrink-to-fit
|
||||
// handles the ordinary case, this rejects what it cannot rescue.
|
||||
const availableTextW = availableNodeTextWidth(node.width);
|
||||
for (const [field, value, minimum] of [
|
||||
['Sublabel', node.sublabel, nodeTextFit.sublabelMinimum],
|
||||
['Tag', node.tag, nodeTextFit.tagMinimum],
|
||||
]) {
|
||||
if (!value) continue;
|
||||
const minimumW = minimumNodeTextWidth(value, minimum);
|
||||
if (minimumW > availableTextW) {
|
||||
problems.push(`${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but node "${node.id}" provides ${availableTextW}px — shorten the ${field.toLowerCase()} or increase node.width.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nodeList = asArray(dataflow.nodes);
|
||||
for (let i = 0; i < nodeList.length; i += 1) {
|
||||
for (let j = i + 1; j < nodeList.length; j += 1) {
|
||||
const a = nodes.get(nodeList[i].id);
|
||||
const b = nodes.get(nodeList[j].id);
|
||||
if (rectsOverlap(a, b, 10)) {
|
||||
problems.push(`Nodes "${a.id}" and "${b.id}" are less than 10px apart — move one to another stage/row or adjust yOffset.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const flow of asArray(dataflow.flows)) {
|
||||
if (!nodes.has(flow.from)) problems.push(`Flow "${flow.label || flow.from}" references unknown source "${flow.from}".`);
|
||||
if (!nodes.has(flow.to)) problems.push(`Flow "${flow.label || flow.to}" references unknown target "${flow.to}".`);
|
||||
if (!flow.label) problems.push(`Flow "${flow.from}" -> "${flow.to}" must include a short data label.`);
|
||||
if (nodes.has(flow.from) && nodes.has(flow.to)) {
|
||||
const routed = pathFor(flow);
|
||||
const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
|
||||
const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
|
||||
if (distance < 34) problems.push(`Flow "${flow.label}" is too short (${Math.round(distance)}px; minimum 34px) — route it through a channel or spread its nodes.`);
|
||||
if (Array.isArray(flow.via)) {
|
||||
for (let segmentIndex = 0; segmentIndex < routed.points.length - 1; segmentIndex += 1) {
|
||||
const segmentStart = routed.points[segmentIndex];
|
||||
const segmentEnd = routed.points[segmentIndex + 1];
|
||||
const isDiagonal = Math.abs(segmentStart[0] - segmentEnd[0]) > 0.01
|
||||
&& Math.abs(segmentStart[1] - segmentEnd[1]) > 0.01;
|
||||
if (!isDiagonal) continue;
|
||||
const viaIndex = Math.min(segmentIndex, flow.via.length - 1);
|
||||
problems.push(`Flow "${flow.label}" has a diagonal segment from (${segmentStart.join(', ')}) to (${segmentEnd.join(', ')}) — align via[${viaIndex}] with its adjacent point by sharing the same x or y coordinate.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
problems.push(...cleanEndpointSideProblems({
|
||||
relations: dataflow.flows,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
fromSideFor: (flow) => flowSides(flow).fromSide,
|
||||
toSideFor: (flow) => flowSides(flow).toSide,
|
||||
routeHint: 'keep automatic routing, or choose fromSide/toSide and via points whose first and final segments cross node borders perpendicularly',
|
||||
}));
|
||||
problems.push(...cleanFlowProblems({
|
||||
relations: dataflow.flows,
|
||||
obstacles: nodes.values(),
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
obstacleKind: 'node',
|
||||
routeHint: 'adjust fromSide/toSide, set route/via or channelX/channelY, or move the node to another stage/row'
|
||||
}));
|
||||
problems.push(...cleanCrossingProblems({
|
||||
relations: dataflow.flows,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
profile: dataflow.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via or channelX/channelY so the flows use separate stage corridors'
|
||||
}));
|
||||
problems.push(...cleanAmbiguousCorridorProblems({
|
||||
relations: dataflow.flows,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
profile: dataflow.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via or channelX/channelY so unrelated flows do not visually merge'
|
||||
}));
|
||||
problems.push(...cleanBorderRunProblems({
|
||||
relations: dataflow.flows,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
frames: compositionFrames,
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
profile: dataflow.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via or channelX/channelY so the flow crosses the stage perpendicularly instead of following its border'
|
||||
}));
|
||||
problems.push(...cleanRouteRhythmProblems({
|
||||
relations: dataflow.flows,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
profile: dataflow.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via or channelX/channelY so each turn uses a clear inter-stage corridor'
|
||||
}));
|
||||
|
||||
const labelRects = [];
|
||||
for (const [flowIndex, flow] of asArray(dataflow.flows).entries()) {
|
||||
if (!flow.label || !nodes.has(flow.from) || !nodes.has(flow.to)) continue;
|
||||
const [lx, ly] = labelPoint(flow, pathFor(flow).points);
|
||||
const { width, height } = flowLabelSize(flow);
|
||||
labelRects.push({ relation: flow, relationIndex: flowIndex, label: flow.label, x: lx - width / 2, y: ly - 11, width, height, lx, ly });
|
||||
}
|
||||
for (const rect of labelRects) {
|
||||
for (const node of nodes.values()) {
|
||||
if (rectsOverlap(rect, node, -2)) {
|
||||
problems.push(`Label "${rect.label}" overlaps node "${node.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, node, 'node', viewBox, nodes.values())}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < labelRects.length; i += 1) {
|
||||
for (let j = i + 1; j < labelRects.length; j += 1) {
|
||||
if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
|
||||
problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — adjust labelDx/labelDy.\n${suggestLabelPairFix(labelRects[i], labelRects[j])}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
problems.push(...cleanLabelRouteClearanceProblems({
|
||||
relations: dataflow.flows,
|
||||
labels: labelRects,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
profile: dataflow.meta?.quality_profile,
|
||||
routeHint: 'adjust labelAt, labelDx, labelDy, or labelSegment; otherwise adjust the other flow route/via/channelX/channelY'
|
||||
}));
|
||||
problems.push(...cleanLabelCanvasContainmentProblems({
|
||||
labels: labelRects,
|
||||
viewBox,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
profile: dataflow.meta?.quality_profile,
|
||||
}));
|
||||
|
||||
const lastStageX = stageX(asArray(dataflow.stages).length - 1);
|
||||
if (lastStageX + layout.stageW / 2 > viewBox[0] - 24) {
|
||||
problems.push(`Stages exceed viewBox width — set meta.viewBox[0] to at least ${Math.ceil(lastStageX + layout.stageW / 2 + 24)}.`);
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
throwDiagnosticProblems('Data-flow layout validation failed', problems, {
|
||||
subject: { diagramType: 'dataflow' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function routeVia(flow, from, to, start, end) {
|
||||
if (flow.via) return flow.via;
|
||||
switch (flow.route || 'auto') {
|
||||
case 'straight':
|
||||
return [];
|
||||
case 'vertical-channel': {
|
||||
const x = flow.channelX ?? start[0] + (end[0] > start[0] ? 44 : -44);
|
||||
return [[x, start[1]], [x, end[1]]];
|
||||
}
|
||||
case 'bottom-channel': {
|
||||
const y = flow.channelY ?? Math.max(from.y + from.height, to.y + to.height) + 26;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
case 'top-channel': {
|
||||
const y = flow.channelY ?? Math.min(from.y, to.y) - 24;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
case 'auto':
|
||||
default: {
|
||||
if (Math.abs(start[1] - end[1]) < 4) return [];
|
||||
const midX = start[0] + (end[0] - start[0]) / 2;
|
||||
return [[midX, start[1]], [midX, end[1]]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pathCache = new Map();
|
||||
|
||||
function flowSides(flow) {
|
||||
const from = nodes.get(flow.from);
|
||||
const to = nodes.get(flow.to);
|
||||
return {
|
||||
fromSide: chosenSide(flow.fromSide, defaultFromSide(from, to)),
|
||||
toSide: chosenSide(flow.toSide, defaultToSide(from, to)),
|
||||
};
|
||||
}
|
||||
|
||||
const automaticPorts = automaticPortSpread(dataflow.flows, nodes, {
|
||||
sideFor: (flow, endpoint) => flowSides(flow)[endpoint === 'source' ? 'fromSide' : 'toSide'],
|
||||
});
|
||||
|
||||
function pathFor(flow) {
|
||||
if (pathCache.has(flow)) return pathCache.get(flow);
|
||||
const from = nodes.get(flow.from);
|
||||
const to = nodes.get(flow.to);
|
||||
const ports = automaticPorts.get(flow);
|
||||
const { fromSide, toSide } = flowSides(flow);
|
||||
const start = ports?.from || anchor(from, fromSide);
|
||||
const end = ports?.to || anchor(to, toSide);
|
||||
// Drop consecutive duplicate points so a purely vertical (or horizontal)
|
||||
// auto-route never emits a zero-length final segment — SVG derives
|
||||
// marker-end orientation from the last segment, and a degenerate segment
|
||||
// leaves the arrowhead angle undefined (see #169).
|
||||
const rawPoints = [start, ...routeVia(flow, from, to, start, end), end];
|
||||
const points = [];
|
||||
for (const p of rawPoints) {
|
||||
const prev = points.at(-1);
|
||||
if (!prev || Math.abs(p[0] - prev[0]) > 0.0001 || Math.abs(p[1] - prev[1]) > 0.0001) {
|
||||
points.push(p);
|
||||
}
|
||||
}
|
||||
// Guard against an all-degenerate route (e.g. start === end): keep both
|
||||
// endpoints so the path is still well-formed even if the marker is hidden.
|
||||
if (points.length < 2) points.push(end);
|
||||
const routed = { d: polylinePath(points), points };
|
||||
pathCache.set(flow, routed);
|
||||
return routed;
|
||||
}
|
||||
|
||||
// Header measurement follows 276970789's #257, including the ordinal.
|
||||
// Long titles wrap at the same legible floor instead of becoming invalid input.
|
||||
function stageHeaderText(stage, index) {
|
||||
return `${String(index + 1).padStart(2, '0')} / ${stage.label}`;
|
||||
}
|
||||
|
||||
function renderStageHeader(stage, index, cx) {
|
||||
const text = stageHeaderText(stage, index);
|
||||
const font = fittedNodeFontSize(text, layout.stageW, 9, 7);
|
||||
const available = availableNodeTextWidth(layout.stageW);
|
||||
const open = `<text x="${cx}" y="${layout.stageY + 22}" class="t-dim" font-size="${font}" font-weight="600" text-anchor="middle">`;
|
||||
if (minimumNodeTextWidth(text, font) <= available) return `${open}${esc(text)}</text>`;
|
||||
const lines = [];
|
||||
let line = '';
|
||||
// Prefer word boundaries; split oversized words and CJK by grapheme, without
|
||||
// dropping whitespace or splitting a combining character/emoji sequence.
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
||||
for (const word of text.match(/\s+|\S+/gu) || []) {
|
||||
if (line && minimumNodeTextWidth(line + word, font) > available) {
|
||||
lines.push(line); line = '';
|
||||
}
|
||||
for (const { segment } of segmenter.segment(word)) {
|
||||
if (line && minimumNodeTextWidth(line + segment, font) > available) {
|
||||
lines.push(line); line = '';
|
||||
}
|
||||
line += segment;
|
||||
}
|
||||
}
|
||||
if (line) lines.push(line);
|
||||
// Explicit node geometry is authoritative. Do not turn an old horizontal
|
||||
// overflow into a new collision with nodes when the header area is packed.
|
||||
const firstNodeY = Math.min(...[...nodes.values()].filter(node => node.stage === index).map(node => node.y),
|
||||
viewBox[1] - layout.stageBottomPad);
|
||||
const lastLineBottom = layout.stageY + 22 + (lines.length - 1) * (font + 4) + font * 0.3;
|
||||
if (lastLineBottom > firstNodeY - 4) return `${open}${esc(text)}</text>`;
|
||||
const content = lines.length === 1 ? esc(text) : lines.map((line, i) =>
|
||||
`<tspan x="${cx}" dy="${i ? font + 4 : 0}">${esc(line)}</tspan>`).join('');
|
||||
return `${open}${content}</text>`;
|
||||
}
|
||||
|
||||
function renderStage(stage, index) {
|
||||
const frame = compositionFrames[index];
|
||||
const cx = stageX(index);
|
||||
return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="${index}" x="${frame.x}" y="${frame.y}" width="${frame.width}" height="${frame.height}" rx="${frame.radius}" class="c-lane" stroke-width="1"/>
|
||||
${renderStageHeader(stage, index, cx)}`;
|
||||
}
|
||||
|
||||
function renderNode(node) {
|
||||
const fill = componentFill[node.type] || 'c-external';
|
||||
const accent = componentText[node.type] || 't-muted';
|
||||
const hasSub = node.sublabel != null && node.sublabel !== '';
|
||||
const labelFontSize = fittedNodeFontSize(node.label, brandLabelFitWidth(node, node.width), 10, 8);
|
||||
const sublabelFontSize = fittedNodeFontSize(node.sublabel, node.width, nodeTextFit.sublabelPreferred, nodeTextFit.sublabelMinimum);
|
||||
const tagFontSize = fittedNodeFontSize(node.tag, node.width, nodeTextFit.tagPreferred, nodeTextFit.tagMinimum);
|
||||
const textRows = [{ text: node.label, font: labelFontSize, y: 21 }];
|
||||
if (hasSub) textRows.push({ text: node.sublabel, font: sublabelFontSize, y: 37 });
|
||||
if (node.tag) textRows.push({ text: node.tag, font: tagFontSize, y: node.height - 11 });
|
||||
const labelLayout = nodeLabelLayout({ width: node.width, height: node.height, rows: textRows,
|
||||
brand: Boolean(brandMarkFor(node)), source: Boolean(sourceEvidence?.nodes?.[node.id]?.length) });
|
||||
const sub = hasSub
|
||||
? `\n <text data-detail="context" x="${node.cx}" y="${node.y + labelLayout.ys[1]}" class="t-muted" font-size="${sublabelFontSize}" text-anchor="middle">${esc(node.sublabel)}</text>`
|
||||
: '';
|
||||
const tag = node.tag
|
||||
? `\n <text data-detail="fine" x="${node.cx}" y="${node.y + labelLayout.ys[hasSub ? 2 : 1]}" class="${accent}" font-size="${tagFontSize}" text-anchor="middle">${esc(node.tag)}</text>`
|
||||
: '';
|
||||
const stage = asArray(dataflow.stages)[node.stage];
|
||||
const context = stage
|
||||
? `${String(node.stage + 1).padStart(2, '0')} / ${stage.label}`
|
||||
: i18nText(dataflow.meta.locale, 'node.context.dataflow');
|
||||
const brand = renderBrandMark(node, { x: node.x + node.width - 22, y: node.y + 6 });
|
||||
const passport = { kind: node.type, sublabel: node.sublabel, tag: node.tag, context, ...brandMetadataFor(node) };
|
||||
return ` <g ${focusNodeAttrs(node.id, node.label, passport, dataflow.meta.locale)}>
|
||||
${focusNodeTitle(node.label, passport)}
|
||||
<rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="c-mask"/>
|
||||
<rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="${fill}"${animateAttr(dataflow.meta, 'node', nodeSteps.get(node.id))} stroke-width="1.5"/>
|
||||
${renderSemanticSigil(node.type, { icon: node.icon, x: node.x + 6, y: node.y + labelLayout.sigilY, size: labelLayout.sigilSize })}${brand ? `\n ${brand}` : ''}
|
||||
<text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${node.x + labelLayout.x}" y="${node.y + labelLayout.ys[0]}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(node.label)}</text>${sub}${tag}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function renderFlowPath(flow, index) {
|
||||
const [cls, marker] = arrowClassMap[flow.variant || 'default'] || arrowClassMap.default;
|
||||
const routed = pathFor(flow);
|
||||
const strokeWidth = flow.width || (flow.variant === 'emphasis' ? 1.8 : 1.4);
|
||||
return ` <path ${focusEdgeAttrs(flow.from, flow.to, flow.label, index, flow.id)} data-composition-points="${routePointsValue(routed.points)}"${authoredStraightRouteAttrs(flow, routed.points)} d="${routed.d}" class="${cls}"${animateAttr(dataflow.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
|
||||
}
|
||||
|
||||
function renderFlowLabel(flow, index) {
|
||||
const routed = pathFor(flow);
|
||||
const [lx, ly] = labelPoint(flow, routed.points);
|
||||
const { width: labelW, height: labelH } = flowLabelSize(flow);
|
||||
const classification = flow.classification
|
||||
? `\n <text data-detail="fine" x="${lx}" y="${ly + 11}" class="t-dim" font-size="7" text-anchor="middle">${esc(flow.classification)}</text>`
|
||||
: '';
|
||||
return ` <g data-detail="context" ${focusEdgeAttrs(flow.from, flow.to, flow.label, index, flow.id)}>
|
||||
<rect x="${lx - labelW / 2}" y="${ly - 11}" width="${labelW}" height="${labelH}" rx="4" class="c-mask"/>
|
||||
<text x="${lx}" y="${ly}" class="${edgeLabelAccent(flow.variant)}" font-size="8" text-anchor="middle">${esc(flow.label)}</text>${classification}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
const LEGEND_CATALOG = [
|
||||
{ kind: 'emphasis', className: 'a-emphasis', marker: 'arrowhead-emphasis', strokeWidth: 1.8, swatchWidth: 34, swatchGap: 9, interactive: false },
|
||||
{ kind: 'security', className: 'a-security', marker: 'arrowhead-security', swatchWidth: 34, swatchGap: 9, interactive: false },
|
||||
{ kind: 'dashed', className: 'a-dashed', marker: 'arrowhead-dashed', swatchWidth: 34, swatchGap: 9, interactive: false },
|
||||
{ kind: 'database' },
|
||||
{ kind: 'default', className: 'a-default', marker: 'arrowhead', swatchWidth: 34, swatchGap: 9, interactive: false },
|
||||
].map((entry) => ({
|
||||
...entry,
|
||||
label: i18nText(dataflow.meta.locale, `legend.dataflow.${entry.kind}`),
|
||||
}));
|
||||
|
||||
function renderLegend() {
|
||||
const presentKinds = new Set(asArray(dataflow.flows).map((flow) => flow.variant || 'default'));
|
||||
if ([...nodes.values()].some((node) => node.type === 'database')) presentKinds.add('database');
|
||||
const entries = resolveLegend(dataflow.meta?.legend, LEGEND_CATALOG, presentKinds);
|
||||
return renderResolvedLegend({
|
||||
entries,
|
||||
locale: dataflow.meta.locale,
|
||||
layout: {
|
||||
x: 40,
|
||||
baselineY: viewBox[1] - 36,
|
||||
width: viewBox[0] - 80,
|
||||
minTitleY: viewBox[1] - 66,
|
||||
unfit: dataflow.meta?.legend === undefined ? 'hide' : 'error',
|
||||
diagramType: 'dataflow',
|
||||
},
|
||||
renderSwatch: (entry) => entry.kind === 'database'
|
||||
? `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="c-database" stroke-width="1"/>`
|
||||
: `<path d="M ${entry.x} ${entry.baseline - 3} L ${entry.x + 34} ${entry.baseline - 3}" class="${entry.className}" stroke-width="${entry.strokeWidth || 1.4}" marker-end="url(#${entry.marker})"/>`,
|
||||
});
|
||||
}
|
||||
|
||||
function renderSvg() {
|
||||
// Same default-canvas contract as lifecycle: 940x720 is below the 1.55 wide
|
||||
// ratio, so without intrinsic-height the desktop Reader can neither narrow
|
||||
// nor scroll it and every default dataflow fails the browser gate.
|
||||
const readerFit = dataflow.meta?.viewBox ? '' : ' data-reader-fit="intrinsic-height"';
|
||||
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}"${readerFit} ${svgRootAttrs(dataflow.meta)}>
|
||||
${svgAccessibleText(dataflow.meta, 'dataflow')}
|
||||
${renderDefinitions()}
|
||||
|
||||
<!-- Background Grid -->
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
<!-- Data Stages -->
|
||||
${dataflow.stages.map(renderStage).join('\n\n')}
|
||||
|
||||
<!-- Flow paths -->
|
||||
${asArray(dataflow.flows).map(renderFlowPath).join('\n')}
|
||||
|
||||
<!-- Nodes -->
|
||||
${[...nodes.values()].map(renderNode).join('\n\n')}
|
||||
|
||||
<!-- Flow labels -->
|
||||
${asArray(dataflow.flows).map(renderFlowLabel).join('\n')}
|
||||
|
||||
<!-- Legend -->
|
||||
${renderLegend()}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
validateDataflow();
|
||||
writeDiagram({
|
||||
outPath,
|
||||
template,
|
||||
diagramType: 'dataflow',
|
||||
meta: dataflow.meta,
|
||||
svg: renderSvg(),
|
||||
cards: dataflow.cards,
|
||||
sourceEvidence,
|
||||
});
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
# Lifecycle Renderer
|
||||
|
||||
Render `diagram_type: "lifecycle"` JSON files into the standard Archify HTML
|
||||
template.
|
||||
|
||||
```bash
|
||||
node archify/renderers/lifecycle/render-lifecycle.mjs input.lifecycle.json output.html
|
||||
```
|
||||
|
||||
The renderer validates input against `archify/schemas/lifecycle.schema.json`
|
||||
with the bundled standalone validator. No dependency installation is required.
|
||||
|
||||
If `output.html` is omitted, the renderer uses the required `meta.output` value
|
||||
from the JSON file.
|
||||
|
||||
## Input
|
||||
|
||||
Lifecycle JSON files must set:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 2,
|
||||
"diagram_type": "lifecycle",
|
||||
"meta": {
|
||||
"title": "Deployment Release Lifecycle",
|
||||
"output": "deployment-release-lifecycle.html"
|
||||
},
|
||||
"lanes": [],
|
||||
"states": [],
|
||||
"transitions": [],
|
||||
"cards": []
|
||||
}
|
||||
```
|
||||
|
||||
`schema_version` is `1` or `2`; author new diagrams as `2`. Lane ids `main`
|
||||
(required) and `terminal` are reserved in both versions.
|
||||
|
||||
- **v2** renders one row per populated lane: `main` first, `terminal` last,
|
||||
other lanes in `lanes[]` order, each titled in the left gutter. A complete
|
||||
example lives at `archify/examples/deployment-release.lifecycle.json`.
|
||||
- **v1** keeps the fixed three bands: `main` is the top phase band,
|
||||
`terminal` the bottom outcome band, and every other lane shares the middle
|
||||
event band, whose header joins their labels with ` + `. A complete example
|
||||
lives at `archify/examples/agent-run.lifecycle.json`.
|
||||
|
||||
The schema lives at:
|
||||
|
||||
```text
|
||||
archify/schemas/lifecycle.schema.json
|
||||
```
|
||||
|
||||
## Legend and state marks
|
||||
|
||||
State color follows `states[].type`: active and start are cyan, waiting amber,
|
||||
decision purple, success green, failure rose, neutral and external slate.
|
||||
Structure is drawn, not colored: every `start` state gets a UML initial marker
|
||||
(a dot and arrow into its left side), and a state with no outgoing transition
|
||||
gets a double border as a final state (in v1, a main state followed by
|
||||
another main column is not final, because the implied rail continues).
|
||||
|
||||
The default legend derives kinds from `states[].type`; the `start` entry shows
|
||||
the initial marker, and a non-interactive `final` entry appears when a final
|
||||
state exists. Supported `meta.legend.entries` keys, in stable order, are
|
||||
`start`, `active`, `waiting`, `decision`, `success`, `failure`, `neutral`, and
|
||||
`external`. Labels and visibility may be overridden through the shared legend
|
||||
contract; only kinds backed by rendered states receive Semantic Legend
|
||||
controls.
|
||||
|
||||
State decorations share one top rail: the type sigil and `step` on the left,
|
||||
the brand mark at the right corner, and the Viewer's runtime source badge just
|
||||
left of the brand. Label layout reserves the badge's width whenever the state
|
||||
has verified repository sources.
|
||||
|
||||
## Layout budget (v2)
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Columns | `col` 0–4, one x grid shared by every row |
|
||||
| Default state | 140×64 (text 11px label, 8px sublabel and tag) |
|
||||
| Column gap | 64px, widened until a labelled same-row neighbour transition fits beside its line; all gaps shrink toward 44px when the canvas would exceed the desktop readability budget of the smallest state text |
|
||||
| Row gap | at least 120px, opened further for the horizontal tracks its routes need |
|
||||
| Canvas | sized from the rows, columns, and measured legend when `meta.viewBox` is omitted; an authored `viewBox` is honored and validated |
|
||||
|
||||
Transitions without `via`, a channel, or a non-`auto` route use the v2 grid
|
||||
router: neighbours in one row connect horizontally (a reciprocal pair runs as
|
||||
two parallel lines), rows connect through the facing top/bottom sides with one
|
||||
turn in a row gap, a state blocking a straight descent sends the route through
|
||||
the empty corridor between columns, and an unlabelled route blocked in an
|
||||
outer column loops around the outside of the grid. Each gap assigns tracks in
|
||||
the order that minimizes crossings. There is no implied rail; a forward
|
||||
transition between two `main` states without a `variant` renders as the
|
||||
emphasized primary path. Showcase labels are ranked beside their line, then on
|
||||
it, then outward past neighbouring parallels.
|
||||
|
||||
Explicit `fromSide` / `toSide` values remain authoritative. Pins that match the
|
||||
grid router's chosen sides keep its routes and adaptive row gaps. If an automatic
|
||||
transition pins a different side, the scene uses the shared side-aware obstacle
|
||||
planner, retaining the v2 state grid and shared port spreading.
|
||||
|
||||
## Layout budget (v1)
|
||||
|
||||
| Band | Lane id | Top y | Column centers | Default state |
|
||||
|------|---------|-------|----------------|---------------|
|
||||
| Phase | `main` (required) | 126 | `col` 0–4 → x = 94, 248, 402, 556, 710 | 118×62 |
|
||||
| Event | any other id | 278 | `col` 0–2 → x = 402, 556, 710 | 126×58 |
|
||||
| Outcome | `terminal` | 450 | `col` 0–2 → x = 402, 556, 710 | 118×58 |
|
||||
|
||||
Event and terminal columns are intentionally offset from the main rail:
|
||||
event/terminal `col: N` uses the same x coordinate as main `col: N + 2`.
|
||||
For example, lower-band columns 0, 1, and 2 align beneath main columns 2, 3,
|
||||
and 4 respectively.
|
||||
|
||||
| Constant | Value |
|
||||
|----------|-------|
|
||||
| viewBox | default `[980, 660]`; schema minimum `[420, 566]` |
|
||||
| State area | x within `[32, width − 32]`; state bottom at or above `height − 122` |
|
||||
| State spacing | ≥10px between any two states — checked across lanes, because all event lanes share one band; separate same-band states with `col` or `yOffset` |
|
||||
| Transition length | ≥32px between endpoints |
|
||||
| Legend row | final baseline y = height − 36; extra measured rows wrap upward |
|
||||
|
||||
The primary lifecycle rail runs along the phase band and extends to the
|
||||
furthest occupied phase column. Route presets for transitions: `straight`,
|
||||
`drop` (bend at `channelY`, defaulting to the vertical midpoint),
|
||||
`bottom-channel`, `top-channel`, `right-channel`, `left-channel`, explicit
|
||||
`via` points, or the default `auto`. Multi-segment transitions get rounded
|
||||
corners; tune them with `cornerRadius` (default 10, `0` for sharp bends).
|
||||
|
||||
Transition `label` and `note` are independently optional. A non-empty `note`
|
||||
renders even when `label` is omitted or empty, using its existing secondary
|
||||
text style on a single row and retaining the note's fine-detail visibility.
|
||||
With both fields present, the note stays below the label. Notes participate in
|
||||
automatic label placement, route-space reservation, and label collision checks;
|
||||
the existing `labelAt`, `labelDx`, `labelDy`, and
|
||||
`labelSegment` controls also position a note-only text block.
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Treat lifecycle diagrams as a phase map, not a dense state-transition graph.
|
||||
- Put the primary lifecycle on one horizontal row using the `main` lane; in v2,
|
||||
author each step of it as a transition.
|
||||
- In v2, place an interruption, recovery, or exit in the column of the state it
|
||||
leaves so its transition drops straight down.
|
||||
- Use `step` labels for ordered phases, such as `01`, `02`, and `03`.
|
||||
- Use lower lanes only for interruptions, recovery, and terminal exits.
|
||||
- Keep transition labels out of the main SVG unless the label is essential;
|
||||
prefer node labels, tags, legend entries, and summary cards.
|
||||
- Prefer axis-aligned lines and avoid crossings. Terminal exits should drop
|
||||
vertically from their source event whenever possible. Explicit `straight`
|
||||
routes remain supported; see the [authored routing contract](../../references/authoring-contract.md#executable-geometry-rules).
|
||||
- Use `success` for completion, `failure` for failure/terminal exits,
|
||||
`waiting` for pauses, and `decision` for quality gates.
|
||||
|
||||
Schema violations exit non-zero with path-prefixed messages annotated with the
|
||||
element's id or label. The renderer additionally fails when it can detect
|
||||
layout problems, including a missing `main` lane, duplicate state IDs, unknown
|
||||
lanes, unknown transition endpoints, states outside the lifecycle area,
|
||||
overlapping states (including across lanes), labels colliding with states or
|
||||
other labels, labels wider than their state, unreadably short transitions, or
|
||||
transitions crossing unrelated states (2px Clean Flow clearance). Lifecycle
|
||||
bands remain intentional pass-through containers.
|
||||
Text width is estimated CJK-aware: fullwidth glyphs count as two units.
|
||||
|
||||
Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
|
||||
X crossings then fail with `composition/proper-crossing`; default `standard`
|
||||
keeps them as artifact-receipt warnings. The final artifact check samples
|
||||
rounded `Q` corners. Collinear corridors remain outside the proper-X rule, but
|
||||
a separate gate warns in `standard` and fails in `showcase` when unrelated
|
||||
transitions overlap for at least 8px. Shared semantic endpoints, point touches,
|
||||
and shorter overlaps remain valid. Showcase also rejects any route segment
|
||||
below 8px and any interior turn segment below 16px; ordinary 8–15px endpoint
|
||||
stubs remain valid.
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
// Orthogonal router for schema_version 2 lifecycle diagrams.
|
||||
//
|
||||
// v2 states sit on a fixed grid: one row per lane and a shared column pitch,
|
||||
// with empty gaps between rows and between columns. That structure lets
|
||||
// every automatic transition use a small, predictable set of shapes instead
|
||||
// of a general obstacle search:
|
||||
// - neighbours in one row connect with a horizontal line;
|
||||
// - other states in one row connect through the gap above (below for the
|
||||
// first row);
|
||||
// - states in different rows leave through the facing top/bottom side,
|
||||
// turn once in a row gap, and enter the target's facing side; when a
|
||||
// state blocks the straight descent, the route steps sideways through the
|
||||
// empty corridor between two columns.
|
||||
// Ports on each side are spread in the order of where their routes head, and
|
||||
// the horizontal runs in each gap get their own tracks, ordered to minimize
|
||||
// crossings. Reciprocal pairs therefore render as two parallel lines.
|
||||
|
||||
const PORT_GUTTER = 16;
|
||||
const PORT_SPACING = 30;
|
||||
const SNAP_LIMIT = 16;
|
||||
const TRACK_TOP_CLEARANCE = 18;
|
||||
const TRACK_BOTTOM_CLEARANCE = 18;
|
||||
const PREFERRED_TRACK_SPACING = 22;
|
||||
const MAX_TRACK_SPACING = 24;
|
||||
const CORRIDOR_SPACING = 10;
|
||||
const EXHAUSTIVE_TRACK_LIMIT = 7;
|
||||
|
||||
const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
|
||||
|
||||
function permutations(items) {
|
||||
if (items.length <= 1) return [items];
|
||||
return items.flatMap((item, index) => permutations([...items.slice(0, index), ...items.slice(index + 1)])
|
||||
.map((rest) => [item, ...rest]));
|
||||
}
|
||||
|
||||
export function createLifecycleGridRouter(states, transitions, { rowOf, columnXs }) {
|
||||
const byRow = new Map();
|
||||
for (const state of states.values()) {
|
||||
const row = rowOf(state);
|
||||
if (!Number.isInteger(row)) continue;
|
||||
if (!byRow.has(row)) byRow.set(row, []);
|
||||
byRow.get(row).push(state);
|
||||
}
|
||||
const rows = [...byRow.keys()].sort((a, b) => a - b);
|
||||
const rowTop = new Map(rows.map((row) => [row, Math.min(...byRow.get(row).map((s) => s.y))]));
|
||||
const rowBottom = new Map(rows.map((row) => [row, Math.max(...byRow.get(row).map((s) => s.y + s.height))]));
|
||||
const nextRow = (row) => rows.find((candidate) => candidate > row);
|
||||
const previousRow = (row) => [...rows].reverse().find((candidate) => candidate < row);
|
||||
|
||||
// Gap g sits below row g. The gap under the last row borders the legend,
|
||||
// so it only receives tracks when nothing else is possible.
|
||||
function gapBand(row) {
|
||||
const below = nextRow(row);
|
||||
const top = rowBottom.get(row) + TRACK_TOP_CLEARANCE;
|
||||
const bottom = below === undefined ? rowBottom.get(row) + 40 : rowTop.get(below) - TRACK_BOTTOM_CLEARANCE;
|
||||
return [top, Math.max(top, bottom)];
|
||||
}
|
||||
|
||||
function blocksVertical(x, fromRow, toRow, exclude) {
|
||||
const [low, high] = fromRow < toRow ? [fromRow, toRow] : [toRow, fromRow];
|
||||
return [...states.values()].some((state) => {
|
||||
if (exclude.has(state.id)) return false;
|
||||
const row = rowOf(state);
|
||||
return row > low && row < high && x >= state.x - 6 && x <= state.x + state.width + 6;
|
||||
});
|
||||
}
|
||||
|
||||
function blocksHorizontal(from, to) {
|
||||
const row = rowOf(from);
|
||||
const [left, right] = from.cx < to.cx ? [from, to] : [to, from];
|
||||
return byRow.get(row).some((state) => state !== left && state !== right
|
||||
&& state.x < right.x && state.x + state.width > left.x + left.width
|
||||
&& state.y < Math.max(left.y + left.height, right.y + right.height)
|
||||
&& state.y + state.height > Math.min(left.y, right.y));
|
||||
}
|
||||
|
||||
// The empty corridors between neighbouring columns.
|
||||
const corridorXs = columnXs.slice(1).map((x, index) => (x + columnXs[index]) / 2);
|
||||
const allStates = [...states.values()];
|
||||
const leftmostX = Math.min(...allStates.map((state) => state.cx));
|
||||
const rightmostX = Math.max(...allStates.map((state) => state.cx));
|
||||
const gridLeft = Math.min(...allStates.map((state) => state.x));
|
||||
const gridRight = Math.max(...allStates.map((state) => state.x + state.width));
|
||||
// The initial-state marker occupies a start state's left side.
|
||||
function loopBlocked(from, to, side) {
|
||||
if (side !== 'left') return false;
|
||||
const [low, high] = [Math.min(rowOf(from), rowOf(to)), Math.max(rowOf(from), rowOf(to))];
|
||||
return allStates.some((state) => state.type === 'start' && Math.abs(state.cx - from.cx) < 1
|
||||
&& rowOf(state) >= low && rowOf(state) <= high);
|
||||
}
|
||||
|
||||
// Plan: sides, the gap each horizontal run uses, and where each end heads.
|
||||
const plans = new Map();
|
||||
for (const transition of transitions) {
|
||||
const from = states.get(transition.from);
|
||||
const to = states.get(transition.to);
|
||||
if (!from || !to || from === to) continue;
|
||||
const fromRow = rowOf(from);
|
||||
const toRow = rowOf(to);
|
||||
if (!Number.isInteger(fromRow) || !Number.isInteger(toRow)) continue;
|
||||
const exclude = new Set([from.id, to.id]);
|
||||
if (fromRow === toRow) {
|
||||
if (!blocksHorizontal(from, to)) {
|
||||
const fromSide = to.cx > from.cx ? 'right' : 'left';
|
||||
plans.set(transition, { kind: 'horizontal', from, to, fromSide, toSide: opposite[fromSide] });
|
||||
} else {
|
||||
const gapRow = previousRow(fromRow);
|
||||
const useAbove = gapRow !== undefined;
|
||||
const side = useAbove ? 'top' : 'bottom';
|
||||
plans.set(transition, {
|
||||
kind: 'channel', from, to, fromSide: side, toSide: side,
|
||||
runs: [{ gap: useAbove ? gapRow : fromRow, legs: useAbove ? ['down', 'down'] : ['up', 'up'] }],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const down = toRow > fromRow;
|
||||
const fromSide = down ? 'bottom' : 'top';
|
||||
const toSide = down ? 'top' : 'bottom';
|
||||
const gapNearTarget = down ? previousRow(toRow) : toRow;
|
||||
const gapNearSource = down ? fromRow : previousRow(fromRow);
|
||||
const legs = down ? ['up', 'down'] : ['down', 'up'];
|
||||
if (!blocksVertical(from.cx, fromRow, toRow, exclude) && Math.abs(from.cx - to.cx) < 1) {
|
||||
plans.set(transition, { kind: 'vertical', from, to, fromSide, toSide, runs: [{ gap: gapNearTarget, legs }] });
|
||||
} else if (!blocksVertical(from.cx, fromRow, toRow, exclude)) {
|
||||
plans.set(transition, { kind: 'channel', from, to, fromSide, toSide, runs: [{ gap: gapNearTarget, legs }] });
|
||||
} else if (!blocksVertical(to.cx, fromRow, toRow, exclude)) {
|
||||
plans.set(transition, { kind: 'channel', from, to, fromSide, toSide, runs: [{ gap: gapNearSource, legs }] });
|
||||
} else if (!transition.label && !transition.note
|
||||
&& Math.abs(from.cx - to.cx) < 1 && (from.cx <= leftmostX || from.cx >= rightmostX)
|
||||
&& !loopBlocked(from, to, from.cx <= leftmostX ? 'left' : 'right')) {
|
||||
// A blocked edge column loops around the outside of the grid, like a
|
||||
// bracket, instead of weaving through the rows' interior corridors.
|
||||
// The margin has no room for a label, so only unlabeled edges loop.
|
||||
const side = from.cx <= leftmostX ? 'left' : 'right';
|
||||
plans.set(transition, { kind: 'loop', from, to, fromSide: side, toSide: side, side });
|
||||
} else {
|
||||
const middle = (from.cx + to.cx) / 2;
|
||||
const corridor = corridorXs
|
||||
.filter((x) => !blocksVertical(x, fromRow, toRow, new Set()))
|
||||
.sort((a, b) => Math.abs(a - middle) - Math.abs(b - middle))[0];
|
||||
plans.set(transition, corridor === undefined
|
||||
? { kind: 'channel', from, to, fromSide, toSide, runs: [{ gap: gapNearTarget, legs }] }
|
||||
: {
|
||||
kind: 'corridor', from, to, fromSide, toSide, corridor,
|
||||
runs: [{ gap: gapNearSource, legs }, { gap: gapNearTarget, legs }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Corridor offsets: routes sharing one corridor run side by side.
|
||||
const corridorUse = new Map();
|
||||
for (const plan of plans.values()) {
|
||||
if (plan.kind !== 'corridor') continue;
|
||||
const list = corridorUse.get(plan.corridor) || [];
|
||||
list.push(plan);
|
||||
corridorUse.set(plan.corridor, list);
|
||||
}
|
||||
for (const [x, list] of corridorUse) {
|
||||
list.sort((a, b) => a.from.cx - b.from.cx || a.to.cx - b.to.cx);
|
||||
list.forEach((plan, index) => { plan.corridorX = x + (index - (list.length - 1) / 2) * CORRIDOR_SPACING; });
|
||||
}
|
||||
// Outer loops nest: a longer span sits further out so loops never cross.
|
||||
for (const side of ['left', 'right']) {
|
||||
const loops = [...plans.values()].filter((plan) => plan.kind === 'loop' && plan.side === side)
|
||||
.sort((a, b) => Math.abs(rowOf(a.from) - rowOf(a.to)) - Math.abs(rowOf(b.from) - rowOf(b.to)));
|
||||
loops.forEach((plan, index) => {
|
||||
plan.loopX = side === 'left' ? gridLeft - 18 - index * CORRIDOR_SPACING : gridRight + 18 + index * CORRIDOR_SPACING;
|
||||
});
|
||||
}
|
||||
|
||||
// Where each end heads after leaving its side, used to order the ports.
|
||||
function headingFor(plan, end) {
|
||||
const self = end === 'source' ? plan.from : plan.to;
|
||||
const other = end === 'source' ? plan.to : plan.from;
|
||||
if (plan.kind === 'horizontal' || plan.kind === 'loop') return other.cy;
|
||||
if (plan.kind === 'corridor') return plan.corridorX;
|
||||
return other === self ? self.cx : other.cx;
|
||||
}
|
||||
|
||||
const sideEnds = new Map();
|
||||
for (const [transition, plan] of plans) {
|
||||
for (const end of ['source', 'target']) {
|
||||
const state = end === 'source' ? plan.from : plan.to;
|
||||
const side = end === 'source' ? plan.fromSide : plan.toSide;
|
||||
const key = `${state.id}:${side}`;
|
||||
if (!sideEnds.has(key)) sideEnds.set(key, { state, side, ends: [] });
|
||||
// Movers in the positive direction (right/down) take the first slot so
|
||||
// both ends of a reciprocal pair line up.
|
||||
const positive = plan.kind === 'horizontal'
|
||||
? plan.to.cx > plan.from.cx
|
||||
: rowOf(plan.to) > rowOf(plan.from) || (rowOf(plan.to) === rowOf(plan.from) && plan.to.cx > plan.from.cx);
|
||||
sideEnds.get(key).ends.push({ transition, end, heading: headingFor(plan, end), positive });
|
||||
}
|
||||
}
|
||||
|
||||
const ports = new Map();
|
||||
for (const { state, side, ends } of sideEnds.values()) {
|
||||
ends.sort((a, b) => a.heading - b.heading || Number(b.positive) - Number(a.positive));
|
||||
const horizontalSide = side === 'top' || side === 'bottom';
|
||||
const length = horizontalSide ? state.width : state.height;
|
||||
const gutter = Math.min(PORT_GUTTER, length / 4);
|
||||
const spacing = ends.length > 1 ? Math.min(PORT_SPACING, (length - gutter * 2) / (ends.length - 1)) : 0;
|
||||
ends.forEach((entry, index) => {
|
||||
const offset = (index - (ends.length - 1) / 2) * spacing;
|
||||
const point = horizontalSide
|
||||
? [state.cx + offset, side === 'top' ? state.y : state.y + state.height]
|
||||
: [side === 'left' ? state.x : state.x + state.width, state.cy + offset];
|
||||
const record = ports.get(entry.transition) || {};
|
||||
record[entry.end] = point;
|
||||
ports.set(entry.transition, record);
|
||||
});
|
||||
}
|
||||
|
||||
// Straight connections whose spread ports landed a few px apart would
|
||||
// otherwise need a jog shorter than a readable turn: move one end onto the
|
||||
// other's line when that side still has room there.
|
||||
function sideRange(state, side) {
|
||||
return side === 'top' || side === 'bottom'
|
||||
? [state.x + PORT_GUTTER / 2, state.x + state.width - PORT_GUTTER / 2]
|
||||
: [state.y + PORT_GUTTER / 2, state.y + state.height - PORT_GUTTER / 2];
|
||||
}
|
||||
function portsOnSide(state, side, except) {
|
||||
return (sideEnds.get(`${state.id}:${side}`)?.ends || [])
|
||||
.filter((entry) => entry.transition !== except)
|
||||
.map((entry) => ports.get(entry.transition)[entry.end]);
|
||||
}
|
||||
for (const [transition, plan] of plans) {
|
||||
const record = ports.get(transition);
|
||||
const axis = plan.kind === 'horizontal' ? 1 : 0;
|
||||
const delta = Math.abs(record.source[axis] - record.target[axis]);
|
||||
if (delta < 0.5 || delta >= SNAP_LIMIT || !(plan.kind === 'horizontal' || plan.kind === 'vertical' || plan.kind === 'channel')) continue;
|
||||
for (const [end, fixed] of [['target', 'source'], ['source', 'target']]) {
|
||||
const state = end === 'source' ? plan.from : plan.to;
|
||||
const side = end === 'source' ? plan.fromSide : plan.toSide;
|
||||
const value = record[fixed][axis];
|
||||
const [low, high] = sideRange(state, side);
|
||||
const crowded = portsOnSide(state, side, transition).some((point) => Math.abs(point[axis] - value) < 10);
|
||||
if (value >= low && value <= high && !crowded) {
|
||||
record[end] = axis ? [record[end][0], value] : [value, record[end][1]];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal runs per gap, then tracks ordered to minimize crossings.
|
||||
const runsByGap = new Map();
|
||||
for (const [transition, plan] of plans) {
|
||||
if (plan.kind === 'horizontal' || plan.kind === 'loop') continue;
|
||||
const { source, target } = ports.get(transition);
|
||||
plan.runs.forEach((run, index) => {
|
||||
const x1 = index === 0 ? source[0] : plan.corridorX;
|
||||
const x2 = plan.kind === 'corridor' && index === 0 ? plan.corridorX : target[0];
|
||||
if (plan.kind !== 'corridor' && Math.abs(x1 - x2) < 0.5) return;
|
||||
const entry = { transition, index, x1, x2, legs: run.legs };
|
||||
if (!runsByGap.has(run.gap)) runsByGap.set(run.gap, []);
|
||||
runsByGap.get(run.gap).push(entry);
|
||||
});
|
||||
}
|
||||
|
||||
const trackY = new Map();
|
||||
const trackCounts = new Map();
|
||||
for (const [gap, runs] of runsByGap) {
|
||||
const [top, bottom] = gapBand(gap);
|
||||
// Greedy interval colouring keeps unrelated runs on shared tracks only
|
||||
// when they do not overlap.
|
||||
const sorted = [...runs].sort((a, b) => Math.min(a.x1, a.x2) - Math.min(b.x1, b.x2));
|
||||
const classes = [];
|
||||
for (const run of sorted) {
|
||||
const low = Math.min(run.x1, run.x2) - 8;
|
||||
const high = Math.max(run.x1, run.x2) + 8;
|
||||
let target = classes.find((members) => members.every((other) => (
|
||||
high < Math.min(other.x1, other.x2) - 8 || low > Math.max(other.x1, other.x2) + 8
|
||||
)));
|
||||
if (!target || runs.length <= EXHAUSTIVE_TRACK_LIMIT) {
|
||||
target = [];
|
||||
classes.push(target);
|
||||
}
|
||||
target.push(run);
|
||||
}
|
||||
const count = classes.length;
|
||||
trackCounts.set(gap, count);
|
||||
// The renderer sizes each gap for its track count; a fixed canvas that
|
||||
// cannot grow compresses the tracks rather than leaving the gap.
|
||||
const spacing = count > 1 ? Math.min(MAX_TRACK_SPACING, (bottom - top) / (count - 1)) : 0;
|
||||
const center = (top + bottom) / 2;
|
||||
const ys = classes.map((_, index) => center + (index - (count - 1) / 2) * spacing);
|
||||
const crossings = (order) => {
|
||||
const y = new Map();
|
||||
order.forEach((members, index) => members.forEach((run) => y.set(run, ys[index])));
|
||||
let total = 0;
|
||||
for (const a of runs) {
|
||||
for (const b of runs) {
|
||||
if (a === b || a.transition === b.transition) continue;
|
||||
const [low, high] = [Math.min(a.x1, a.x2), Math.max(a.x1, a.x2)];
|
||||
for (const [x, leg] of [[b.x1, b.legs[0]], [b.x2, b.legs[1]]]) {
|
||||
// An up leg and a down leg on one x overlap when the down leg
|
||||
// starts above where the up leg ends: that merges two routes.
|
||||
for (const [ax, aLeg] of [[a.x1, a.legs[0]], [a.x2, a.legs[1]]]) {
|
||||
if (Math.abs(ax - x) < 1 && aLeg === 'up' && leg === 'down' && y.get(b) < y.get(a)) total += 100;
|
||||
}
|
||||
if (x <= low + 0.5 || x >= high - 0.5) continue;
|
||||
if ((leg === 'up' && y.get(a) < y.get(b)) || (leg === 'down' && y.get(a) > y.get(b))) total += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
};
|
||||
let best = classes;
|
||||
let bestScore = crossings(best);
|
||||
if (count <= EXHAUSTIVE_TRACK_LIMIT) {
|
||||
for (const order of permutations(classes).slice(1)) {
|
||||
const score = crossings(order);
|
||||
if (score < bestScore) {
|
||||
best = order;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Too many tracks to enumerate: swap pairs while that still helps.
|
||||
for (let improved = true; improved;) {
|
||||
improved = false;
|
||||
for (let i = 0; i < count && !improved; i += 1) {
|
||||
for (let j = i + 1; j < count && !improved; j += 1) {
|
||||
const order = [...best];
|
||||
[order[i], order[j]] = [order[j], order[i]];
|
||||
const score = crossings(order);
|
||||
if (score < bestScore) {
|
||||
best = order;
|
||||
bestScore = score;
|
||||
improved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best.forEach((members, index) => members.forEach((run) => trackY.set(`${gap}:${run.index}:${transitions.indexOf(run.transition)}`, ys[index])));
|
||||
}
|
||||
|
||||
const pathCache = new Map();
|
||||
function pointsFor(transition) {
|
||||
const plan = plans.get(transition);
|
||||
if (!plan) {
|
||||
// Self transitions and unknown endpoints are rejected by validation;
|
||||
// a degenerate stub keeps that diagnostic reachable.
|
||||
const state = states.get(transition.from) || states.get(transition.to);
|
||||
const point = state ? [state.x + state.width, state.cy] : [0, 0];
|
||||
return [point, point];
|
||||
}
|
||||
const { source, target } = ports.get(transition);
|
||||
if (plan.kind === 'horizontal') {
|
||||
if (Math.abs(source[1] - target[1]) < 0.5) return [source, target];
|
||||
const x = (source[0] + target[0]) / 2;
|
||||
return [source, [x, source[1]], [x, target[1]], target];
|
||||
}
|
||||
if (plan.kind === 'loop') return [source, [plan.loopX, source[1]], [plan.loopX, target[1]], target];
|
||||
const trackFor = (index) => trackY.get(`${plan.runs[index].gap}:${index}:${transitions.indexOf(transition)}`);
|
||||
if (plan.kind === 'corridor') {
|
||||
const y1 = trackFor(0);
|
||||
const y2 = trackFor(1);
|
||||
return [source, [source[0], y1], [plan.corridorX, y1], [plan.corridorX, y2], [target[0], y2], target];
|
||||
}
|
||||
if (Math.abs(source[0] - target[0]) < 0.5) return [source, target];
|
||||
const y = trackFor(0);
|
||||
return [source, [source[0], y], [target[0], y], target];
|
||||
}
|
||||
|
||||
// Ports of two routes can still line up across a gap so their vertical
|
||||
// runs share one line. Nudge a turning route's end sideways on its side.
|
||||
for (const transition of plans.keys()) pathCache.set(transition, pointsFor(transition));
|
||||
const verticals = (points) => points.slice(1).flatMap((end, index) => {
|
||||
const start = points[index];
|
||||
if (Math.abs(start[0] - end[0]) >= 0.5 || Math.abs(start[1] - end[1]) < 0.5) return [];
|
||||
return [{ x: start[0], low: Math.min(start[1], end[1]), high: Math.max(start[1], end[1]), index, last: index === points.length - 2 }];
|
||||
});
|
||||
for (let round = 0; round < 12; round += 1) {
|
||||
const entries = [...pathCache.entries()];
|
||||
let conflict = null;
|
||||
for (let i = 0; i < entries.length && !conflict; i += 1) {
|
||||
for (let j = i + 1; j < entries.length && !conflict; j += 1) {
|
||||
for (const left of verticals(entries[i][1])) {
|
||||
const right = verticals(entries[j][1]).find((other) => Math.abs(other.x - left.x) < 1
|
||||
&& Math.min(other.high, left.high) - Math.max(other.low, left.low) > 0.5);
|
||||
if (right) {
|
||||
conflict = [[entries[i][0], left], [entries[j][0], right]];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!conflict) break;
|
||||
let moved = false;
|
||||
for (const [transition, segment] of conflict) {
|
||||
const plan = plans.get(transition);
|
||||
const end = segment.index === 0 ? 'source' : segment.last ? 'target' : null;
|
||||
if (!end || pathCache.get(transition).length < 4 || !['channel', 'corridor'].includes(plan.kind)) continue;
|
||||
const state = end === 'source' ? plan.from : plan.to;
|
||||
const side = end === 'source' ? plan.fromSide : plan.toSide;
|
||||
const record = ports.get(transition);
|
||||
const [low, high] = sideRange(state, side);
|
||||
const x = [12, -12, 20, -20].map((delta) => record[end][0] + delta).find((candidate) => (
|
||||
candidate >= low && candidate <= high
|
||||
&& !portsOnSide(state, side, transition).some((point) => Math.abs(point[0] - candidate) < 10)
|
||||
));
|
||||
if (x === undefined) continue;
|
||||
record[end] = [x, record[end][1]];
|
||||
pathCache.set(transition, pointsFor(transition));
|
||||
moved = true;
|
||||
break;
|
||||
}
|
||||
if (!moved) break;
|
||||
}
|
||||
|
||||
return {
|
||||
// Height a gap below `row` needs for its tracks at a readable spacing.
|
||||
gapHeight(row) {
|
||||
const count = trackCounts.get(row) || 0;
|
||||
return TRACK_TOP_CLEARANCE + TRACK_BOTTOM_CLEARANCE + Math.max(0, count - 1) * PREFERRED_TRACK_SPACING;
|
||||
},
|
||||
connectionSides(transition) {
|
||||
const plan = plans.get(transition);
|
||||
return plan ? { fromSide: plan.fromSide, toSide: plan.toSide } : { fromSide: 'right', toSide: 'right' };
|
||||
},
|
||||
pathFor(transition) {
|
||||
if (!pathCache.has(transition)) pathCache.set(transition, pointsFor(transition));
|
||||
return pathCache.get(transition);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,938 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
|
||||
import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
|
||||
import { recordDiagnostic, throwDiagnosticProblems } from '../shared/diagnostics.mjs';
|
||||
import { createRouter } from '../architecture/routing.mjs';
|
||||
import { createLifecycleGridRouter } from './grid-routing.mjs';
|
||||
import { placeAutomaticLabels, reservedLabelRect } from '../architecture/labels.mjs';
|
||||
import { legendFootprint, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
|
||||
import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth, nodeLabelLayout } from '../shared/text-fit.mjs';
|
||||
import { brandLabelFitWidth, brandMarkFor, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
|
||||
import { translateMessage as i18nText } from '../shared/i18n.mjs';
|
||||
import { DESKTOP_READER_DIAGRAM_WIDTH, MIN_PROJECTED_NODE_TEXT_PX } from '../shared/desktop-readability.mjs';
|
||||
import {
|
||||
asArray,
|
||||
isFinitePoint,
|
||||
rectsOverlap,
|
||||
cleanEndpointSideProblems,
|
||||
cleanFlowProblems,
|
||||
cleanCrossingProblems,
|
||||
cleanAmbiguousCorridorProblems,
|
||||
cleanBorderRunProblems,
|
||||
cleanRouteRhythmProblems,
|
||||
cleanLabelRouteClearanceProblems,
|
||||
cleanLabelCanvasContainmentProblems,
|
||||
suggestLabelObstacleFix,
|
||||
suggestLabelPairFix,
|
||||
anchor,
|
||||
automaticPortSpread,
|
||||
legacyDefaultFromSide as defaultFromSide,
|
||||
legacyDefaultToSide as defaultToSide,
|
||||
chosenSide,
|
||||
roundedPath,
|
||||
routePointsValue,
|
||||
authoredStraightRouteAttrs,
|
||||
labelPoint,
|
||||
arrowClassMap,
|
||||
edgeLabelAccent
|
||||
} from '../shared/geometry.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const { diagram: lifecycle, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
|
||||
rendererDir: __dirname,
|
||||
diagramType: 'lifecycle',
|
||||
defaultExample: 'agent-run.lifecycle.json'
|
||||
});
|
||||
|
||||
// v2 sets state text one step larger; its canvas width is then budgeted
|
||||
// from the smallest fitted text so the desktop Reader keeps it legible.
|
||||
const stateTextFit = lifecycle.schema_version === 2 ? {
|
||||
labelPreferred: 11,
|
||||
labelMinimum: 9,
|
||||
sublabelPreferred: 8,
|
||||
sublabelMinimum: 7,
|
||||
tagPreferred: 8,
|
||||
tagMinimum: 7,
|
||||
step: 8,
|
||||
} : {
|
||||
labelPreferred: 10,
|
||||
labelMinimum: 8,
|
||||
sublabelPreferred: 7,
|
||||
sublabelMinimum: 6,
|
||||
tagPreferred: 7,
|
||||
tagMinimum: 6,
|
||||
step: 7,
|
||||
};
|
||||
|
||||
// schema_version 2 replaces the three fixed bands (main/event/outcome, with
|
||||
// non-main lanes sharing one band and lower columns offset by +2) with one
|
||||
// row per populated lane on a shared 0..4 column grid. v1 keeps its exact
|
||||
// state geometry; only presentation (colors, markers, legend, sigil side)
|
||||
// is shared between versions.
|
||||
const isV2 = lifecycle.schema_version === 2;
|
||||
const authoredViewBox = lifecycle.meta?.viewBox;
|
||||
|
||||
const layout = {
|
||||
phaseY: 126,
|
||||
eventY: 278,
|
||||
outcomeY: 450,
|
||||
phaseW: 118,
|
||||
phaseH: 62,
|
||||
eventW: 126,
|
||||
eventH: 58,
|
||||
outcomeW: 118,
|
||||
outcomeH: 58,
|
||||
phaseXs: [94, 248, 402, 556, 710],
|
||||
eventXs: [402, 556, 710],
|
||||
outcomeXs: [402, 556, 710]
|
||||
};
|
||||
|
||||
const layoutV2 = {
|
||||
stateW: 140,
|
||||
stateH: 64,
|
||||
marginX: 60,
|
||||
minGap: 64,
|
||||
floorGap: 44,
|
||||
firstRowTop: 56,
|
||||
rowPitch: 184,
|
||||
};
|
||||
|
||||
function transitionLabelWidth(transition) {
|
||||
const longestLine = Math.max(textUnits(transition.label), textUnits(transition.note || ''));
|
||||
return Math.max(32, longestLine * 4.9 + 12);
|
||||
}
|
||||
|
||||
function stateFontSizes(state, width) {
|
||||
return {
|
||||
label: fittedNodeFontSize(state.label, brandLabelFitWidth(state, width), stateTextFit.labelPreferred, stateTextFit.labelMinimum),
|
||||
sublabel: fittedNodeFontSize(state.sublabel, width, stateTextFit.sublabelPreferred, stateTextFit.sublabelMinimum),
|
||||
tag: fittedNodeFontSize(state.tag, width, stateTextFit.tagPreferred, stateTextFit.tagMinimum),
|
||||
};
|
||||
}
|
||||
|
||||
// v2 column centers. A gap between two columns widens until the label of a
|
||||
// same-row neighbour transition fits beside its line; the whole canvas then
|
||||
// stays inside the desktop readability budget of its smallest state text.
|
||||
const v2ColumnCenters = (() => {
|
||||
if (!isV2) return [];
|
||||
const authored = asArray(lifecycle.states);
|
||||
const widths = [0, 1, 2, 3, 4].map((col) => Math.max(
|
||||
layoutV2.stateW, ...authored.filter((state) => state.col === col).map((state) => state.width || 0),
|
||||
));
|
||||
const cols = authored.map((state) => state.col).filter((col) => Number.isInteger(col) && col >= 0 && col <= 4);
|
||||
const lastCol = cols.length ? Math.max(...cols) : 0;
|
||||
const gaps = [0, 1, 2, 3].map(() => layoutV2.minGap);
|
||||
const byId = new Map(authored.map((state) => [state.id, state]));
|
||||
for (const transition of asArray(lifecycle.transitions)) {
|
||||
const [from, to] = [byId.get(transition.from), byId.get(transition.to)];
|
||||
if (!(transition.label || transition.note) || !from || !to || from.lane !== to.lane
|
||||
|| !Number.isInteger(from.col) || !Number.isInteger(to.col) || Math.abs(from.col - to.col) !== 1) continue;
|
||||
const gap = Math.min(from.col, to.col);
|
||||
if (gap >= 0 && gap < 4) gaps[gap] = Math.max(gaps[gap], Math.ceil(transitionLabelWidth(transition) + 24));
|
||||
}
|
||||
const smallestText = Math.min(stateTextFit.step, ...authored.flatMap((state) => (
|
||||
Object.values(stateFontSizes(state, state.width || layoutV2.stateW))
|
||||
)));
|
||||
const budget = Math.floor(DESKTOP_READER_DIAGRAM_WIDTH * smallestText / MIN_PROJECTED_NODE_TEXT_PX);
|
||||
const fixed = layoutV2.marginX * 2 + widths.slice(0, lastCol + 1).reduce((sum, width) => sum + width, 0);
|
||||
const used = gaps.slice(0, lastCol);
|
||||
const excess = fixed + used.reduce((sum, gap) => sum + gap, 0) - budget;
|
||||
const slack = used.reduce((sum, gap) => sum + gap - layoutV2.floorGap, 0);
|
||||
if (excess > 0 && slack > 0) {
|
||||
const ratio = Math.min(1, excess / slack);
|
||||
for (let index = 0; index < used.length; index += 1) {
|
||||
gaps[index] = Math.floor(gaps[index] - (gaps[index] - layoutV2.floorGap) * ratio);
|
||||
}
|
||||
}
|
||||
const centers = [];
|
||||
let x = layoutV2.marginX;
|
||||
for (let col = 0; col <= 4; col += 1) {
|
||||
centers.push(x + widths[col] / 2);
|
||||
x += widths[col] + (gaps[col] ?? 0);
|
||||
}
|
||||
return centers;
|
||||
})();
|
||||
|
||||
// Rows render in authored lane order with `main` first and `terminal` last;
|
||||
// lanes without states get no row and no title.
|
||||
function laneRowOrder() {
|
||||
const lanes = asArray(lifecycle.lanes);
|
||||
const populated = new Set(asArray(lifecycle.states).map((state) => state.lane));
|
||||
const ordered = [];
|
||||
if (populated.has('main')) ordered.push('main');
|
||||
for (const lane of lanes) {
|
||||
if (lane.id !== 'main' && lane.id !== 'terminal' && populated.has(lane.id)) ordered.push(lane.id);
|
||||
}
|
||||
if (populated.has('terminal')) ordered.push('terminal');
|
||||
return ordered;
|
||||
}
|
||||
|
||||
const v2RowTop = new Map(isV2
|
||||
? laneRowOrder().map((laneId, index) => [laneId, layoutV2.firstRowTop + index * layoutV2.rowPitch])
|
||||
: []);
|
||||
|
||||
const typeClass = {
|
||||
start: 'c-frontend',
|
||||
active: 'c-frontend',
|
||||
waiting: 'c-cloud',
|
||||
decision: 'c-database',
|
||||
success: 'c-backend',
|
||||
failure: 'c-security',
|
||||
neutral: 'c-external',
|
||||
external: 'c-external'
|
||||
};
|
||||
|
||||
const textClass = {
|
||||
start: 't-frontend',
|
||||
active: 't-frontend',
|
||||
waiting: 't-cloud',
|
||||
decision: 't-database',
|
||||
success: 't-backend',
|
||||
failure: 't-security',
|
||||
neutral: 't-muted',
|
||||
external: 't-muted'
|
||||
};
|
||||
|
||||
// Lane semantics are fixed: lane id "main" maps to the top phase band, lane id
|
||||
// "terminal" maps to the bottom outcome band, and every other lane shares the
|
||||
// middle event band (separated visually via yOffset). v1 only.
|
||||
function bandFor(lane) {
|
||||
if (lane === 'main') return 'phase';
|
||||
if (lane === 'terminal') return 'outcome';
|
||||
return 'event';
|
||||
}
|
||||
|
||||
function measureState(state) {
|
||||
let width;
|
||||
let height;
|
||||
let cx;
|
||||
let y;
|
||||
if (isV2) {
|
||||
width = state.width || layoutV2.stateW;
|
||||
height = state.height || layoutV2.stateH;
|
||||
cx = v2ColumnCenters[state.col] ?? NaN;
|
||||
y = (v2RowTop.get(state.lane) ?? NaN) + (state.yOffset || 0);
|
||||
} else {
|
||||
const isPhase = bandFor(state.lane) === 'phase';
|
||||
const isOutcome = bandFor(state.lane) === 'outcome';
|
||||
width = state.width || (isPhase ? layout.phaseW : isOutcome ? layout.outcomeW : layout.eventW);
|
||||
height = state.height || (isPhase ? layout.phaseH : isOutcome ? layout.outcomeH : layout.eventH);
|
||||
const xs = isPhase ? layout.phaseXs : isOutcome ? layout.outcomeXs : layout.eventXs;
|
||||
cx = xs[state.col] ?? xs[xs.length - 1];
|
||||
y = (
|
||||
isPhase ? layout.phaseY :
|
||||
isOutcome ? layout.outcomeY :
|
||||
layout.eventY
|
||||
) + (state.yOffset || 0);
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
width,
|
||||
height,
|
||||
x: cx - width / 2,
|
||||
y,
|
||||
cx,
|
||||
cy: y + height / 2
|
||||
};
|
||||
}
|
||||
|
||||
const states = new Map(asArray(lifecycle.states).map((state) => [state.id, measureState(state)]));
|
||||
const plannedTransitions = asArray(lifecycle.transitions).filter(plannerRouted);
|
||||
const v2Rows = laneRowOrder();
|
||||
const v2RowOf = (state) => (v2Rows.includes(state.lane) ? v2Rows.indexOf(state.lane) : undefined);
|
||||
let useGridRouter = isV2;
|
||||
if (isV2) {
|
||||
// Route once to learn how many horizontal tracks each row gap carries,
|
||||
// then open every gap to fit them before the final routing pass.
|
||||
const probe = createLifecycleGridRouter(states, plannedTransitions, {
|
||||
rowOf: v2RowOf, columnXs: v2ColumnCenters,
|
||||
});
|
||||
// Compatible pins keep the grid layout. A conflicting pin sends the whole
|
||||
// scene to the side-aware planner so all edges still share port spreading
|
||||
// and obstacle reservations.
|
||||
useGridRouter = plannedTransitions.every(transition => {
|
||||
const sides = probe.connectionSides(transition);
|
||||
return ['fromSide', 'toSide'].every(key => !transition[key] || transition[key] === 'auto' || transition[key] === sides[key]);
|
||||
});
|
||||
let top = layoutV2.firstRowTop;
|
||||
v2Rows.forEach((laneId, index) => {
|
||||
const rowHeight = Math.max(layoutV2.stateH, ...[...states.values()]
|
||||
.filter((state) => state.lane === laneId)
|
||||
.map((state) => state.y + state.height - v2RowTop.get(laneId)));
|
||||
v2RowTop.set(laneId, top);
|
||||
top += rowHeight + Math.max(layoutV2.rowPitch - layoutV2.stateH, useGridRouter ? probe.gapHeight(index) : 0);
|
||||
});
|
||||
for (const state of asArray(lifecycle.states)) states.set(state.id, measureState(state));
|
||||
}
|
||||
const laneLabels = new Map(asArray(lifecycle.lanes).map((lane) => [lane.id, lane.label]));
|
||||
const authoredOutgoing = new Set(asArray(lifecycle.transitions).map((transition) => transition.from));
|
||||
|
||||
// A state with no authored outgoing transition is terminal in the UML sense.
|
||||
// v1 main states rely on the implied phase rail, so a main state is only final
|
||||
// at the furthest occupied main column; every other v1 lane is explicit.
|
||||
function isFinal(state) {
|
||||
if (authoredOutgoing.has(state.id)) return false;
|
||||
if (isV2) return true;
|
||||
if (bandFor(state.lane) !== 'phase') return true;
|
||||
const mainCols = [...states.values()].filter((s) => bandFor(s.lane) === 'phase').map((s) => s.col);
|
||||
return state.col === Math.max(...mainCols);
|
||||
}
|
||||
|
||||
const LEGEND_CATALOG = [
|
||||
'start',
|
||||
'active',
|
||||
'waiting',
|
||||
'decision',
|
||||
'success',
|
||||
'failure',
|
||||
'neutral',
|
||||
'external',
|
||||
].map((kind) => ({
|
||||
kind,
|
||||
label: i18nText(lifecycle.meta.locale, `legend.lifecycle.${kind}`),
|
||||
swatchWidth: kind === 'start' ? 26 : undefined,
|
||||
}));
|
||||
|
||||
// Entries exist before the canvas so an auto-sized v2 viewBox can reserve the
|
||||
// measured legend rows below the last row instead of painting over them.
|
||||
// The structural `final` entry explains the double border; it follows the
|
||||
// kind entries and disappears with them, so a hidden legend stays empty.
|
||||
function legendCatalog() {
|
||||
const presentKinds = new Set([...states.values()].map((state) => state.type));
|
||||
const entries = resolveLegend(lifecycle.meta?.legend, LEGEND_CATALOG, presentKinds);
|
||||
if (!entries.length || ![...states.values()].some(isFinal)) return entries;
|
||||
return [...entries, {
|
||||
kind: 'final',
|
||||
label: i18nText(lifecycle.meta.locale, 'legend.lifecycle.final'),
|
||||
interactive: false,
|
||||
present: true,
|
||||
swatchWidth: 16,
|
||||
}];
|
||||
}
|
||||
|
||||
const resolvedLegendEntries = legendCatalog();
|
||||
|
||||
let viewBox;
|
||||
if (isV2 && !authoredViewBox) {
|
||||
const finite = [...states.values()];
|
||||
const maxRight = Math.max(0, ...finite.map((state) => state.cx + state.width / 2).filter(Number.isFinite));
|
||||
const width = Math.max(640, Math.ceil(maxRight + layoutV2.marginX));
|
||||
const footprint = legendFootprint(resolvedLegendEntries, { width: width - 80 });
|
||||
const statesBottom = Math.max(0, ...finite.map((state) => state.y + state.height).filter(Number.isFinite));
|
||||
viewBox = [width, Math.ceil(statesBottom + footprint.extraHeight + 96)];
|
||||
} else {
|
||||
viewBox = authoredViewBox || [980, 660];
|
||||
}
|
||||
|
||||
const legendExtraHeight = legendFootprint(resolvedLegendEntries, { width: viewBox[0] - 80 }).extraHeight;
|
||||
|
||||
function legendY() {
|
||||
return viewBox[1] - 36;
|
||||
}
|
||||
|
||||
// Keep the authored state-placement contract independent from the measured
|
||||
// legend's lower baseline. Moving legend chrome must not admit new state
|
||||
// geometry into the reserved outcome/legend band.
|
||||
function lifecycleAreaBottom() {
|
||||
return isV2 ? viewBox[1] - legendExtraHeight - 96 : viewBox[1] - 122;
|
||||
}
|
||||
const stateSteps = new Map();
|
||||
for (const [index, transition] of asArray(lifecycle.transitions).entries()) {
|
||||
if (!stateSteps.has(transition.from)) stateSteps.set(transition.from, index);
|
||||
if (!stateSteps.has(transition.to)) stateSteps.set(transition.to, index + 1);
|
||||
}
|
||||
for (const [index, state] of asArray(lifecycle.states).entries()) {
|
||||
if (!stateSteps.has(state.id)) stateSteps.set(state.id, index);
|
||||
}
|
||||
|
||||
function validateLifecycle() {
|
||||
const problems = [];
|
||||
if (states.size !== asArray(lifecycle.states).length) problems.push('State ids must be unique.');
|
||||
|
||||
// The three bands are fixed at y=112/264/436. Preserve the original
|
||||
// outcome/legend reserve even though measured legend rows now sit lower.
|
||||
// v2 derives its canvas from rendered rows, so the floor does not apply.
|
||||
if (!isV2 && lifecycleAreaBottom() + 4 < 448) {
|
||||
problems.push(`viewBox height ${viewBox[1]} is too short for the fixed band layout — set meta.viewBox[1] to at least 566.`);
|
||||
}
|
||||
|
||||
const laneIds = new Set(asArray(lifecycle.lanes).map((lane) => lane.id));
|
||||
if (laneIds.size !== asArray(lifecycle.lanes).length) problems.push('Lane ids must be unique.');
|
||||
if (!laneIds.has('main')) {
|
||||
problems.push(isV2
|
||||
? 'Lifecycle diagrams need a lane with id "main" (the first row). Lane ids "main" and "terminal" are reserved: "main" renders as the first row, "terminal" as the last, and every other lane in authored order.'
|
||||
: 'Lifecycle diagrams need a lane with id "main" (the phase rail). Lane ids "main" and "terminal" are reserved: "main" maps to the top phase band, "terminal" to the bottom outcome band, and all other lanes share the middle event band.');
|
||||
}
|
||||
|
||||
for (const state of states.values()) {
|
||||
if (!laneIds.has(state.lane)) {
|
||||
problems.push(`State "${state.id}" uses unknown lane "${state.lane}".`);
|
||||
continue;
|
||||
}
|
||||
if (isV2) {
|
||||
if (!Number.isInteger(state.col) || state.col < 0 || state.col > 4) {
|
||||
problems.push(`State "${state.id}" uses invalid column ${state.col} — every lifecycle row has integer columns 0..4.`);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
const band = bandFor(state.lane);
|
||||
const maxCol = band === 'phase'
|
||||
? layout.phaseXs.length
|
||||
: band === 'outcome'
|
||||
? layout.outcomeXs.length
|
||||
: layout.eventXs.length;
|
||||
if (!Number.isInteger(state.col) || state.col < 0 || state.col >= maxCol) {
|
||||
problems.push(`State "${state.id}" uses invalid column ${state.col} — the ${band} band has integer columns 0..${maxCol - 1}.`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!isFinitePoint(state.x, state.y, state.cx, state.cy)) {
|
||||
problems.push(`State "${state.id}" produced non-finite coordinates — check col, width, height, and yOffset are numbers.`);
|
||||
continue;
|
||||
}
|
||||
if (state.x < (isV2 ? 28 : 32) || state.x + state.width > viewBox[0] - (isV2 ? 28 : 32)) {
|
||||
problems.push(`State "${state.id}" exceeds the horizontal bounds of the diagram — reduce state.width${isV2 ? ', lower its col,' : ''} or increase meta.viewBox[0].`);
|
||||
}
|
||||
if (state.y < (isV2 ? 44 : 64) || state.y + state.height > lifecycleAreaBottom()) {
|
||||
problems.push(`State "${state.id}" exceeds the vertical lifecycle area — keep y between ${isV2 ? 44 : 64} and ${lifecycleAreaBottom()} (adjust yOffset or increase meta.viewBox[1]).`);
|
||||
}
|
||||
const estLabelW = textUnits(state.label) * 6.2;
|
||||
if (estLabelW > state.width + 6) {
|
||||
problems.push(`Label "${state.label}" (~${Math.round(estLabelW)}px) is wider than state "${state.id}" (${state.width}px) — shorten the label or increase state.width.`);
|
||||
}
|
||||
const brandRailProblem = brandTopRailProblem(state, state.width, 8, 'State');
|
||||
if (brandRailProblem) problems.push(brandRailProblem);
|
||||
// sublabel and tag render as single unwrapped <text> elements; shrink-to-fit
|
||||
// handles the ordinary case, this rejects what it cannot rescue.
|
||||
const availableTextW = availableNodeTextWidth(state.width);
|
||||
for (const [field, value, minimum] of [
|
||||
['Sublabel', state.sublabel, stateTextFit.sublabelMinimum],
|
||||
['Tag', state.tag, stateTextFit.tagMinimum],
|
||||
]) {
|
||||
if (!value) continue;
|
||||
const minimumW = minimumNodeTextWidth(value, minimum);
|
||||
if (minimumW > availableTextW) {
|
||||
problems.push(`${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but state "${state.id}" provides ${availableTextW}px — shorten the ${field.toLowerCase()} or increase state.width.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v1: all non-main/non-terminal lanes share the same y band, so the overlap
|
||||
// check must run across lanes — not per-lane. v2 gives each lane its own
|
||||
// row, so only same-row neighbours can collide, but the shared check still
|
||||
// covers custom widths and yOffset nudges.
|
||||
const allStates = [...states.values()];
|
||||
for (let i = 0; i < allStates.length; i += 1) {
|
||||
for (let j = i + 1; j < allStates.length; j += 1) {
|
||||
if (rectsOverlap(allStates[i], allStates[j], 10)) {
|
||||
problems.push(`States "${allStates[i].id}" and "${allStates[j].id}" are less than 10px apart — move one to another col or separate them with yOffset${isV2 ? '.' : ' (lanes other than "main"/"terminal" share one band).'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const transition of asArray(lifecycle.transitions)) {
|
||||
if (!states.has(transition.from)) problems.push(`Transition "${transition.label || transition.from}" references unknown source "${transition.from}".`);
|
||||
if (!states.has(transition.to)) problems.push(`Transition "${transition.label || transition.to}" references unknown target "${transition.to}".`);
|
||||
if (states.has(transition.from) && states.has(transition.to)) {
|
||||
const routed = pathFor(transition);
|
||||
const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
|
||||
const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
|
||||
if (distance < 32) problems.push(`Transition "${transition.label || `${transition.from}->${transition.to}`}" is too short (${Math.round(distance)}px; minimum 32px) — route it through a channel or drop its label.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Authored via points are authoritative in schema v1, including under a
|
||||
// quality profile. Preserve and render them exactly: applying the endpoint
|
||||
// gate would either reject an existing typed input or require silently
|
||||
// falsifying its geometry. Automatic routes still receive the side gate.
|
||||
problems.push(...cleanEndpointSideProblems({
|
||||
relations: lifecycle.transitions,
|
||||
endpointIds: new Set(states.keys()),
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
fromSideFor: (transition) => transitionSides(transition).fromSide,
|
||||
toSideFor: (transition) => transitionSides(transition).toSide,
|
||||
shouldCheckRelation: (transition) => !Array.isArray(transition.via),
|
||||
routeHint: 'keep automatic routing, or choose fromSide/toSide and via points whose first and final segments cross state borders perpendicularly',
|
||||
}));
|
||||
problems.push(...cleanFlowProblems({
|
||||
relations: lifecycle.transitions,
|
||||
obstacles: states.values(),
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
obstacleKind: 'state',
|
||||
routeHint: 'adjust fromSide/toSide, set route/via or channelX/channelY, or move the state with col/yOffset'
|
||||
}));
|
||||
problems.push(...cleanCrossingProblems({
|
||||
relations: lifecycle.transitions,
|
||||
endpointIds: new Set(states.keys()),
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
profile: lifecycle.meta?.quality_profile,
|
||||
// Planner routes render with the opaque crossover halo, like architecture.
|
||||
crossingResolved: (left, right) => plannerRouted(left) && plannerRouted(right),
|
||||
routeHint: 'adjust route/via or channelX/channelY so the transitions use separate lifecycle corridors'
|
||||
}));
|
||||
problems.push(...cleanAmbiguousCorridorProblems({
|
||||
relations: lifecycle.transitions,
|
||||
endpointIds: new Set(states.keys()),
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
profile: lifecycle.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via or channelX/channelY so unrelated transitions do not visually merge'
|
||||
}));
|
||||
// Lifecycle bands are dashed reading guides, not closed containers. Keep the
|
||||
// shared contract wired with an explicit empty frame set so future typed
|
||||
// lifecycle containers cannot accidentally inherit presentation geometry.
|
||||
problems.push(...cleanBorderRunProblems({
|
||||
relations: lifecycle.transitions,
|
||||
endpointIds: new Set(states.keys()),
|
||||
frames: [],
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
profile: lifecycle.meta?.quality_profile
|
||||
}));
|
||||
problems.push(...cleanRouteRhythmProblems({
|
||||
relations: lifecycle.transitions,
|
||||
endpointIds: new Set(states.keys()),
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
profile: lifecycle.meta?.quality_profile,
|
||||
routeHint: 'move route/via or channel coordinates so each lifecycle turn has a readable run-up'
|
||||
}));
|
||||
|
||||
const labelRects = transitionLabelRects();
|
||||
if (lifecycle.meta?.quality_profile === 'showcase') {
|
||||
for (const rect of labelRects) {
|
||||
for (const title of bandGeometry()) {
|
||||
if (!rectsOverlap(rect, title)) continue;
|
||||
const message = `Transition ${rect.relationIndex} label "${rect.label}" overlaps lifecycle band title "${title.label}" — move the label with labelAt/labelDx/labelDy/labelSegment or provide more space.`;
|
||||
recordDiagnostic({
|
||||
code: 'composition/label-band-title-overlap', severity: 'error', message,
|
||||
subject: { diagramType: 'lifecycle', collection: 'transitions', index: rect.relationIndex, from: rect.relation.from, to: rect.relation.to },
|
||||
evidence: { labelRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, bandTitle: title },
|
||||
supportedFixes: ['move the transition label with labelAt/labelDx/labelDy/labelSegment while preserving its text'],
|
||||
});
|
||||
problems.push(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const rect of labelRects) {
|
||||
for (const state of states.values()) {
|
||||
if (rectsOverlap(rect, state, -2)) {
|
||||
problems.push(`Label "${rect.label}" overlaps state "${state.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, state, 'state', viewBox, states.values())}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < labelRects.length; i += 1) {
|
||||
for (let j = i + 1; j < labelRects.length; j += 1) {
|
||||
if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
|
||||
problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — adjust labelDx/labelDy.\n${suggestLabelPairFix(labelRects[i], labelRects[j])}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
problems.push(...cleanLabelRouteClearanceProblems({
|
||||
relations: lifecycle.transitions,
|
||||
labels: labelRects,
|
||||
endpointIds: new Set(states.keys()),
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
profile: lifecycle.meta?.quality_profile,
|
||||
}));
|
||||
problems.push(...cleanLabelCanvasContainmentProblems({
|
||||
labels: labelRects,
|
||||
viewBox,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
profile: lifecycle.meta?.quality_profile,
|
||||
}));
|
||||
|
||||
if (problems.length) {
|
||||
throwDiagnosticProblems('Lifecycle layout validation failed', problems, {
|
||||
subject: { diagramType: 'lifecycle' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function routeVia(transition, from, to, start, end, fromSide, toSide) {
|
||||
if (transition.via) return transition.via;
|
||||
switch (transition.route || 'auto') {
|
||||
case 'straight':
|
||||
return [];
|
||||
case 'drop': {
|
||||
const y = transition.channelY ?? (start[1] + end[1]) / 2;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
case 'bottom-channel': {
|
||||
const y = transition.channelY ?? Math.max(from.y + from.height, to.y + to.height) + 34;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
case 'top-channel': {
|
||||
const y = transition.channelY ?? Math.min(from.y, to.y) - 28;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
case 'right-channel': {
|
||||
const x = transition.channelX ?? Math.max(from.x + from.width, to.x + to.width) + 36;
|
||||
return [[x, start[1]], [x, end[1]]];
|
||||
}
|
||||
case 'left-channel': {
|
||||
const x = transition.channelX ?? Math.min(from.x, to.x) - 36;
|
||||
return [[x, start[1]], [x, end[1]]];
|
||||
}
|
||||
case 'auto':
|
||||
default: {
|
||||
if (start[0] === end[0] || start[1] === end[1]) return [];
|
||||
const fromVertical = fromSide === 'top' || fromSide === 'bottom';
|
||||
const toVertical = toSide === 'top' || toSide === 'bottom';
|
||||
if (fromVertical !== toVertical) {
|
||||
return [fromVertical ? [start[0], end[1]] : [end[0], start[1]]];
|
||||
}
|
||||
if (fromVertical) {
|
||||
const y = transition.channelY ?? (start[1] + end[1]) / 2;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
const x = transition.channelX ?? (start[0] + end[0]) / 2;
|
||||
return [[x, start[1]], [x, end[1]]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pathCache = new Map();
|
||||
|
||||
// A transition without via, channel, or a lifecycle route preset is routed by
|
||||
// the obstacle-aware planner shared with architecture, so a first draft that
|
||||
// leaves routing to the renderer does not cross unrelated states or produce
|
||||
// micro jogs. Authored via/route/channel geometry keeps the lifecycle presets.
|
||||
function plannerRouted(transition) {
|
||||
return !transition.via
|
||||
&& (!transition.route || transition.route === 'auto')
|
||||
&& transition.channelX === undefined
|
||||
&& transition.channelY === undefined;
|
||||
}
|
||||
|
||||
// v2 states sit on a fixed row/column grid, so automatic transitions use the
|
||||
// dedicated orthogonal grid router; v1 keeps the shared obstacle planner.
|
||||
const planner = useGridRouter ? createLifecycleGridRouter(states, plannedTransitions, {
|
||||
rowOf: v2RowOf,
|
||||
columnXs: v2ColumnCenters,
|
||||
}) : createRouter(states, plannedTransitions, {
|
||||
labelRectFor: (transition, points, { routes, labels }) => ((transition.label || transition.note) ? reservedLabelRect({
|
||||
label: { relation: transition, label: transition.label || transition.note, ...transitionLabelBoxAt(transition, labelPoint(transition, points)) },
|
||||
points,
|
||||
routes: routes.map((route, index) => ({ relationIndex: index, points: route })),
|
||||
labels,
|
||||
components: [...states.values()],
|
||||
viewBox,
|
||||
placementBottom: lifecycleAreaBottom(),
|
||||
}) : null),
|
||||
});
|
||||
|
||||
function transitionSides(transition) {
|
||||
if (plannerRouted(transition)) return planner.connectionSides(transition);
|
||||
const from = states.get(transition.from);
|
||||
const to = states.get(transition.to);
|
||||
return {
|
||||
fromSide: chosenSide(transition.fromSide, defaultFromSide(from, to)),
|
||||
toSide: chosenSide(transition.toSide, defaultToSide(from, to)),
|
||||
};
|
||||
}
|
||||
|
||||
const automaticPorts = automaticPortSpread(
|
||||
asArray(lifecycle.transitions).filter((transition) => !plannerRouted(transition)),
|
||||
states,
|
||||
{ sideFor: (transition, endpoint) => transitionSides(transition)[endpoint === 'source' ? 'fromSide' : 'toSide'] },
|
||||
);
|
||||
|
||||
function pathFor(transition) {
|
||||
if (pathCache.has(transition)) return pathCache.get(transition);
|
||||
if (plannerRouted(transition)) {
|
||||
let routed = planner.pathFor(transition);
|
||||
if (useGridRouter) routed = { d: roundedPath(routed, transition.cornerRadius ?? 10), points: routed };
|
||||
pathCache.set(transition, routed);
|
||||
return routed;
|
||||
}
|
||||
const from = states.get(transition.from);
|
||||
const to = states.get(transition.to);
|
||||
const ports = automaticPorts.get(transition);
|
||||
const { fromSide, toSide } = transitionSides(transition);
|
||||
const start = ports?.from || anchor(from, fromSide);
|
||||
const end = ports?.to || anchor(to, toSide);
|
||||
let via = routeVia(transition, from, to, start, end, fromSide, toSide);
|
||||
if (ports && !via.length && Math.abs(start[0] - end[0]) >= 4 && Math.abs(start[1] - end[1]) >= 4) {
|
||||
const midX = (start[0] + end[0]) / 2;
|
||||
via = [[midX, start[1]], [midX, end[1]]];
|
||||
}
|
||||
const points = [start, ...via, end];
|
||||
const routed = {
|
||||
d: roundedPath(points, transition.cornerRadius ?? 10),
|
||||
points
|
||||
};
|
||||
pathCache.set(transition, routed);
|
||||
return routed;
|
||||
}
|
||||
|
||||
const resolvedLabelPoints = new Map();
|
||||
|
||||
function transitionLabelBox(transition) {
|
||||
return transitionLabelBoxAt(
|
||||
transition,
|
||||
resolvedLabelPoints.get(transition) || labelPoint(transition, pathFor(transition).points),
|
||||
);
|
||||
}
|
||||
|
||||
function transitionLabelBoxAt(transition, [lx, ly]) {
|
||||
const width = transitionLabelWidth(transition);
|
||||
const height = transition.label && transition.note ? 27 : 16;
|
||||
return { x: lx - width / 2, y: ly - 11, width, height, lx, ly };
|
||||
}
|
||||
|
||||
function transitionLabelRects() {
|
||||
const rects = [];
|
||||
for (const [relationIndex, transition] of asArray(lifecycle.transitions).entries()) {
|
||||
if (!(transition.label || transition.note) || !states.has(transition.from) || !states.has(transition.to)) continue;
|
||||
rects.push({ relation: transition, relationIndex, label: transition.label || transition.note, ...transitionLabelBox(transition) });
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
// Showcase drafts leave label positions to the renderer too: move an unpinned
|
||||
// label off other routes and states instead of reporting a clearance defect.
|
||||
if (lifecycle.meta?.quality_profile === 'showcase') {
|
||||
const placed = placeAutomaticLabels({
|
||||
labels: transitionLabelRects(),
|
||||
routes: asArray(lifecycle.transitions).flatMap((transition, relationIndex) => (
|
||||
states.has(transition.from) && states.has(transition.to)
|
||||
? [{ relationIndex, points: pathFor(transition).points }] : []
|
||||
)),
|
||||
components: [...states.values()],
|
||||
titles: bandGeometry(),
|
||||
viewBox,
|
||||
placementBottom: lifecycleAreaBottom(),
|
||||
keepFallbackNearRoute: isV2,
|
||||
gridSweep: isV2,
|
||||
});
|
||||
for (const rect of placed) resolvedLabelPoints.set(rect.relation, [rect.lx, rect.ly]);
|
||||
}
|
||||
|
||||
function bandGeometry() {
|
||||
if (isV2) {
|
||||
// One row header per rendered row, set vertically in the left gutter so
|
||||
// routes entering a row never run through its title.
|
||||
return laneRowOrder().map((laneId, index) => {
|
||||
const cy = v2RowTop.get(laneId) + layoutV2.stateH / 2;
|
||||
const label = laneLabels.get(laneId) || laneId;
|
||||
const length = textUnits(label) * 6.2;
|
||||
return { index, label, vertical: true, cx: 18, cy, x: 11, y: cy - length / 2, width: 14, height: length };
|
||||
});
|
||||
}
|
||||
const lanes = asArray(lifecycle.lanes);
|
||||
const mainLane = lanes.find((lane) => lane.id === 'main');
|
||||
const terminalLane = lanes.find((lane) => lane.id === 'terminal');
|
||||
const eventLanes = lanes.filter((lane) => lane.id !== 'main' && lane.id !== 'terminal');
|
||||
const populated = new Set([...states.values()].map((state) => bandFor(state.lane)));
|
||||
return [
|
||||
mainLane?.label || 'Lifecycle phases',
|
||||
eventLanes.length ? eventLanes.map((lane) => lane.label).join(' + ') : 'Interruptions + recovery',
|
||||
terminalLane?.label || 'Outcomes'
|
||||
].map((title, index) => {
|
||||
const baseline = [100, 252, 424][index];
|
||||
const label = `${String(index + 1).padStart(2, '0')} / ${title}`;
|
||||
return { index, band: ['phase', 'event', 'outcome'][index], label, x: 72, y: baseline - 11, width: textUnits(label) * 6.2, height: 14, baseline };
|
||||
}).filter((band) => populated.has(band.band));
|
||||
}
|
||||
|
||||
function renderBands() {
|
||||
return bandGeometry().map((band) => (band.vertical
|
||||
? ` <text x="${band.cx}" y="${band.cy}" class="t-dim" font-size="10" font-weight="600" writing-mode="vertical-rl" text-anchor="middle">${esc(band.label)}</text>`
|
||||
: ` <path d="M ${band.x} ${band.baseline + 12} L ${viewBox[0] - band.x} ${band.baseline + 12}" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
|
||||
<text x="${band.x}" y="${band.baseline}" class="t-dim" font-size="10" font-weight="600">${esc(band.label)}</text>`)).join('\n');
|
||||
}
|
||||
|
||||
function renderState(state) {
|
||||
const fill = typeClass[state.type] || typeClass.neutral;
|
||||
const accent = textClass[state.type] || 't-muted';
|
||||
const hasSub = state.sublabel != null && state.sublabel !== '';
|
||||
const { label: labelFontSize, sublabel: sublabelFontSize, tag: tagFontSize } = stateFontSizes(state, state.width);
|
||||
const textRows = [{ text: state.label, font: labelFontSize, y: isV2 ? 23 : 21 }];
|
||||
if (hasSub) textRows.push({ text: state.sublabel, font: sublabelFontSize, y: isV2 ? 40 : 37 });
|
||||
if (state.tag) textRows.push({ text: state.tag, font: tagFontSize, y: state.height - (isV2 ? 12 : 11) });
|
||||
const hasBrand = Boolean(brandMarkFor(state));
|
||||
const hasSource = Boolean(sourceEvidence?.nodes?.[state.id]?.length);
|
||||
const labelLayout = nodeLabelLayout({ width: state.width, height: state.height, rows: textRows,
|
||||
brand: hasBrand, source: hasSource, side: 'left', step: state.step });
|
||||
const sub = hasSub
|
||||
? `\n <text data-detail="context" x="${state.cx}" y="${state.y + labelLayout.ys[1]}" class="t-muted" font-size="${sublabelFontSize}" text-anchor="middle">${esc(state.sublabel)}</text>`
|
||||
: '';
|
||||
const tag = state.tag
|
||||
? `\n <text data-detail="fine" x="${state.cx}" y="${state.y + labelLayout.ys[hasSub ? 2 : 1]}" class="${accent}" font-size="${tagFontSize}" text-anchor="middle">${esc(state.tag)}</text>`
|
||||
: '';
|
||||
const step = state.step
|
||||
? `\n <text data-detail="fine" x="${state.x + 23}" y="${state.y + 14}" class="${accent}" font-size="${stateTextFit.step}" font-weight="700">${esc(state.step)}</text>`
|
||||
: '';
|
||||
const brand = renderBrandMark(state, { x: state.x + state.width - 22, y: state.y + 6 });
|
||||
// UML pseudo-state markers: start states get an initial dot + arrow into
|
||||
// the left border; states with no authored outgoing transition get a double
|
||||
// border. Both are decorations, not focus/relationship edges.
|
||||
const initialMarker = state.type === 'start'
|
||||
? `\n <g aria-hidden="true" data-lifecycle-initial-marker="">
|
||||
${initialMarkerShape(state.x - 22, state.x - 1, state.cy)}
|
||||
</g>`
|
||||
: '';
|
||||
const finalBorder = isFinal(state)
|
||||
? `\n <rect x="${state.x + 3}" y="${state.y + 3}" width="${state.width - 6}" height="${state.height - 6}" rx="4" class="${fill}" style="fill: none" stroke-width="1"/>`
|
||||
: '';
|
||||
const passport = {
|
||||
kind: state.type,
|
||||
sublabel: state.sublabel,
|
||||
tag: state.tag,
|
||||
context: laneLabels.get(state.lane) || i18nText(lifecycle.meta.locale, 'node.context.lifecycle'),
|
||||
...brandMetadataFor(state),
|
||||
};
|
||||
return ` <g ${focusNodeAttrs(state.id, state.label, passport, lifecycle.meta.locale)}>
|
||||
${focusNodeTitle(state.label, passport)}
|
||||
<rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="7" class="c-mask"/>
|
||||
<rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="7" class="${fill}"${animateAttr(lifecycle.meta, 'node', stateSteps.get(state.id))} stroke-width="1.5"/>${finalBorder}${initialMarker}
|
||||
${renderSemanticSigil(state.type, { icon: state.icon, x: state.x + 6, y: state.y + labelLayout.sigilY, size: labelLayout.sigilSize })}${brand ? `\n ${brand}` : ''}${step}
|
||||
<text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${state.x + labelLayout.x}" y="${state.y + labelLayout.ys[0]}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(state.label)}</text>${sub}${tag}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
// v2 replaces the implied phase rail with explicit topology: a forward
|
||||
// transition between two main-lane states renders as the emphasized primary
|
||||
// path unless the author chose another variant.
|
||||
function effectiveVariant(transition) {
|
||||
if (transition.variant) return transition.variant;
|
||||
if (isV2) {
|
||||
const from = states.get(transition.from);
|
||||
const to = states.get(transition.to);
|
||||
if (from?.lane === 'main' && to?.lane === 'main' && to.col > from.col) return 'emphasis';
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function renderTransitionPath(transition, index) {
|
||||
const variant = effectiveVariant(transition);
|
||||
const [cls, marker] = arrowClassMap[variant] || arrowClassMap.default;
|
||||
const routed = pathFor(transition);
|
||||
const strokeWidth = transition.width || (variant === 'emphasis' ? (isV2 ? 1.6 : 2) : 1.1);
|
||||
const automaticRoute = plannerRouted(transition);
|
||||
const crossover = automaticRoute ? ' data-composition-crossover="halo"' : '';
|
||||
const edge = ` <path ${focusEdgeAttrs(transition.from, transition.to, transition.label || transition.note, index, transition.id)} data-composition-points="${routePointsValue(routed.points)}"${crossover}${authoredStraightRouteAttrs(transition, routed.points)} d="${routed.d}" class="${cls}"${animateAttr(lifecycle.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
|
||||
if (!automaticRoute) return edge;
|
||||
// Same presentation-only wrapper as architecture: the mask underlay lets two
|
||||
// planner routes cross legibly while the viewer still sees one semantic edge.
|
||||
const underlay = ` <path data-graph-role="automatic-crossover-underlay" d="${routed.d}" fill="none" stroke="var(--mask)" stroke-width="${strokeWidth + 4}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"/>\n`;
|
||||
return ` <g data-graph-role="automatic-crossover" style="--step:${index}">\n${underlay}${edge.replace(/^ /, ' ')}\n </g>`;
|
||||
}
|
||||
|
||||
function renderTransitionLabel(transition, index) {
|
||||
if (!(transition.label || transition.note)) return '';
|
||||
const { lx, ly, width: labelW, height: labelH } = transitionLabelBox(transition);
|
||||
const label = transition.label
|
||||
? `\n <text x="${lx}" y="${ly}" class="${edgeLabelAccent(effectiveVariant(transition))}" font-size="8" text-anchor="middle">${esc(transition.label)}</text>`
|
||||
: '';
|
||||
const note = transition.note
|
||||
? `\n <text data-detail="fine" x="${lx}" y="${ly + (transition.label ? 11 : 0)}" class="t-dim" font-size="7" text-anchor="middle">${esc(transition.note)}</text>`
|
||||
: '';
|
||||
return ` <g data-detail="${transition.label ? 'context' : 'fine'}" ${focusEdgeAttrs(transition.from, transition.to, transition.label || transition.note, index, transition.id)}>
|
||||
<rect x="${lx - labelW / 2}" y="${ly - 11}" width="${labelW}" height="${labelH}" rx="4" class="c-mask"/>${label}${note}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
// UML initial pseudo-state: a filled dot and a short arrow ending at `tipX`.
|
||||
function initialMarkerShape(dotX, tipX, y) {
|
||||
return `<circle cx="${dotX}" cy="${y}" r="4.5" style="fill: var(--arrow-emphasis)"/><path d="M ${dotX + 4.5} ${y} L ${tipX - 6} ${y}" class="a-emphasis" stroke-width="1.4"/><path d="M ${tipX - 7} ${y - 3.5} L ${tipX} ${y} L ${tipX - 7} ${y + 3.5} Z" style="fill: var(--arrow-emphasis)"/>`;
|
||||
}
|
||||
|
||||
function renderSwatch(entry) {
|
||||
// `start` is structural, not a color: its swatch is the same initial
|
||||
// pseudo-state marker drawn on the canvas. `final` is a non-interactive
|
||||
// structural entry (a double-border rect) appended when a final state exists.
|
||||
if (entry.kind === 'start') {
|
||||
return initialMarkerShape(entry.x + 4.5, entry.x + 24, entry.baseline - 3.5);
|
||||
}
|
||||
if (entry.kind === 'final') {
|
||||
return `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="c-external" stroke-width="1"/><rect x="${entry.x + 2.5}" y="${entry.baseline - 5.5}" width="9" height="4" rx="1" class="c-external" style="fill: none" stroke-width="0.8"/>`;
|
||||
}
|
||||
return `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="${typeClass[entry.kind] || 'c-external'}" stroke-width="1"/>`;
|
||||
}
|
||||
|
||||
function renderLegend() {
|
||||
return renderResolvedLegend({
|
||||
entries: resolvedLegendEntries,
|
||||
locale: lifecycle.meta.locale,
|
||||
layout: {
|
||||
x: 40,
|
||||
baselineY: legendY(),
|
||||
width: viewBox[0] - 80,
|
||||
minTitleY: lifecycleAreaBottom() + 8,
|
||||
unfit: lifecycle.meta?.legend === undefined ? 'hide' : 'error',
|
||||
diagramType: 'lifecycle',
|
||||
},
|
||||
renderSwatch,
|
||||
});
|
||||
}
|
||||
|
||||
// v1 only: the implied emphasis line behind main states. v2 topology is fully
|
||||
// explicit, so the rail would double the authored forward transitions.
|
||||
function renderLifecycleRail() {
|
||||
if (isV2) return '';
|
||||
const mainCols = [...states.values()]
|
||||
.filter((state) => bandFor(state.lane) === 'phase')
|
||||
.map((state) => state.col);
|
||||
if (!mainCols.length) return '';
|
||||
const railEnd = layout.phaseXs[mainCols.reduce((max, col) => Math.max(max, col))] + 38;
|
||||
return ` <path data-lifecycle-rail="" d="M 154 ${layout.phaseY + 31} L ${railEnd} ${layout.phaseY + 31}" class="a-emphasis" stroke-width="2.2" marker-end="url(#arrowhead-emphasis)"/>`;
|
||||
}
|
||||
|
||||
function renderSvg() {
|
||||
// A renderer-sized canvas declares the intrinsic-height fit exactly like
|
||||
// architecture: the default 980x660 band layout is below the 1.55 wide
|
||||
// ratio, so without this the desktop Reader could neither narrow it nor
|
||||
// scroll it and every default lifecycle failed the browser gate.
|
||||
const readerFit = lifecycle.meta?.viewBox ? '' : ' data-reader-fit="intrinsic-height"';
|
||||
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}"${readerFit} ${svgRootAttrs(lifecycle.meta)}>
|
||||
${svgAccessibleText(lifecycle.meta, 'lifecycle')}
|
||||
${renderDefinitions()}
|
||||
|
||||
<!-- Background Grid -->
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
<!-- Lifecycle bands -->
|
||||
${renderBands()}
|
||||
|
||||
<!-- Primary lifecycle rail -->
|
||||
${renderLifecycleRail()}
|
||||
|
||||
<!-- Transition paths -->
|
||||
${asArray(lifecycle.transitions).map(renderTransitionPath).join('\n')}
|
||||
|
||||
<!-- States -->
|
||||
${[...states.values()].map(renderState).join('\n\n')}
|
||||
|
||||
<!-- Transition labels -->
|
||||
${asArray(lifecycle.transitions).map(renderTransitionLabel).join('\n')}
|
||||
|
||||
<!-- Legend -->
|
||||
${renderLegend()}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
validateLifecycle();
|
||||
writeDiagram({
|
||||
outPath,
|
||||
template,
|
||||
diagramType: 'lifecycle',
|
||||
meta: lifecycle.meta,
|
||||
svg: renderSvg(),
|
||||
cards: lifecycle.cards,
|
||||
sourceEvidence,
|
||||
});
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# Sequence Renderer
|
||||
|
||||
Render `diagram_type: "sequence"` JSON files into the standard Archify HTML
|
||||
template.
|
||||
|
||||
```bash
|
||||
node archify/renderers/sequence/render-sequence.mjs input.sequence.json output.html
|
||||
```
|
||||
|
||||
The renderer validates input against `archify/schemas/sequence.schema.json`
|
||||
with the bundled standalone validator. No dependency installation is required.
|
||||
|
||||
If `output.html` is omitted, the renderer uses the required `meta.output` value
|
||||
from the JSON file.
|
||||
|
||||
## Input
|
||||
|
||||
Sequence JSON files must set:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "sequence",
|
||||
"meta": {
|
||||
"title": "Cache Miss Request Sequence",
|
||||
"output": "cache-miss-request.html",
|
||||
"viewBox": [920, 760]
|
||||
},
|
||||
"participants": [],
|
||||
"segments": [],
|
||||
"messages": [],
|
||||
"activations": [],
|
||||
"cards": []
|
||||
}
|
||||
```
|
||||
|
||||
The timeline scales with the viewBox height: a taller `meta.viewBox` buys more
|
||||
message room, a shorter one shrinks the readable band instead of clipping. A
|
||||
complete worked example lives at
|
||||
`archify/examples/cache-miss-request.sequence.json`.
|
||||
|
||||
The schema lives at:
|
||||
|
||||
```text
|
||||
archify/schemas/sequence.schema.json
|
||||
```
|
||||
|
||||
## Legend
|
||||
|
||||
The default visual legend derives kinds from `messages[].variant` (omitting
|
||||
`variant` means `default`). Supported `meta.legend.entries` keys, in stable
|
||||
order, are `emphasis`, `return`, `security`, `dashed`, and `default`. These are
|
||||
visual message keys, not Semantic Lens controls; label/visibility overrides do
|
||||
not create edge facts.
|
||||
|
||||
The legend sits below all timeline content: the last message and its note,
|
||||
activation bars, and segment frames, with a 12px gap. Without `meta.viewBox`
|
||||
the canvas grows to keep that gap. With an authored `viewBox` that is too short,
|
||||
`showcase` fails with the exact height to set, and `standard` hides the implicit
|
||||
legend rather than drawing it over content. Lifelines stop above the legend.
|
||||
Message labels use their line's color; gray default and return lines keep the
|
||||
muted text color.
|
||||
|
||||
## Layout budget
|
||||
|
||||
| Constant | Value |
|
||||
|----------|-------|
|
||||
| viewBox | default `[920, 760]`, taller when late content needs legend room; schema minimum `[480, 480]` |
|
||||
| Participant boxes | `fixed` (default): 86×54 at y 72; `spread`: viewBox-relative width from 86px up to 190px |
|
||||
| Participant columns | `fixed`: centers at x = 62 + index×108; `spread`: columns distribute across the available viewBox width |
|
||||
| Participant count | the last box must end at or before width − 40; layouts that cannot fit fail closed |
|
||||
| Lifelines | from y 142 down to height − 65 (drawn to just above the legend); band must be ≥120px tall |
|
||||
| Message `y` range | `[160, height − 83]` |
|
||||
| Message spacing | ≥28px vertical between messages that share horizontal space |
|
||||
| Arrow span | ≥60px horizontal between the two participants |
|
||||
| Segments | y pixel ranges with `to > from`, inside `[72, lifeline bottom + 20]` |
|
||||
| Legend | last row baseline at height − 54; extra rows wrap upward and stay 12px below the timeline content |
|
||||
|
||||
`segments[].from/to` and `activations[].from/to` are y pixel coordinates, not
|
||||
participant ids; activations also require `to > from`.
|
||||
|
||||
### Column fit
|
||||
|
||||
Sequence diagrams use `meta.column_fit: "fixed"` by default so existing
|
||||
documents keep their historical coordinates. Use `"spread"` when a wide
|
||||
viewBox would otherwise leave empty space on the right or when meaningful
|
||||
participant labels do not fit the fixed 86px boxes. Spread derives box width
|
||||
and column distance from the viewBox while preserving participant order,
|
||||
lifelines, and message semantics.
|
||||
|
||||
The artifact checker reports `composition.sequenceColumnSpace` from the rendered
|
||||
participants, routes and text. A large unused right-hand region in a fixed layout
|
||||
can produce an `inspect-sequence-width` recommendation in `finalize`; it is advice,
|
||||
not a new warning or failure. See [Sequence width review](../../references/delivery-contract.md#sequence-width-review)
|
||||
for the bounded authoring repair and explicit-fixed/legacy preservation rules.
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Put participants across the top, ordered by the story the reader should
|
||||
follow.
|
||||
- Time moves downward.
|
||||
- Use `emphasis` for the main request path.
|
||||
- Use `security` for auth, consent, permission, and policy calls.
|
||||
- Use `return` for quiet response messages.
|
||||
- Use `dashed` for async trace, event, logging, and non-blocking work.
|
||||
- Use segments as light background guides; keep segment labels short.
|
||||
- Keep labels concise, but try `meta.column_fit: "spread"` before shortening a
|
||||
meaningful participant label just to fit the fixed boxes.
|
||||
|
||||
Schema violations exit non-zero with path-prefixed messages annotated with the
|
||||
element's id or label. The renderer additionally fails when it can detect
|
||||
layout problems, including missing participants, duplicate participant IDs,
|
||||
participant labels wider than their box, unknown message endpoints, messages
|
||||
outside the readable timeline, overly tight vertical spacing between messages
|
||||
that overlap horizontally, invalid segment or activation ranges, or
|
||||
participants that exceed the viewBox. The shared Clean Flow contract treats
|
||||
participant headers as semantic boxes while explicitly allowing messages to
|
||||
cross intermediate lifelines, activation bars, and segment frames. Text width is estimated CJK-aware:
|
||||
fullwidth glyphs count as two units.
|
||||
|
||||
Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
|
||||
message X crossings then fail with `composition/proper-crossing`; default
|
||||
`standard` keeps them as artifact-receipt warnings. Messages may still cross
|
||||
intermediate lifelines. Collinear corridors remain outside the proper-X rule,
|
||||
but a separate gate warns in `standard` and fails in `showcase` when unrelated
|
||||
messages overlap for at least 8px. Shared semantic endpoints, point touches,
|
||||
and shorter overlaps remain valid. Showcase also rejects any route segment
|
||||
below 8px and any interior turn segment below 16px; ordinary 8–15px endpoint
|
||||
stubs remain valid.
|
||||
@@ -0,0 +1,526 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
|
||||
import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
|
||||
import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
|
||||
import { legendFootprint, measureLegend, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
|
||||
import { componentFill, arrowClassMap, rectsOverlap, cleanFlowProblems, cleanCrossingProblems, cleanAmbiguousCorridorProblems, cleanBorderRunProblems, cleanRouteRhythmProblems, cleanLabelRouteClearanceProblems, cleanLabelCanvasContainmentProblems, routePointsValue, asArray, isFinitePoint, edgeLabelAccent } from '../shared/geometry.mjs';
|
||||
import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
|
||||
import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
|
||||
import { translateMessage as i18nText } from '../shared/i18n.mjs';
|
||||
|
||||
const participantTextFit = {
|
||||
sublabelPreferred: 7,
|
||||
sublabelMinimum: 6,
|
||||
};
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const { diagram: sequence, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
|
||||
rendererDir: __dirname,
|
||||
diagramType: 'sequence',
|
||||
defaultExample: 'cache-miss-request.sequence.json'
|
||||
});
|
||||
|
||||
const LEGEND_CATALOG = [
|
||||
{ kind: 'emphasis', className: 'a-emphasis', marker: 'arrowhead-emphasis', strokeWidth: 1.8 },
|
||||
{ kind: 'return', className: 'a-default', marker: 'arrowhead', dash: '3,5' },
|
||||
{ kind: 'security', className: 'a-security', marker: 'arrowhead-security' },
|
||||
{ kind: 'dashed', className: 'a-dashed', marker: 'arrowhead-dashed' },
|
||||
{ kind: 'default', className: 'a-default', marker: 'arrowhead' },
|
||||
].map((entry) => ({
|
||||
...entry,
|
||||
interactive: false,
|
||||
swatchWidth: 34,
|
||||
swatchGap: 9,
|
||||
label: i18nText(sequence.meta.locale, `legend.sequence.${entry.kind}`),
|
||||
}));
|
||||
|
||||
function legendEntries() {
|
||||
const presentKinds = new Set(asArray(sequence.messages).map((message) => message.variant || 'default'));
|
||||
return resolveLegend(sequence.meta?.legend, LEGEND_CATALOG, presentKinds);
|
||||
}
|
||||
|
||||
// The legend sits below the timeline content: the last message and its note,
|
||||
// activation bars, and segment frames. Its block starts LEGEND_CONTENT_GAP
|
||||
// below that content; from the block top to the canvas bottom a one-row legend
|
||||
// needs LEGEND_BLOCK_HEIGHT (title glyphs, row, and the 54px baseline inset).
|
||||
const LEGEND_CONTENT_GAP = 12;
|
||||
const LEGEND_BLOCK_HEIGHT = 86;
|
||||
const contentBottom = Math.max(
|
||||
0,
|
||||
...asArray(sequence.messages).map((message) => message.y + (message.note ? 22 : 6)),
|
||||
...asArray(sequence.activations).map((activation) => activation.to),
|
||||
...asArray(sequence.segments).map((segment) => segment.to),
|
||||
);
|
||||
function legendRequiredHeight(width) {
|
||||
const entries = legendEntries();
|
||||
if (!entries.length) return 0;
|
||||
return Math.ceil(contentBottom + LEGEND_CONTENT_GAP + LEGEND_BLOCK_HEIGHT
|
||||
+ legendFootprint(entries, { width: width - 80 }).extraHeight);
|
||||
}
|
||||
// A renderer-sized canvas grows to keep the legend clear of late messages;
|
||||
// an authored viewBox is honored and validated below.
|
||||
const viewBox = sequence.meta?.viewBox || [920, Math.max(760, legendRequiredHeight(920))];
|
||||
// The timeline scales with viewBox height: a taller viewBox gains message room,
|
||||
// a shorter one shrinks the readable band (validated below) instead of clipping.
|
||||
// `column_fit: "spread"` widens the lanes with the viewBox instead of keeping
|
||||
// the fixed 108px gap, so a wide canvas gains column distance and label room
|
||||
// rather than dead space on the right. The default stays "fixed" so existing
|
||||
// diagrams keep their coordinates.
|
||||
const columnFit = sequence.meta?.column_fit === 'spread' ? 'spread' : 'fixed';
|
||||
const participantCount = Math.max(1, asArray(sequence.participants).length);
|
||||
const sideMargin = 62;
|
||||
const participantW = columnFit === 'spread'
|
||||
? Math.max(86, Math.min(190, Math.round((viewBox[0] - sideMargin * 2) / participantCount) - 24))
|
||||
: 86;
|
||||
const colGap = columnFit === 'spread' && participantCount > 1
|
||||
? Math.max(108, (viewBox[0] - 40 - sideMargin - participantW) / (participantCount - 1))
|
||||
: 108;
|
||||
|
||||
// Showcase is the fast-authoring default; standard retains legacy label geometry.
|
||||
const readableMessages = sequence.meta?.quality_profile === 'showcase';
|
||||
const messageFontSize = readableMessages ? 11 : 9;
|
||||
const messageUnitWidth = readableMessages ? 6.6 : 5.2;
|
||||
const layout = {
|
||||
topY: 72,
|
||||
participantW,
|
||||
// Keep a separate top rail for the 11px semantic sigil and 16px brand mark.
|
||||
// Literal labels retain their fitted font size and full authored wording.
|
||||
participantH: 60,
|
||||
participantLabelY: 36,
|
||||
participantSublabelY: 50,
|
||||
lifelineTop: 142,
|
||||
lifelineBottom: viewBox[1] - 65,
|
||||
legendY: viewBox[1] - 54,
|
||||
leftX: columnFit === 'spread' ? sideMargin + participantW / 2 : sideMargin,
|
||||
colGap,
|
||||
labelH: readableMessages ? 18 : 16
|
||||
};
|
||||
|
||||
const participantBoxWidthNote = columnFit === 'spread'
|
||||
? `participant boxes are ${participantW}px for this viewBox width and ${participantCount} participants`
|
||||
: `participant boxes are a fixed ${participantW}px unless meta.column_fit is "spread"`;
|
||||
|
||||
const arrowClass = {
|
||||
...arrowClassMap,
|
||||
return: ['a-default', 'arrowhead']
|
||||
};
|
||||
|
||||
function participantX(index) {
|
||||
return layout.leftX + index * layout.colGap;
|
||||
}
|
||||
|
||||
const participants = new Map(asArray(sequence.participants).map((participant, index) => [
|
||||
participant.id,
|
||||
{
|
||||
...participant,
|
||||
index,
|
||||
cx: participantX(index),
|
||||
x: participantX(index) - layout.participantW / 2,
|
||||
y: layout.topY,
|
||||
width: layout.participantW,
|
||||
height: layout.participantH,
|
||||
cy: layout.topY + layout.participantH / 2
|
||||
}
|
||||
]));
|
||||
|
||||
function messageGeometry(message) {
|
||||
const from = participants.get(message.from);
|
||||
const to = participants.get(message.to);
|
||||
if (!from || !to || typeof message.y !== 'number') return null;
|
||||
const direction = to.cx > from.cx ? 1 : -1;
|
||||
const start = from.cx + direction * 7;
|
||||
const end = to.cx - direction * 7;
|
||||
return { start, end, center: (start + end) / 2 };
|
||||
}
|
||||
|
||||
function messageLabelBox(message, relationIndex = null) {
|
||||
const geometry = messageGeometry(message);
|
||||
if (!geometry) return null;
|
||||
const width = Math.max(34, textUnits(message.label) * messageUnitWidth + 12);
|
||||
return {
|
||||
relation: message,
|
||||
relationIndex,
|
||||
label: message.label,
|
||||
x: geometry.center - width / 2,
|
||||
y: message.y - 20,
|
||||
width,
|
||||
height: layout.labelH,
|
||||
};
|
||||
}
|
||||
|
||||
function messageRouteBox(message) {
|
||||
const geometry = messageGeometry(message);
|
||||
if (!geometry) return null;
|
||||
return {
|
||||
x: Math.min(geometry.start, geometry.end),
|
||||
y: message.y - 2,
|
||||
width: Math.abs(geometry.end - geometry.start),
|
||||
height: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function segmentLabelBox(segment) {
|
||||
const labelW = Math.max(42, textUnits(segment.label) * 5.2 + 14);
|
||||
const occupied = asArray(sequence.messages)
|
||||
.flatMap((message) => [messageLabelBox(message), messageRouteBox(message)])
|
||||
.filter(Boolean);
|
||||
const label = { x: 56, y: segment.from - 22, width: labelW, height: 18 };
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
if (!occupied.some((rect) => rectsOverlap(label, rect, 2))) break;
|
||||
label.y -= 22;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
const compositionFrames = asArray(sequence.segments).map((segment, index) => ({
|
||||
id: index,
|
||||
label: segment.label,
|
||||
kind: 'segment',
|
||||
x: 48,
|
||||
y: segment.from,
|
||||
width: viewBox[0] - 96,
|
||||
height: segment.to - segment.from,
|
||||
radius: 10,
|
||||
}));
|
||||
|
||||
function messagePath(message) {
|
||||
return {
|
||||
points: participants.has(message.from) && participants.has(message.to)
|
||||
? [[participants.get(message.from).cx, message.y], [participants.get(message.to).cx, message.y]]
|
||||
: []
|
||||
};
|
||||
}
|
||||
|
||||
function validateSequence() {
|
||||
const problems = [];
|
||||
if (participants.size !== asArray(sequence.participants).length) problems.push('Participant ids must be unique.');
|
||||
|
||||
if (layout.lifelineBottom - layout.lifelineTop < 120) {
|
||||
problems.push(`viewBox height ${viewBox[1]} leaves under 120px of timeline — set meta.viewBox[1] to at least ${layout.lifelineTop + 120 + 65}.`);
|
||||
}
|
||||
|
||||
for (const participant of participants.values()) {
|
||||
const estLabelW = textUnits(participant.label) * 6.8;
|
||||
if (estLabelW > layout.participantW + 6) {
|
||||
problems.push(`Label "${participant.label}" (~${Math.round(estLabelW)}px) is wider than the ${layout.participantW}px participant box — shorten it.`);
|
||||
}
|
||||
const brandRailProblem = brandTopRailProblem(participant, layout.participantW, 8, 'Participant');
|
||||
if (brandRailProblem) problems.push(brandRailProblem);
|
||||
// sublabel renders as a single unwrapped <text>; shrink-to-fit handles the
|
||||
// ordinary case, this rejects what it cannot rescue.
|
||||
if (participant.sublabel) {
|
||||
const availableTextW = availableNodeTextWidth(layout.participantW);
|
||||
const minimumW = minimumNodeTextWidth(participant.sublabel, participantTextFit.sublabelMinimum);
|
||||
if (minimumW > availableTextW) {
|
||||
problems.push(`Sublabel "${participant.sublabel}" needs ~${Math.ceil(minimumW)}px at the ${participantTextFit.sublabelMinimum}px legible minimum, but participant "${participant.id}" provides ${availableTextW}px — shorten the sublabel (${participantBoxWidthNote}).`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const message of asArray(sequence.messages)) {
|
||||
if (!participants.has(message.from)) problems.push(`Message "${message.label}" references unknown source "${message.from}".`);
|
||||
if (!participants.has(message.to)) problems.push(`Message "${message.label}" references unknown target "${message.to}".`);
|
||||
if (typeof message.y !== 'number') problems.push(`Message "${message.label}" must provide a numeric y.`);
|
||||
if (message.y < layout.lifelineTop + 18 || message.y > layout.lifelineBottom - 18) {
|
||||
problems.push(`Message "${message.label}" sits outside the readable timeline — keep y between ${layout.lifelineTop + 18} and ${layout.lifelineBottom - 18}.`);
|
||||
}
|
||||
if (participants.has(message.from) && participants.has(message.to)) {
|
||||
const distance = Math.abs(participants.get(message.to).cx - participants.get(message.from).cx);
|
||||
if (distance < 60) problems.push(`Message "${message.label}" spans ${Math.round(distance)}px (minimum 60px) — give its participants more column distance.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Participant headers are opaque nodes. Lifelines, activation bars, and
|
||||
// segment bands remain intentional pass-through geometry and are excluded.
|
||||
problems.push(...cleanFlowProblems({
|
||||
relations: sequence.messages,
|
||||
obstacles: participants.values(),
|
||||
pathFor: messagePath,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
obstacleKind: 'participant header',
|
||||
clearance: 0,
|
||||
routeHint: 'move the message y below the participant headers or reorder participants'
|
||||
}));
|
||||
problems.push(...cleanCrossingProblems({
|
||||
relations: sequence.messages,
|
||||
endpointIds: new Set(participants.keys()),
|
||||
pathFor: messagePath,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
profile: sequence.meta?.quality_profile,
|
||||
routeHint: 'separate the message y values; lifeline crossings remain allowed'
|
||||
}));
|
||||
problems.push(...cleanAmbiguousCorridorProblems({
|
||||
relations: sequence.messages,
|
||||
endpointIds: new Set(participants.keys()),
|
||||
pathFor: messagePath,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
profile: sequence.meta?.quality_profile,
|
||||
routeHint: 'separate the message y values so unrelated messages do not visually merge'
|
||||
}));
|
||||
problems.push(...cleanBorderRunProblems({
|
||||
relations: sequence.messages,
|
||||
endpointIds: new Set(participants.keys()),
|
||||
frames: compositionFrames,
|
||||
pathFor: messagePath,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
profile: sequence.meta?.quality_profile,
|
||||
routeHint: 'move the message y so it crosses a segment boundary perpendicularly or stays clearly inside the segment'
|
||||
}));
|
||||
problems.push(...cleanRouteRhythmProblems({
|
||||
relations: sequence.messages,
|
||||
endpointIds: new Set(participants.keys()),
|
||||
pathFor: messagePath,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
profile: sequence.meta?.quality_profile,
|
||||
routeHint: 'increase participant spacing or simplify message routing so every turn has room to read'
|
||||
}));
|
||||
|
||||
// Vertical crowding only matters when the arrows share horizontal space;
|
||||
// disjoint arrows may legitimately run in parallel rows.
|
||||
const placed = asArray(sequence.messages)
|
||||
.filter((m) => participants.has(m.from) && participants.has(m.to))
|
||||
.map((m) => ({
|
||||
label: m.label,
|
||||
y: m.y,
|
||||
x1: Math.min(participants.get(m.from).cx, participants.get(m.to).cx),
|
||||
x2: Math.max(participants.get(m.from).cx, participants.get(m.to).cx)
|
||||
}))
|
||||
.sort((a, b) => a.y - b.y);
|
||||
for (let i = 0; i < placed.length; i += 1) {
|
||||
for (let j = i + 1; j < placed.length && placed[j].y - placed[i].y < 28; j += 1) {
|
||||
if (placed[i].x1 < placed[j].x2 && placed[j].x1 < placed[i].x2) {
|
||||
problems.push(`Messages "${placed[i].label}" and "${placed[j].label}" are less than 28px apart and share horizontal space — spread their y values.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Label masks can extend well past the arrow span, so check the actual
|
||||
// label rectangles too — tangent arrows with long labels still collide.
|
||||
const labelRects = asArray(sequence.messages)
|
||||
.map((m, messageIndex) => messageLabelBox(m, messageIndex))
|
||||
.filter(Boolean);
|
||||
for (let i = 0; i < labelRects.length; i += 1) {
|
||||
for (let j = i + 1; j < labelRects.length; j += 1) {
|
||||
if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
|
||||
problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — spread their message y values or shorten the labels.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
problems.push(...cleanLabelRouteClearanceProblems({
|
||||
relations: sequence.messages,
|
||||
labels: labelRects,
|
||||
endpointIds: new Set(participants.keys()),
|
||||
pathFor: messagePath,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
profile: sequence.meta?.quality_profile,
|
||||
routeHint: 'spread the message y values, shorten the label, or reorder participants so the adjacent route stays visible'
|
||||
}));
|
||||
problems.push(...cleanLabelCanvasContainmentProblems({
|
||||
labels: labelRects,
|
||||
viewBox,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
profile: sequence.meta?.quality_profile,
|
||||
routeHint: 'shorten the label, reorder participants, or enlarge meta.viewBox',
|
||||
}));
|
||||
|
||||
for (const segment of asArray(sequence.segments)) {
|
||||
if (segment.to <= segment.from) {
|
||||
problems.push(`Segment "${segment.label}" has invalid y range (from ${segment.from} to ${segment.to}) — "to" must be greater than "from".`);
|
||||
}
|
||||
if (segment.from < layout.topY || segment.to > layout.lifelineBottom + 20) {
|
||||
problems.push(`Segment "${segment.label}" extends outside the canvas — keep its y range between ${layout.topY} and ${layout.lifelineBottom + 20}.`);
|
||||
}
|
||||
const labelBox = segmentLabelBox(segment);
|
||||
const availableWidth = Math.max(0, viewBox[0] - 48 - labelBox.x);
|
||||
if (labelBox.x + labelBox.width > viewBox[0] - 48) {
|
||||
const requiredWidth = Math.ceil(labelBox.x + labelBox.width + 48);
|
||||
problems.push(`Segment "${segment.label}" label (~${Math.round(labelBox.width)}px) exceeds the segment frame's available width (${availableWidth}px) — shorten the label or increase meta.viewBox[0] to at least ${requiredWidth}.`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const activation of asArray(sequence.activations)) {
|
||||
if (!participants.has(activation.participant)) problems.push(`Activation references unknown participant "${activation.participant}".`);
|
||||
if (activation.to <= activation.from) problems.push(`Activation for "${activation.participant}" has invalid time range — "to" must be greater than "from".`);
|
||||
}
|
||||
|
||||
const lastParticipant = asArray(sequence.participants)[asArray(sequence.participants).length - 1];
|
||||
if (lastParticipant && participants.get(lastParticipant.id).cx + layout.participantW / 2 > viewBox[0] - 40) {
|
||||
const requiredWidth = Math.ceil(participants.get(lastParticipant.id).cx + layout.participantW / 2 + 40);
|
||||
problems.push(`Participants exceed viewBox width — set meta.viewBox[0] to at least ${requiredWidth} or remove a participant.`);
|
||||
}
|
||||
|
||||
// Showcase must not silently drop the implicit legend because late content
|
||||
// leaves no room for it; give the exact canvas height instead.
|
||||
const legendHeight = legendRequiredHeight(viewBox[0]);
|
||||
if (sequence.meta?.quality_profile === 'showcase' && sequence.meta?.legend === undefined
|
||||
&& legendHeight > viewBox[1] && !measureLegend(legendEntries(), legendLayout())) {
|
||||
problems.push(`Sequence content ends at y=${contentBottom}, leaving no room for the legend below it — set meta.viewBox[1] to at least ${legendHeight} or omit meta.viewBox so the canvas grows.`);
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
throwDiagnosticProblems('Sequence layout validation failed', problems, {
|
||||
subject: { diagramType: 'sequence' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderParticipant(participant) {
|
||||
const fill = componentFill[participant.type] || 'c-external';
|
||||
const hasSub = participant.sublabel != null && participant.sublabel !== '';
|
||||
const sub = hasSub
|
||||
? `\n <text data-detail="context" x="${participant.cx}" y="${layout.topY + layout.participantSublabelY}" class="t-muted" font-size="${fittedNodeFontSize(participant.sublabel, layout.participantW, participantTextFit.sublabelPreferred, participantTextFit.sublabelMinimum)}" text-anchor="middle">${esc(participant.sublabel)}</text>`
|
||||
: '';
|
||||
const brand = renderBrandMark(participant, { x: participant.x + layout.participantW - 22, y: layout.topY + 6 });
|
||||
const labelFontSize = fittedNodeFontSize(participant.label, brandLabelFitWidth(participant, layout.participantW), 11, 8);
|
||||
const passport = {
|
||||
kind: participant.type,
|
||||
sublabel: participant.sublabel,
|
||||
context: i18nText(sequence.meta.locale, 'node.context.sequence'),
|
||||
...brandMetadataFor(participant),
|
||||
};
|
||||
return ` <g ${focusNodeAttrs(participant.id, participant.label, passport, sequence.meta.locale)}>
|
||||
${focusNodeTitle(participant.label, passport)}
|
||||
<rect x="${participant.x}" y="${layout.topY}" width="${layout.participantW}" height="${layout.participantH}" rx="6" class="c-mask"/>
|
||||
<rect x="${participant.x}" y="${layout.topY}" width="${layout.participantW}" height="${layout.participantH}" rx="6" class="${fill}"${animateAttr(sequence.meta, 'node', participant.index)} stroke-width="1.5"/>
|
||||
${renderSemanticSigil(participant.type, { icon: participant.icon, x: participant.x + 6, y: layout.topY + 6 })}${brand ? `\n ${brand}` : ''}
|
||||
<text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${participant.cx}" y="${layout.topY + layout.participantLabelY}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(participant.label)}</text>${sub}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
// Lifelines never enter the legend band. The legend is placed below all
|
||||
// timeline content, so stopping above its title still reaches every message.
|
||||
function lifelineEnd() {
|
||||
const legend = measureLegend(legendEntries(), legendLayout());
|
||||
return legend?.titleY == null ? layout.lifelineBottom : Math.min(layout.lifelineBottom, legend.titleY - 22);
|
||||
}
|
||||
|
||||
function renderLifeline(participant, end) {
|
||||
return ` <path d="M ${participant.cx} ${layout.lifelineTop} L ${participant.cx} ${end}" class="a-default" stroke-width="0.8" stroke-dasharray="3,7"/>`;
|
||||
}
|
||||
|
||||
function renderSegment(segment, index) {
|
||||
return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="segment" data-composition-frame-id="${index}" x="48" y="${segment.from}" width="${viewBox[0] - 96}" height="${segment.to - segment.from}" rx="10" class="c-lane" stroke-width="1"/>`;
|
||||
}
|
||||
|
||||
function renderSegmentLabel(segment, index) {
|
||||
const label = segmentLabelBox(segment);
|
||||
return ` <g data-graph-role="segment-label" data-segment-id="${index}">
|
||||
<rect x="${label.x}" y="${label.y}" width="${label.width}" height="${label.height}" rx="3" class="c-mask"/>
|
||||
<text x="${label.x + 6}" y="${label.y + 13}" class="t-dim" font-size="9" font-weight="600">${esc(segment.label)}</text>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function renderActivation(activation) {
|
||||
const participant = participants.get(activation.participant);
|
||||
const fill = componentFill[activation.type] || componentFill[participant.type] || 'c-external';
|
||||
const x = participant.cx - 5;
|
||||
const height = activation.to - activation.from;
|
||||
return ` <rect x="${x}" y="${activation.from}" width="10" height="${height}" rx="3" class="c-mask"/>
|
||||
<rect x="${x}" y="${activation.from}" width="10" height="${height}" rx="3" class="${fill}" stroke-width="1"/>`;
|
||||
}
|
||||
|
||||
function messageLabel(message, x1, x2) {
|
||||
const box = messageLabelBox(message);
|
||||
const center = box ? box.x + box.width / 2 : (x1 + x2) / 2;
|
||||
const y = message.y - 10;
|
||||
const labelW = box?.width || Math.max(34, textUnits(message.label) * messageUnitWidth + 12);
|
||||
// A colored line gets a label in the same color, as in the legend swatches.
|
||||
// Gray lines (default and return) keep the readable muted text color.
|
||||
const accent = ['emphasis', 'security', 'dashed'].includes(message.variant) ? edgeLabelAccent(message.variant) : 't-muted';
|
||||
return ` <g data-detail="context">
|
||||
<rect x="${center - labelW / 2}" y="${y - 10}" width="${labelW}" height="${layout.labelH}" rx="3" class="c-mask"/>
|
||||
<text x="${center}" y="${y}" class="${accent}" font-size="${messageFontSize}" text-anchor="middle">${esc(message.label)}</text>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function renderMessage(message, index) {
|
||||
const { start, end } = messageGeometry(message);
|
||||
const [cls, marker] = arrowClass[message.variant || 'default'] || arrowClass.default;
|
||||
const strokeWidth = message.variant === 'emphasis' ? 1.8 : 1.4;
|
||||
const dash = message.variant === 'return' ? ' stroke-dasharray="3,5"' : '';
|
||||
const note = message.note
|
||||
? `\n <text data-detail="fine" x="${Math.min(start, end) + 12}" y="${message.y + 18}" class="t-dim" font-size="7">${esc(message.note)}</text>`
|
||||
: '';
|
||||
return ` <g ${focusEdgeAttrs(message.from, message.to, message.label, index, message.id)}>
|
||||
<path data-composition-edge-from="${esc(message.from)}" data-composition-edge-to="${esc(message.to)}"${message.id ? ` data-composition-edge-id="${esc(message.id)}"` : ''} data-composition-points="${routePointsValue([[start, message.y], [end, message.y]])}" d="M ${start} ${message.y} L ${end} ${message.y}" class="${cls}"${animateAttr(sequence.meta, 'edge', index)} stroke-width="${strokeWidth}"${dash} marker-end="url(#${marker})"/>
|
||||
${messageLabel(message, start, end)}${note}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
|
||||
function legendLayout() {
|
||||
return {
|
||||
x: 40,
|
||||
baselineY: layout.legendY,
|
||||
width: viewBox[0] - 80,
|
||||
// The same content-based budget as legendRequiredHeight(): a wrapped
|
||||
// legend may use any rows it needs as long as it stays below the content.
|
||||
minTitleY: Math.max(layout.lifelineTop, contentBottom + LEGEND_CONTENT_GAP),
|
||||
unfit: sequence.meta?.legend === undefined ? 'hide' : 'error',
|
||||
diagramType: 'sequence',
|
||||
};
|
||||
}
|
||||
|
||||
function renderLegend() {
|
||||
return renderResolvedLegend({
|
||||
entries: legendEntries(),
|
||||
locale: sequence.meta.locale,
|
||||
layout: legendLayout(),
|
||||
renderSwatch: (entry) => `<path d="M ${entry.x} ${entry.baseline - 3} L ${entry.x + 34} ${entry.baseline - 3}" class="${entry.className}" stroke-width="${entry.strokeWidth || 1.4}"${entry.dash ? ` stroke-dasharray="${entry.dash}"` : ''} marker-end="url(#${entry.marker})"/>`,
|
||||
});
|
||||
}
|
||||
|
||||
function renderSvg() {
|
||||
const participantList = [...participants.values()];
|
||||
// Same default-canvas contract as lifecycle: 920x760 is below the 1.55 wide
|
||||
// ratio, so without intrinsic-height the desktop Reader can neither narrow
|
||||
// nor scroll it and every default sequence fails the browser gate.
|
||||
const readerFit = sequence.meta?.viewBox ? '' : ' data-reader-fit="intrinsic-height"';
|
||||
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" data-sequence-column-fit="${columnFit}"${readerFit} ${svgRootAttrs(sequence.meta)}>
|
||||
${svgAccessibleText(sequence.meta, 'sequence')}
|
||||
${renderDefinitions()}
|
||||
|
||||
<!-- Background Grid -->
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
<!-- Time Segments -->
|
||||
${asArray(sequence.segments).map(renderSegment).join('\n\n')}
|
||||
|
||||
<!-- Lifelines -->
|
||||
${participantList.map((participant) => renderLifeline(participant, lifelineEnd())).join('\n')}
|
||||
|
||||
<!-- Activations -->
|
||||
${asArray(sequence.activations).map(renderActivation).join('\n')}
|
||||
|
||||
<!-- Messages -->
|
||||
${asArray(sequence.messages).map(renderMessage).join('\n\n')}
|
||||
|
||||
<!-- Segment Labels -->
|
||||
${asArray(sequence.segments).map(renderSegmentLabel).join('\n')}
|
||||
|
||||
<!-- Participants -->
|
||||
${participantList.map(renderParticipant).join('\n\n')}
|
||||
|
||||
<!-- Legend -->
|
||||
${renderLegend()}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
validateSequence();
|
||||
writeDiagram({
|
||||
outPath,
|
||||
template,
|
||||
diagramType: 'sequence',
|
||||
meta: sequence.meta,
|
||||
svg: renderSvg(),
|
||||
cards: sequence.cards,
|
||||
sourceEvidence,
|
||||
});
|
||||
+1982
File diff suppressed because it is too large
Load Diff
+670
@@ -0,0 +1,670 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import net from 'node:net';
|
||||
import { BRAND_MARKS } from './generated-brand-marks.mjs';
|
||||
import { throwDiagnosticError } from './diagnostics.mjs';
|
||||
import { esc, textUnits } from './utils.mjs';
|
||||
|
||||
const COLLECTIONS = Object.freeze({
|
||||
architecture: 'components',
|
||||
workflow: 'nodes',
|
||||
sequence: 'participants',
|
||||
dataflow: 'nodes',
|
||||
lifecycle: 'states',
|
||||
});
|
||||
const MARK_BY_LOOKUP = new Map();
|
||||
const MARK_BY_DOMAIN = new Map();
|
||||
const RESOLVED_BY_NODE = new WeakMap();
|
||||
const RESOLVED_MARK = Symbol('archify.brandMark');
|
||||
const MAX_HTML_BYTES = 256 * 1024;
|
||||
const MAX_IMAGE_BYTES = 1024 * 1024;
|
||||
const MAX_CAPTURE_CONCURRENCY = 3;
|
||||
const DEFAULT_CAPTURE_TIMEOUT_MS = 8000;
|
||||
const USER_AGENT = 'Archify/2.15 brand-preview';
|
||||
|
||||
function lookupForms(value) {
|
||||
const raw = String(value ?? '').trim().toLocaleLowerCase('en-US');
|
||||
if (!raw) return [];
|
||||
const dashed = raw.replace(/[\s_]+/g, '-');
|
||||
const compact = raw.replace(/[\s_.-]+/g, '');
|
||||
return [...new Set([raw, dashed, compact])];
|
||||
}
|
||||
|
||||
for (const mark of BRAND_MARKS) {
|
||||
for (const value of [mark.id, mark.title, ...mark.aliases]) {
|
||||
for (const form of lookupForms(value)) {
|
||||
if (!MARK_BY_LOOKUP.has(form)) MARK_BY_LOOKUP.set(form, mark);
|
||||
}
|
||||
}
|
||||
for (const domain of mark.domains) MARK_BY_DOMAIN.set(domain, mark);
|
||||
}
|
||||
|
||||
function asUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value));
|
||||
return ['https:', 'http:'].includes(url.protocol) ? url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function domainMark(hostname) {
|
||||
const host = hostname.toLocaleLowerCase('en-US').replace(/\.$/, '');
|
||||
const candidates = [...MARK_BY_DOMAIN.entries()]
|
||||
.filter(([domain]) => host === domain || host.endsWith(`.${domain}`))
|
||||
.sort(([left], [right]) => right.length - left.length);
|
||||
return candidates[0]?.[1] || null;
|
||||
}
|
||||
|
||||
export function findBrandMark(value) {
|
||||
const url = asUrl(value);
|
||||
if (url) return domainMark(url.hostname);
|
||||
for (const form of lookupForms(value)) {
|
||||
const mark = MARK_BY_LOOKUP.get(form);
|
||||
if (mark) return mark;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function listBrandMarks(query = '') {
|
||||
const needle = String(query).trim().toLocaleLowerCase('en-US');
|
||||
return BRAND_MARKS.filter((mark) => {
|
||||
if (!needle) return true;
|
||||
return [mark.id, mark.title, mark.category, ...mark.aliases, ...mark.domains]
|
||||
.some((value) => String(value).toLocaleLowerCase('en-US').includes(needle));
|
||||
}).map(({ path, ...mark }) => mark);
|
||||
}
|
||||
|
||||
function ipv4Private(address) {
|
||||
const parts = address.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
|
||||
const [a, b, c] = parts;
|
||||
return a === 0 || a === 10 || a === 127 || a >= 224
|
||||
|| (a === 100 && b >= 64 && b <= 127)
|
||||
|| (a === 169 && b === 254)
|
||||
|| (a === 172 && b >= 16 && b <= 31)
|
||||
|| (a === 192 && b === 0 && (c === 0 || c === 2))
|
||||
|| (a === 192 && b === 88 && c === 99)
|
||||
|| (a === 192 && b === 168)
|
||||
|| (a === 198 && (b === 18 || b === 19))
|
||||
|| (a === 198 && b === 51 && c === 100)
|
||||
|| (a === 203 && b === 0 && c === 113);
|
||||
}
|
||||
|
||||
function ipv6Private(address) {
|
||||
const normalized = address.toLocaleLowerCase('en-US').split('%')[0];
|
||||
if (normalized === '::' || normalized === '::1') return true;
|
||||
if (normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('ff') || /^fe[89ab]/.test(normalized)) return true;
|
||||
if (normalized.startsWith('64:ff9b:') || normalized.startsWith('100:')
|
||||
|| normalized.startsWith('2001:db8:') || normalized.startsWith('2002:')) return true;
|
||||
const mappedDotted = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (mappedDotted) return ipv4Private(mappedDotted[1]);
|
||||
const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
||||
if (mappedHex) {
|
||||
const high = Number.parseInt(mappedHex[1], 16);
|
||||
const low = Number.parseInt(mappedHex[2], 16);
|
||||
return ipv4Private(`${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`);
|
||||
}
|
||||
const compatibleHex = normalized.match(/^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
||||
if (compatibleHex) {
|
||||
const high = Number.parseInt(compatibleHex[1], 16);
|
||||
const low = Number.parseInt(compatibleHex[2], 16);
|
||||
return ipv4Private(`${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isPrivateBrandAddress(address) {
|
||||
const family = net.isIP(address);
|
||||
return family === 4 ? ipv4Private(address) : (family === 6 ? ipv6Private(address) : true);
|
||||
}
|
||||
|
||||
function validateUrlShape(url, allowPrivate = process.env.ARCHIFY_BRAND_ALLOW_PRIVATE === '1') {
|
||||
if (!['https:', 'http:'].includes(url.protocol)) throw new Error('only HTTP(S) brand links are supported');
|
||||
if (url.username || url.password) throw new Error('brand links cannot contain credentials');
|
||||
const expectedPort = url.protocol === 'https:' ? '443' : '80';
|
||||
if (!allowPrivate && url.port && url.port !== expectedPort) {
|
||||
throw new Error('brand links must use a standard web port');
|
||||
}
|
||||
const host = url.hostname.toLocaleLowerCase('en-US').replace(/\.$/, '').replace(/^\[|\]$/g, '');
|
||||
if (!allowPrivate && (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local'))) {
|
||||
throw new Error('private brand links are not fetched');
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
function beforeDeadline(promise, deadline) {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) return Promise.reject(new Error('brand capture timed out'));
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('brand capture timed out')), remaining);
|
||||
timer.unref?.();
|
||||
promise.then(
|
||||
(value) => { clearTimeout(timer); resolve(value); },
|
||||
(error) => { clearTimeout(timer); reject(error); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveRequestTarget(url, deadline) {
|
||||
const allowPrivate = process.env.ARCHIFY_BRAND_ALLOW_PRIVATE === '1';
|
||||
const host = validateUrlShape(url, allowPrivate);
|
||||
const directFamily = net.isIP(host);
|
||||
const addresses = directFamily
|
||||
? [{ address: host, family: directFamily }]
|
||||
: await beforeDeadline(lookup(host, { all: true, verbatim: true }), deadline);
|
||||
if (!addresses.length || (!allowPrivate && addresses.some(({ address }) => isPrivateBrandAddress(address)))) {
|
||||
throw new Error('private brand links are not fetched');
|
||||
}
|
||||
return addresses[0];
|
||||
}
|
||||
|
||||
function timeoutSignal(milliseconds) {
|
||||
if (typeof AbortSignal.timeout === 'function') return AbortSignal.timeout(milliseconds);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), milliseconds);
|
||||
timer.unref?.();
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
function captureTimeoutMilliseconds() {
|
||||
const configured = Number(process.env.ARCHIFY_BRAND_CAPTURE_TIMEOUT_MS);
|
||||
if (!Number.isFinite(configured)) return DEFAULT_CAPTURE_TIMEOUT_MS;
|
||||
return Math.max(100, Math.min(30000, Math.round(configured)));
|
||||
}
|
||||
|
||||
function requestPinned(url, accept, target, deadline) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
const request = transport.request(url, {
|
||||
method: 'GET',
|
||||
signal: timeoutSignal(Math.max(1, Math.min(4500, deadline - Date.now()))),
|
||||
headers: { accept, 'accept-encoding': 'identity', 'user-agent': USER_AGENT },
|
||||
// Reuse the exact public address that passed validation. This closes the
|
||||
// DNS-rebinding gap between checking a hostname and opening its socket.
|
||||
lookup(_hostname, options, callback) {
|
||||
if (options?.all) callback(null, [target]);
|
||||
else callback(null, target.address, target.family);
|
||||
},
|
||||
}, (response) => {
|
||||
const status = response.statusCode || 0;
|
||||
resolve({
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
headers: {
|
||||
get(name) {
|
||||
const value = response.headers[String(name).toLocaleLowerCase('en-US')];
|
||||
return Array.isArray(value) ? value.join(', ') : (value ?? null);
|
||||
},
|
||||
},
|
||||
body: response,
|
||||
});
|
||||
});
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function checkedFetch(input, accept, deadline) {
|
||||
let current = new URL(input);
|
||||
for (let redirects = 0; redirects <= 3; redirects += 1) {
|
||||
if (Date.now() >= deadline) throw new Error('brand capture timed out');
|
||||
const target = await resolveRequestTarget(current, deadline);
|
||||
const response = await requestPinned(current, accept, target, deadline);
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
const location = response.headers.get('location');
|
||||
response.body.resume();
|
||||
if (!location || redirects === 3) throw new Error('brand link redirected too many times');
|
||||
current = new URL(location, current);
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) {
|
||||
response.body.resume();
|
||||
throw new Error(`brand link returned HTTP ${response.status}`);
|
||||
}
|
||||
// Raw HTTP responses are not decompressed. Check successful bodies before
|
||||
// HTML discovery or image validation so byte limits and digests stay valid.
|
||||
const contentEncoding = (response.headers.get('content-encoding') || '').trim().toLowerCase();
|
||||
if (contentEncoding && contentEncoding !== 'identity') {
|
||||
response.body.destroy();
|
||||
throw new Error(`unsupported brand content encoding ${contentEncoding}`);
|
||||
}
|
||||
return { response, finalUrl: current };
|
||||
}
|
||||
throw new Error('brand link redirected too many times');
|
||||
}
|
||||
|
||||
async function readLimited(response, maximum) {
|
||||
const declared = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declared) && declared > maximum) {
|
||||
response.body?.destroy?.();
|
||||
throw new Error('brand asset is too large');
|
||||
}
|
||||
if (response.body && typeof response.body[Symbol.asyncIterator] === 'function') {
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
for await (const value of response.body) {
|
||||
total += value.byteLength;
|
||||
if (total > maximum) {
|
||||
response.body.destroy?.();
|
||||
throw new Error('brand asset is too large');
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
if (!response.body?.getReader) {
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.length > maximum) throw new Error('brand asset is too large');
|
||||
return buffer;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maximum) {
|
||||
await reader.cancel();
|
||||
throw new Error('brand asset is too large');
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
|
||||
// Read only the bounded head, independent of network chunk boundaries. Scan
|
||||
// bytes once so many tiny chunks cannot cause repeated concatenation/rescanning.
|
||||
// This is a boundary scanner, not a DOM parser: comments, quoted attributes and
|
||||
// raw-text elements must not turn a literal </head> into an early stop.
|
||||
async function readHtmlHead(response, maximum) {
|
||||
const chunks = response.body && typeof response.body[Symbol.asyncIterator] === 'function'
|
||||
? response.body : [await readLimited(response, maximum)];
|
||||
const buffer = Buffer.alloc(maximum);
|
||||
let total = 0;
|
||||
let tagStart = -1;
|
||||
let quote = 0;
|
||||
let comment = false;
|
||||
let rawClosing = '';
|
||||
let matched = 0;
|
||||
for await (const value of chunks) {
|
||||
const chunk = Buffer.from(value);
|
||||
const length = Math.min(chunk.length, maximum - total);
|
||||
chunk.copy(buffer, total, 0, length);
|
||||
for (let offset = 0; offset < length; offset++) {
|
||||
const byte = chunk[offset];
|
||||
const position = total + offset;
|
||||
if (comment) {
|
||||
if (byte === 0x3e && buffer[position - 1] === 0x2d && buffer[position - 2] === 0x2d) comment = false;
|
||||
continue;
|
||||
}
|
||||
if (rawClosing) {
|
||||
const lower = byte >= 65 && byte <= 90 ? byte + 32 : byte;
|
||||
if (matched === rawClosing.length && [9, 10, 12, 13, 32, 47, 62].includes(byte)) {
|
||||
tagStart = position - matched;
|
||||
rawClosing = '';
|
||||
matched = 0;
|
||||
} else {
|
||||
matched = lower === rawClosing.charCodeAt(matched) ? matched + 1 : (byte === 0x3c ? 1 : 0);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (tagStart < 0) {
|
||||
if (byte === 0x3c) tagStart = position;
|
||||
continue;
|
||||
}
|
||||
if (position === tagStart + 1 && !((byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || [33, 47, 63].includes(byte))) {
|
||||
tagStart = byte === 0x3c ? position : -1;
|
||||
continue;
|
||||
}
|
||||
if (position === tagStart + 3 && buffer[tagStart + 1] === 0x21 && buffer[tagStart + 2] === 0x2d && byte === 0x2d) {
|
||||
comment = true;
|
||||
tagStart = -1;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (byte === quote) quote = 0;
|
||||
continue;
|
||||
}
|
||||
if (byte === 0x22 || byte === 0x27) {
|
||||
quote = byte;
|
||||
continue;
|
||||
}
|
||||
if (byte === 0x3e) {
|
||||
const tag = buffer.toString('utf8', tagStart, position + 1);
|
||||
if (/^<\/head[\t\n\f\r ]*>$/i.test(tag)) {
|
||||
response.body?.destroy?.();
|
||||
return buffer.toString('utf8', 0, position + 1);
|
||||
}
|
||||
const raw = /^<(script|style|title|textarea|xmp|iframe|noembed|noframes)(?=[\t\n\f\r />])/i.exec(tag);
|
||||
if (raw) rawClosing = `</${raw[1].toLowerCase()}`;
|
||||
tagStart = -1;
|
||||
}
|
||||
}
|
||||
total += length;
|
||||
if (chunk.length > length) {
|
||||
response.body?.destroy?.();
|
||||
throw new Error('brand asset is too large');
|
||||
}
|
||||
}
|
||||
return buffer.toString('utf8', 0, total);
|
||||
}
|
||||
|
||||
function attribute(tag, name) {
|
||||
const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
|
||||
return match ? (match[1] ?? match[2] ?? match[3] ?? '') : '';
|
||||
}
|
||||
|
||||
// HTML numeric references in the C1 range use the legacy Windows-1252 mapping.
|
||||
// https://html.spec.whatwg.org/multipage/parsing.html#numeric-character-reference-end-state
|
||||
const HTML_C1_REFERENCES = [
|
||||
0x20ac, 0x81, 0x201a, 0x192, 0x201e, 0x2026, 0x2020, 0x2021,
|
||||
0x2c6, 0x2030, 0x160, 0x2039, 0x152, 0x8d, 0x17d, 0x8f,
|
||||
0x90, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022, 0x2013, 0x2014,
|
||||
0x2dc, 0x2122, 0x161, 0x203a, 0x153, 0x9d, 0x17e, 0x178,
|
||||
];
|
||||
const BASIC_HTML_REFERENCES = { amp: '&', quot: '"', apos: "'", lt: '<', gt: '>' };
|
||||
|
||||
function decodeIconHref(value) {
|
||||
// Decode only after extracting the attribute, in one pass. Leave percent
|
||||
// escapes to URL parsing and do not reinterpret decoded quotes as markup.
|
||||
return value.replace(/&#(?:[xX]([0-9a-fA-F]+)|([0-9]+));?|&(amp|AMP|quot|QUOT|lt|LT|gt|GT)(?:;|(?![A-Za-z0-9=]))|&(apos);/g,
|
||||
(_match, hex, decimal, named, apostrophe) => {
|
||||
if (named || apostrophe) return BASIC_HTML_REFERENCES[(named || apostrophe).toLowerCase()];
|
||||
let point = Number.parseInt(hex || decimal, hex ? 16 : 10);
|
||||
if (point === 0 || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff)) return '\uFFFD';
|
||||
if (point >= 0x80 && point <= 0x9f) point = HTML_C1_REFERENCES[point - 0x80];
|
||||
return String.fromCodePoint(point);
|
||||
});
|
||||
}
|
||||
|
||||
function iconCandidates(html, pageUrl) {
|
||||
const candidates = [];
|
||||
for (const match of html.matchAll(/<link\b[^>]*>/gi)) {
|
||||
const tag = match[0];
|
||||
const rel = attribute(tag, 'rel').toLocaleLowerCase('en-US').split(/\s+/);
|
||||
if (!rel.some((value) => value === 'icon' || value === 'apple-touch-icon' || value === 'mask-icon')) continue;
|
||||
const href = decodeIconHref(attribute(tag, 'href'));
|
||||
if (!href) continue;
|
||||
try {
|
||||
const url = new URL(href, pageUrl);
|
||||
if (!['https:', 'http:'].includes(url.protocol)) continue;
|
||||
const type = attribute(tag, 'type').toLocaleLowerCase('en-US');
|
||||
const sizes = attribute(tag, 'sizes');
|
||||
const area = [...sizes.matchAll(/(\d+)x(\d+)/gi)]
|
||||
.reduce((best, size) => Math.max(best, Number(size[1]) * Number(size[2])), 0);
|
||||
const score = (type.includes('svg') || /\.svg(?:$|[?#])/i.test(url.href) ? 1000000 : 0)
|
||||
+ (rel.includes('apple-touch-icon') ? 500000 : 0)
|
||||
+ area;
|
||||
candidates.push({ url, score });
|
||||
} catch {
|
||||
// A malformed icon candidate is ignored; the deterministic fallback remains available.
|
||||
}
|
||||
}
|
||||
candidates.sort((left, right) => right.score - left.score);
|
||||
const fallback = new URL('/favicon.ico', pageUrl);
|
||||
const unique = new Map(candidates.map((candidate) => [candidate.url.href, candidate]));
|
||||
unique.delete(fallback.href);
|
||||
return [...unique.values()].slice(0, 5).concat({ url: fallback, score: -1 });
|
||||
}
|
||||
|
||||
async function imageData(response) {
|
||||
const contentType = (response.headers.get('content-type') || '').split(';')[0].trim().toLocaleLowerCase('en-US');
|
||||
const allowed = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/x-icon',
|
||||
'image/vnd.microsoft.icon',
|
||||
]);
|
||||
if (!allowed.has(contentType)) {
|
||||
response.body?.destroy?.();
|
||||
throw new Error(`unsupported brand image type ${contentType || 'unknown'}`);
|
||||
}
|
||||
const buffer = await readLimited(response, MAX_IMAGE_BYTES);
|
||||
const signatureMatches = contentType === 'image/png'
|
||||
? buffer.length >= 45
|
||||
&& buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||
&& buffer.readUInt32BE(8) === 13
|
||||
&& buffer.toString('ascii', 12, 16) === 'IHDR'
|
||||
&& buffer.readUInt32BE(16) > 0
|
||||
&& buffer.readUInt32BE(20) > 0
|
||||
&& buffer.toString('ascii', buffer.length - 8, buffer.length - 4) === 'IEND'
|
||||
: (contentType === 'image/jpeg'
|
||||
? buffer.length >= 20
|
||||
&& buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff
|
||||
&& buffer.at(-2) === 0xff && buffer.at(-1) === 0xd9
|
||||
: (contentType === 'image/webp'
|
||||
? buffer.length >= 16
|
||||
&& buffer.toString('ascii', 0, 4) === 'RIFF'
|
||||
&& buffer.toString('ascii', 8, 12) === 'WEBP'
|
||||
&& buffer.readUInt32LE(4) + 8 <= buffer.length
|
||||
: buffer.length >= 22
|
||||
&& buffer[0] === 0 && buffer[1] === 0 && buffer[2] === 1 && buffer[3] === 0
|
||||
&& buffer.readUInt16LE(4) > 0
|
||||
&& 6 + buffer.readUInt16LE(4) * 16 <= buffer.length));
|
||||
if (!signatureMatches) throw new Error(`brand asset bytes do not match ${contentType}`);
|
||||
return {
|
||||
dataUrl: `data:${contentType};base64,${buffer.toString('base64')}`,
|
||||
sha256: createHash('sha256').update(buffer).digest('hex'),
|
||||
contentType,
|
||||
};
|
||||
}
|
||||
|
||||
async function captureRemoteBrand(value, deadline = Date.now() + captureTimeoutMilliseconds()) {
|
||||
const sourceUrl = new URL(value);
|
||||
const fallback = (reason) => ({
|
||||
id: sourceUrl.hostname,
|
||||
title: sourceUrl.hostname,
|
||||
category: 'link',
|
||||
kind: 'fallback',
|
||||
status: 'unavailable',
|
||||
sourceUrl: sourceUrl.href,
|
||||
reason,
|
||||
});
|
||||
try {
|
||||
const page = await checkedFetch(sourceUrl, 'text/html,application/xhtml+xml,image/*;q=0.8', deadline);
|
||||
const pageType = (page.response.headers.get('content-type') || '').toLocaleLowerCase('en-US');
|
||||
if (pageType.startsWith('image/')) {
|
||||
const image = await imageData(page.response);
|
||||
return {
|
||||
id: sourceUrl.hostname,
|
||||
title: sourceUrl.hostname,
|
||||
category: 'link',
|
||||
kind: 'remote',
|
||||
status: 'captured',
|
||||
sourceUrl: sourceUrl.href,
|
||||
resolvedUrl: page.finalUrl.href,
|
||||
...image,
|
||||
};
|
||||
}
|
||||
if (!pageType.includes('text/html') && !pageType.includes('application/xhtml+xml')) {
|
||||
page.response.body?.destroy?.();
|
||||
return fallback('linked page is not HTML');
|
||||
}
|
||||
const html = await readHtmlHead(page.response, MAX_HTML_BYTES);
|
||||
const iconErrors = [];
|
||||
for (const candidate of iconCandidates(html, page.finalUrl)) {
|
||||
try {
|
||||
const fetched = await checkedFetch(candidate.url, 'image/*', deadline);
|
||||
const image = await imageData(fetched.response);
|
||||
return {
|
||||
id: sourceUrl.hostname,
|
||||
title: sourceUrl.hostname,
|
||||
category: 'link',
|
||||
kind: 'remote',
|
||||
status: 'captured',
|
||||
sourceUrl: sourceUrl.href,
|
||||
resolvedUrl: fetched.finalUrl.href,
|
||||
...image,
|
||||
};
|
||||
} catch (error) {
|
||||
iconErrors.push(error);
|
||||
// Try the next declared favicon before using the generic link mark.
|
||||
}
|
||||
}
|
||||
const usefulError = iconErrors.find((error) => /unsupported brand (?:image type|content encoding)/i.test(error?.message))
|
||||
|| iconErrors.at(-1);
|
||||
return fallback(usefulError?.message || 'no usable site icon was found');
|
||||
} catch (error) {
|
||||
return fallback(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureBrandReference(value) {
|
||||
const url = asUrl(value);
|
||||
if (!url) throw new Error('brand capture requires one HTTP(S) URL');
|
||||
validateUrlShape(url);
|
||||
const preset = findBrandMark(url.href);
|
||||
if (preset) return { brand: preset.id, resolved: { ...preset, kind: 'preset', status: 'preset' } };
|
||||
const resolved = await captureRemoteBrand(url.href);
|
||||
if (resolved.status !== 'captured' || !resolved.sha256) {
|
||||
throw new Error(`brand capture failed: ${resolved.reason || 'no usable site icon was found'}`);
|
||||
}
|
||||
return {
|
||||
brand: { url: url.href, sha256: resolved.sha256 },
|
||||
resolved,
|
||||
};
|
||||
}
|
||||
|
||||
function remoteBrand(value, cache, deadline) {
|
||||
const key = new URL(value).href;
|
||||
if (!cache.has(key)) cache.set(key, captureRemoteBrand(key, deadline));
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
function suggestions(value) {
|
||||
const needle = lookupForms(value)[0] || '';
|
||||
return BRAND_MARKS.map((mark) => ({
|
||||
id: mark.id,
|
||||
score: lookupForms(mark.id).some((form) => form.includes(needle) || needle.includes(form)) ? 0 : 1,
|
||||
})).sort((left, right) => left.score - right.score || left.id.localeCompare(right.id))
|
||||
.slice(0, 5)
|
||||
.map((entry) => entry.id);
|
||||
}
|
||||
|
||||
async function mapConcurrent(values, limit, visit) {
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, values.length) }, async () => {
|
||||
while (cursor < values.length) {
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
await visit(values[index], index);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
export async function prepareDiagramBrandMarks(diagramType, diagram) {
|
||||
const collection = COLLECTIONS[diagramType];
|
||||
const nodes = collection && Array.isArray(diagram[collection]) ? diagram[collection] : [];
|
||||
const unknown = [];
|
||||
const remoteByUrl = new Map();
|
||||
const deadline = Date.now() + captureTimeoutMilliseconds();
|
||||
await mapConcurrent(nodes, MAX_CAPTURE_CONCURRENCY, async (node, index) => {
|
||||
if (!node.brand) return;
|
||||
if (typeof node.brand === 'object') {
|
||||
const url = asUrl(node.brand.url);
|
||||
const resolved = url ? await remoteBrand(url.href, remoteByUrl, deadline) : null;
|
||||
if (!resolved || resolved.status !== 'captured') {
|
||||
unknown.push(`/${collection}/${index}/brand could not reproduce the pinned capture: ${resolved?.reason || 'invalid URL'}`);
|
||||
return;
|
||||
}
|
||||
if (resolved.sha256 !== node.brand.sha256) {
|
||||
unknown.push(`/${collection}/${index}/brand digest changed: expected ${node.brand.sha256}, received ${resolved.sha256}`);
|
||||
return;
|
||||
}
|
||||
node[RESOLVED_MARK] = resolved;
|
||||
RESOLVED_BY_NODE.set(node, resolved);
|
||||
return;
|
||||
}
|
||||
const preset = findBrandMark(node.brand);
|
||||
if (preset) {
|
||||
const resolved = { ...preset, kind: 'preset', status: 'preset', sourceUrl: preset.provenance.source };
|
||||
node[RESOLVED_MARK] = resolved;
|
||||
RESOLVED_BY_NODE.set(node, resolved);
|
||||
return;
|
||||
}
|
||||
const url = asUrl(node.brand);
|
||||
if (url) {
|
||||
unknown.push(`/${collection}/${index}/brand ${JSON.stringify(node.brand)} is an unpinned URL; capture it first with \`archify brands capture ${url.href} --json\``);
|
||||
return;
|
||||
}
|
||||
unknown.push(`/${collection}/${index}/brand ${JSON.stringify(node.brand)} is not a built-in brand; closest IDs: ${suggestions(node.brand).join(', ')}`);
|
||||
});
|
||||
if (unknown.length) {
|
||||
throwDiagnosticError(`Brand mark validation failed:\n- ${unknown.join('\n- ')}`, unknown.map((message) => ({
|
||||
code: message.includes('is an unpinned URL') ? 'brand/unpinned-url'
|
||||
: (message.includes('digest changed') ? 'brand/digest-mismatch'
|
||||
: (message.includes('could not reproduce') ? 'brand/capture-unavailable' : 'brand/unknown')),
|
||||
severity: 'error',
|
||||
message,
|
||||
subject: { diagramType, collection },
|
||||
evidence: {},
|
||||
supportedFixes: message.includes('is an unpinned URL')
|
||||
? ['run `archify brands capture <url> --json` and author the returned digest-pinned brand object']
|
||||
: ['choose an ID from `archify brands`', 'run `archify brands capture <url> --json` for an unknown official site'],
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
export function brandMarkFor(node) {
|
||||
return node?.[RESOLVED_MARK] || RESOLVED_BY_NODE.get(node) || null;
|
||||
}
|
||||
|
||||
export function brandMetadataFor(node) {
|
||||
const mark = brandMarkFor(node);
|
||||
return mark ? {
|
||||
brand: mark.title,
|
||||
brandId: mark.id,
|
||||
brandStatus: mark.status,
|
||||
brandSource: mark.sourceUrl,
|
||||
} : {};
|
||||
}
|
||||
|
||||
export function brandLabelFitWidth(node, width) {
|
||||
return brandMarkFor(node) ? Math.max(1, width - 48) : width;
|
||||
}
|
||||
|
||||
export function brandTopRailProblem(node, width, minimumFontSize, subject = 'Node') {
|
||||
if (!brandMarkFor(node)) return null;
|
||||
const available = width - 48;
|
||||
const required = textUnits(node.label) * minimumFontSize * 0.6;
|
||||
if (available >= required) return null;
|
||||
return `${subject} "${node.id}" brand top rail leaves ${Math.max(0, available)}px for its label, but `
|
||||
+ `"${node.label}" needs ~${Math.ceil(required)}px at the ${minimumFontSize}px legible minimum — widen the node or shorten the label.`;
|
||||
}
|
||||
|
||||
function markAttrs(mark) {
|
||||
return [
|
||||
`data-brand-mark="${esc(mark.id)}"`,
|
||||
`data-brand-title="${esc(mark.title)}"`,
|
||||
`data-brand-status="${esc(mark.status)}"`,
|
||||
mark.sourceUrl ? `data-brand-source="${esc(mark.sourceUrl)}"` : '',
|
||||
mark.sha256 ? `data-brand-sha256="${esc(mark.sha256)}"` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export function renderBrandMark(node, { x, y, size = 16 } = {}) {
|
||||
const mark = brandMarkFor(node);
|
||||
if (!mark) return '';
|
||||
const inset = 3;
|
||||
let content;
|
||||
if (mark.kind === 'preset') {
|
||||
const scale = (size - inset * 2) / mark.viewBox;
|
||||
content = `<path d="${esc(mark.path)}" transform="translate(${inset} ${inset}) scale(${scale})" fill="#${esc(mark.hex)}"/>`;
|
||||
} else if (mark.kind === 'remote') {
|
||||
content = `<image href="${esc(mark.dataUrl)}" x="${inset}" y="${inset}" width="${size - inset * 2}" height="${size - inset * 2}" preserveAspectRatio="xMidYMid meet"/>`;
|
||||
} else {
|
||||
const scale = size / 20;
|
||||
content = `<g transform="scale(${scale})" class="brand-mark-fallback"><circle cx="10" cy="10" r="5.2"/><path d="M4.8 10h10.4M10 4.8c1.6 1.6 2.4 3.3 2.4 5.2s-.8 3.6-2.4 5.2M10 4.8C8.4 6.4 7.6 8.1 7.6 10s.8 3.6 2.4 5.2"/></g>`;
|
||||
}
|
||||
return `<g aria-hidden="true" ${markAttrs(mark)} class="brand-mark" transform="translate(${x} ${y})">
|
||||
<rect width="${size}" height="${size}" rx="4" class="brand-mark-badge"/>
|
||||
${content}
|
||||
<rect width="${size}" height="${size}" rx="4" class="brand-mark-frame"/>
|
||||
</g>`;
|
||||
}
|
||||
+451
@@ -0,0 +1,451 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { applyTemplate, renderCards, esc } from './utils.mjs';
|
||||
import { validateSchema } from './validator.mjs';
|
||||
import { verifyRepositoryEvidence } from './repository-evidence.mjs';
|
||||
import { installRendererDiagnosticBoundary, throwDiagnosticError, throwDiagnosticProblems, recordDiagnostic } from './diagnostics.mjs';
|
||||
import { validateEngineeringProfile } from './engineering-profiles.mjs';
|
||||
import {
|
||||
resolveOutputPath,
|
||||
validateAuthoredOutputPath,
|
||||
} from './output-path.mjs';
|
||||
import {
|
||||
captureAtomicOutput,
|
||||
captureRegularFileBinding,
|
||||
publishRegularFileBinding,
|
||||
releaseRegularFileBinding,
|
||||
removeOwnedRegularFile,
|
||||
verifyAtomicOutput,
|
||||
} from './atomic-output.mjs';
|
||||
import { resolveLocale, translateMessage, registerLocale, SUPPORTED_LOCALES } from './i18n.mjs';
|
||||
import { prepareDiagramBrandMarks } from './brand-marks.mjs';
|
||||
|
||||
const outputPathGuards = new Map();
|
||||
let renderCandidateSequence = 0;
|
||||
|
||||
// meta.locale is renderer-owned Viewer UI, not authored content.
|
||||
// en and zh-CN ship as built-in catalogs.
|
||||
// Any other tag needs meta.translations (validated against the English
|
||||
// message-key set, layered over English per-key so partial/invalid entries
|
||||
// never break rendering) or it falls back to the English Viewer chrome —
|
||||
// the same "omit locale, disclose the fallback" contract as before, just
|
||||
// resolved from data instead of a hard-coded enum. See i18n.mjs.
|
||||
function applyLocaleTranslations(diagramType, diagram) {
|
||||
const locale = diagram.meta?.locale;
|
||||
if (!locale) return;
|
||||
const translations = diagram.meta?.translations;
|
||||
if (translations && Object.keys(translations).length) {
|
||||
const report = registerLocale(locale, translations);
|
||||
if (report.missingKeys.length || report.unknownKeys.length || report.placeholderMismatches.length) {
|
||||
recordDiagnostic({
|
||||
code: 'i18n/translation-coverage',
|
||||
severity: 'warning',
|
||||
message: `meta.translations for locale ${JSON.stringify(locale)} covers ${report.coveredKeys}/${report.totalKeys} renderer-owned messages (${Math.round(report.coverage * 100)}%); uncovered keys fall back to English.`,
|
||||
subject: { diagramType, path: '/meta/translations' },
|
||||
evidence: {
|
||||
missingKeys: report.missingKeys.slice(0, 10),
|
||||
missingKeysTotal: report.missingKeys.length,
|
||||
unknownKeys: report.unknownKeys.slice(0, 10),
|
||||
unknownKeysTotal: report.unknownKeys.length,
|
||||
placeholderMismatches: report.placeholderMismatches.slice(0, 10),
|
||||
placeholderMismatchesTotal: report.placeholderMismatches.length,
|
||||
},
|
||||
supportedFixes: ['Add the missing keys to meta.translations.', 'Match each translation\'s {placeholders} to the English source string.'],
|
||||
});
|
||||
// Coverage is a fact about this render, not just a diagnostic-mode
|
||||
// artifact: print it to stderr unconditionally so `render`/`deliver`/
|
||||
// `validate` disclose the fallback even without ARCHIFY_DIAGNOSTIC_FORMAT.
|
||||
console.warn(`archify: meta.translations for locale ${JSON.stringify(locale)} covers ${report.coveredKeys}/${report.totalKeys} renderer-owned messages (${Math.round(report.coverage * 100)}%); uncovered keys fall back to English.`);
|
||||
}
|
||||
} else if (!SUPPORTED_LOCALES.includes(locale)) {
|
||||
recordDiagnostic({
|
||||
code: 'i18n/locale-fallback',
|
||||
severity: 'warning',
|
||||
message: `meta.locale ${JSON.stringify(locale)} has no built-in catalog and no meta.translations; the Viewer chrome and <html lang> fall back to English.`,
|
||||
subject: { diagramType, path: '/meta/locale' },
|
||||
supportedFixes: ['Supply meta.translations for this locale.', `Use a built-in locale: ${SUPPORTED_LOCALES.join(', ')}.`],
|
||||
});
|
||||
console.warn(`archify: meta.locale ${JSON.stringify(locale)} has no built-in catalog and no meta.translations; the Viewer chrome and <html lang> fall back to English.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Common CLI head: node render-<type>.mjs [input.json] [output.html]
|
||||
// Keep this synchronous because callers also use it to establish the guarded
|
||||
// output path before testing a last-moment filesystem alias change.
|
||||
export function loadDiagram({ rendererDir, diagramType, defaultExample, argv = process.argv }) {
|
||||
// Compilers also import this module for SVG helpers. Only CLI execution
|
||||
// should install a process-level handler, before reading or validating input.
|
||||
installRendererDiagnosticBoundary();
|
||||
const skillRoot = path.resolve(rendererDir, '../..');
|
||||
const inputPath = path.resolve(argv[2] || path.join(skillRoot, 'examples', defaultExample));
|
||||
let input;
|
||||
try {
|
||||
input = fs.readFileSync(inputPath, 'utf8');
|
||||
} catch (error) {
|
||||
if (!isFilesystemError(error)) throw error;
|
||||
const message = `Input could not be read: ${error.message}`;
|
||||
throwDiagnosticError(message, [{
|
||||
code: 'input/read', message,
|
||||
subject: { input: inputPath },
|
||||
evidence: { systemCode: error.code, reason: error.message },
|
||||
supportedFixes: ['provide one readable JSON input file'],
|
||||
}]);
|
||||
}
|
||||
let diagram;
|
||||
try {
|
||||
diagram = JSON.parse(input);
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) throw error;
|
||||
const message = `Input JSON could not be parsed: ${error.message}`;
|
||||
throwDiagnosticError(message, [{
|
||||
code: 'input/json-parse', message,
|
||||
subject: { input: inputPath },
|
||||
evidence: { reason: error.message },
|
||||
supportedFixes: ['repair the JSON syntax and run validation again'],
|
||||
}]);
|
||||
}
|
||||
const authoredOutput = diagram?.meta?.output;
|
||||
if (authoredOutput !== undefined) validateAuthoredOutputPath(authoredOutput);
|
||||
validateSchema(diagramType, diagram);
|
||||
applyLocaleTranslations(diagramType, diagram);
|
||||
validateCrossCollectionContracts(diagramType, diagram);
|
||||
validateEngineeringProfile(diagramType, diagram);
|
||||
const sourceEvidence = verifyRepositoryEvidence(diagramType, diagram, process.env.ARCHIFY_REPO_ROOT);
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets/template.html'), 'utf8');
|
||||
const outputRequest = {
|
||||
requestedOutput: argv[3],
|
||||
authoredOutput: diagram.meta?.output,
|
||||
defaultOutput: `${diagramType}.html`,
|
||||
inputPaths: [inputPath],
|
||||
cwd: process.cwd(),
|
||||
};
|
||||
let outPath;
|
||||
try {
|
||||
({ outputPath: outPath } = resolveOutputPath(outputRequest));
|
||||
} catch (error) {
|
||||
throwOutputError(error, path.resolve(outputRequest.requestedOutput || outputRequest.authoredOutput || outputRequest.defaultOutput));
|
||||
}
|
||||
outputPathGuards.set(outPath, outputRequest);
|
||||
return { diagram, template, outPath, sourceEvidence };
|
||||
}
|
||||
|
||||
// Brand URL capture is the only asynchronous authoring step. Typed renderers
|
||||
// opt into it through this wrapper without changing loadDiagram's long-lived
|
||||
// synchronous safety contract.
|
||||
export async function loadDiagramWithBrandMarks(options) {
|
||||
const loaded = loadDiagram(options);
|
||||
await prepareDiagramBrandMarks(options.diagramType, loaded.diagram);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
const START_TYPES = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']);
|
||||
|
||||
function isFilesystemError(error) {
|
||||
return typeof error?.code === 'string'
|
||||
&& typeof error?.syscall === 'string'
|
||||
&& typeof error?.errno === 'number';
|
||||
}
|
||||
|
||||
function throwOutputError(error, output) {
|
||||
if (error?.archifyDiagnostics || !isFilesystemError(error)) throw error;
|
||||
const message = `Output could not be written: ${error.message}`;
|
||||
throwDiagnosticError(message, [{
|
||||
code: 'output/write', message,
|
||||
subject: { output },
|
||||
evidence: { systemCode: error.code, reason: error.message },
|
||||
supportedFixes: ['choose a writable HTML file path and ensure its parent directories can be created'],
|
||||
}]);
|
||||
}
|
||||
|
||||
function throwAtomicOutputFailure(result, output) {
|
||||
const reason = result.reason || { code: 'unclassified' };
|
||||
const changed = result.status === 'different';
|
||||
const candidate = reason.code.startsWith('candidate-');
|
||||
const nonRegular = ['target-not-regular-file', 'candidate-not-regular-file'].includes(reason.code);
|
||||
const hardlinked = ['target-hardlinked', 'candidate-hardlinked'].includes(reason.code);
|
||||
const message = changed
|
||||
? 'Output target changed while the rendered artifact was being prepared.'
|
||||
: nonRegular
|
||||
? candidate
|
||||
? 'Temporary output candidate is no longer a regular file.'
|
||||
: 'Output already exists and is not a regular file.'
|
||||
: hardlinked
|
||||
? candidate
|
||||
? 'Temporary output candidate has multiple hard-link names.'
|
||||
: 'Output already exists through multiple hard-link names.'
|
||||
: 'Output target stability could not be determined safely before commit.';
|
||||
throwDiagnosticError(message, [{
|
||||
code: changed
|
||||
? 'output/target-changed'
|
||||
: nonRegular
|
||||
? 'output/target-not-regular-file'
|
||||
: hardlinked
|
||||
? 'output/target-hardlinked'
|
||||
: 'output/target-indeterminate',
|
||||
message,
|
||||
subject: { output },
|
||||
evidence: { relation: reason },
|
||||
supportedFixes: [hardlinked
|
||||
? 'choose a non-hardlinked output path; atomic replacement cannot update every hard-link name'
|
||||
: 'retry after other processes stop replacing or redirecting the output path'],
|
||||
}]);
|
||||
}
|
||||
|
||||
function stageRenderedHtml(outputPath, html, mode) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
renderCandidateSequence += 1;
|
||||
const candidatePath = path.join(
|
||||
path.dirname(outputPath),
|
||||
`.archify-render-${process.pid}-${Date.now().toString(36)}-${renderCandidateSequence}.tmp`,
|
||||
);
|
||||
let descriptor;
|
||||
let identity;
|
||||
try {
|
||||
const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0);
|
||||
descriptor = fs.openSync(
|
||||
candidatePath,
|
||||
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow,
|
||||
mode ?? 0o666,
|
||||
);
|
||||
let metadata;
|
||||
try {
|
||||
metadata = fs.fstatSync(descriptor, { bigint: true });
|
||||
} catch (error) {
|
||||
// A transient first inspection failure must not strand the exclusive
|
||||
// candidate. A successful retry binds cleanup to the still-open file;
|
||||
// if both inspections fail, preserving the unknown entry is safer.
|
||||
try {
|
||||
const retry = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (retry.isFile() && retry.ino !== 0n) {
|
||||
identity = { device: retry.dev, inode: retry.ino };
|
||||
}
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
if (!metadata.isFile() || metadata.ino === 0n) {
|
||||
throw new Error('Temporary render candidate identity could not be verified safely.');
|
||||
}
|
||||
identity = { device: metadata.dev, inode: metadata.ino };
|
||||
fs.writeFileSync(descriptor, html);
|
||||
// Creation modes are filtered through the process umask. An atomic
|
||||
// replacement must retain the exact permissions of an existing target,
|
||||
// while a brand-new target should keep normal umask behavior.
|
||||
if (mode !== null) fs.fchmodSync(descriptor, mode);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
return { candidatePath, identity };
|
||||
} catch (error) {
|
||||
if (descriptor !== undefined) {
|
||||
try { fs.closeSync(descriptor); } catch {}
|
||||
}
|
||||
if (error.code === 'EEXIST') continue;
|
||||
if (identity) {
|
||||
const cleanup = removeOwnedRegularFile(candidatePath, identity);
|
||||
if (!['removed', 'absent', 'preserved'].includes(cleanup.status)) {
|
||||
const cleanupError = new Error(`${error.message}; temporary render candidate cleanup also failed.`);
|
||||
cleanupError.cause = error;
|
||||
throw cleanupError;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const error = new Error(`Could not reserve a temporary render candidate beside "${outputPath}".`);
|
||||
error.code = 'EEXIST';
|
||||
error.errno = -17;
|
||||
error.syscall = 'open';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Common CLI tail: fill the template and write the standalone HTML file.
|
||||
export function writeDiagram({ outPath, template, diagramType, meta, svg, cards, sourceEvidence = null }) {
|
||||
if (!START_TYPES.has(diagramType)) throw new Error(`writeDiagram: unknown diagram type ${JSON.stringify(diagramType)}`);
|
||||
const outputGuard = outputPathGuards.get(outPath);
|
||||
const html = applyTemplate(template, {
|
||||
title: meta.title,
|
||||
subtitle: meta.subtitle,
|
||||
svg,
|
||||
cards: renderCards(cards),
|
||||
locale: meta.locale,
|
||||
visualPreset: meta.visual_preset || 'classic',
|
||||
sourceEvidence,
|
||||
});
|
||||
let candidatePath;
|
||||
let candidateIdentity;
|
||||
let candidateBinding;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
const outputCapture = captureAtomicOutput(outPath);
|
||||
if (outputCapture.status !== 'captured') throwAtomicOutputFailure(outputCapture, outPath);
|
||||
const beforeStage = verifyAtomicOutput(outputCapture.snapshot);
|
||||
if (beforeStage.status !== 'match') throwAtomicOutputFailure(beforeStage, outPath);
|
||||
({ candidatePath, identity: candidateIdentity } = stageRenderedHtml(
|
||||
outputCapture.commitPath,
|
||||
html,
|
||||
outputCapture.mode,
|
||||
));
|
||||
|
||||
// The renderer may spend substantial time building HTML after loadDiagram
|
||||
// establishes the guard. Re-run it after staging so a last-moment alias
|
||||
// cannot redirect the commit onto an input file.
|
||||
if (outputGuard) resolveOutputPath(outputGuard);
|
||||
const candidateCapture = captureRegularFileBinding(candidatePath, {
|
||||
subject: 'candidate',
|
||||
expectedSha256: createHash('sha256').update(html).digest('hex'),
|
||||
expectedBytes: Buffer.byteLength(html),
|
||||
expectedIdentity: candidateIdentity,
|
||||
...(outputCapture.mode === null ? {} : { expectedMode: outputCapture.mode }),
|
||||
});
|
||||
if (candidateCapture.status !== 'captured') throwAtomicOutputFailure(candidateCapture, outPath);
|
||||
candidateBinding = candidateCapture.binding;
|
||||
const beforeCommit = verifyAtomicOutput(outputCapture.snapshot);
|
||||
if (beforeCommit.status !== 'match') throwAtomicOutputFailure(beforeCommit, outPath);
|
||||
const publication = publishRegularFileBinding(
|
||||
candidateBinding,
|
||||
candidatePath,
|
||||
outputCapture.snapshot,
|
||||
{ subject: 'candidate' },
|
||||
);
|
||||
if (!['committed', 'committed-with-warning'].includes(publication.status)) {
|
||||
throwAtomicOutputFailure(publication, outPath);
|
||||
}
|
||||
const releasedCandidate = releaseRegularFileBinding(candidateBinding);
|
||||
candidateBinding = undefined;
|
||||
if (releasedCandidate.status !== 'released') throwAtomicOutputFailure(releasedCandidate, outPath);
|
||||
candidatePath = undefined;
|
||||
candidateIdentity = undefined;
|
||||
} catch (error) {
|
||||
throwOutputError(error, outPath);
|
||||
} finally {
|
||||
outputPathGuards.delete(outPath);
|
||||
if (candidateBinding) releaseRegularFileBinding(candidateBinding);
|
||||
if (candidatePath && candidateIdentity) {
|
||||
const cleanup = removeOwnedRegularFile(candidatePath, candidateIdentity);
|
||||
if (!['removed', 'absent', 'preserved'].includes(cleanup.status)) {
|
||||
throwAtomicOutputFailure(cleanup, outPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(outPath);
|
||||
}
|
||||
|
||||
const SEMANTIC_COLLECTIONS = {
|
||||
architecture: 'components',
|
||||
workflow: 'nodes',
|
||||
sequence: 'participants',
|
||||
dataflow: 'nodes',
|
||||
lifecycle: 'states',
|
||||
};
|
||||
|
||||
const RELATIONSHIP_COLLECTIONS = {
|
||||
architecture: 'connections',
|
||||
workflow: 'edges',
|
||||
sequence: 'messages',
|
||||
dataflow: 'flows',
|
||||
lifecycle: 'transitions',
|
||||
};
|
||||
|
||||
// Relationship IDs are optional for backwards compatibility, but once an
|
||||
// author supplies one it becomes the durable identity used by viewer links.
|
||||
// Keep uniqueness enforcement in the shared zero-install path so every typed
|
||||
// renderer fails the same way even when development dependencies are absent.
|
||||
export function validateRelationshipIds(diagramType, diagram) {
|
||||
const collection = RELATIONSHIP_COLLECTIONS[diagramType];
|
||||
const relationships = collection && Array.isArray(diagram[collection]) ? diagram[collection] : [];
|
||||
const seen = new Set();
|
||||
const problems = [];
|
||||
|
||||
relationships.forEach((relationship, index) => {
|
||||
if (relationship.id === undefined || relationship.id === null || relationship.id === '') return;
|
||||
if (seen.has(relationship.id)) {
|
||||
problems.push(`/${collection}/${index}/id duplicates relationship id ${JSON.stringify(relationship.id)}`);
|
||||
}
|
||||
seen.add(relationship.id);
|
||||
});
|
||||
|
||||
if (problems.length) {
|
||||
throwDiagnosticProblems('Relationship identity validation failed', problems, {
|
||||
code: 'relationship/duplicate-id',
|
||||
subject: { diagramType, collection },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Share relationship-ID semantic checks between the loader
|
||||
// and workflow compiler without performing filesystem operations (see #429).
|
||||
export function validateCrossCollectionContracts(diagramType, diagram) {
|
||||
validateRelationshipIds(diagramType, diagram);
|
||||
}
|
||||
|
||||
// Accessible name for the generated diagram SVG.
|
||||
export function svgRootAttrs(meta, explicitQualityProfile) {
|
||||
const animation = meta.animation === 'trace' ? ' data-animation="trace"' : '';
|
||||
const preset = ` data-preset="${esc(meta.visual_preset || 'classic')}"`;
|
||||
const engineeringProfile = meta.engineering_profile
|
||||
? ` data-engineering-profile="${esc(meta.engineering_profile)}"`
|
||||
: '';
|
||||
const requestedProfile = explicitQualityProfile || process.env.ARCHIFY_QUALITY_PROFILE || meta.quality_profile;
|
||||
const qualityProfile = requestedProfile === 'showcase' ? 'showcase' : 'standard';
|
||||
const advisory = requestedProfile ? '' : ' data-quality-gates="advisory"';
|
||||
return `role="img" lang="${esc(resolveLocale(meta.locale))}" aria-labelledby="archify-diagram-title archify-diagram-description"${animation}${preset}${engineeringProfile} data-quality-profile="${esc(qualityProfile)}"${advisory}`;
|
||||
}
|
||||
|
||||
// Keep the accessible name inside the SVG so it survives standalone SVG
|
||||
// export and embedding. The fixed IDs are deterministic because an Archify
|
||||
// artifact intentionally contains one primary diagram SVG.
|
||||
export function svgAccessibleText(meta, kind) {
|
||||
const description = meta.subtitle || translateMessage(meta.locale, `diagram.description.${kind}`);
|
||||
return ` <title id="archify-diagram-title">${esc(meta.title)}</title>\n <desc id="archify-diagram-description">${esc(description)}</desc>`;
|
||||
}
|
||||
|
||||
export function animateAttr(meta, kind, step) {
|
||||
if (meta.animation !== 'trace') return '';
|
||||
// Ambient trace must finish inside the fixed six-second WebM capture. The
|
||||
// cap affects visual delay only; authored order and semantic identity stay
|
||||
// untouched in the JSON, DOM, and relationship contracts.
|
||||
const safeStep = Number.isFinite(step) && step >= 0 ? Math.min(12, Math.floor(step)) : 0;
|
||||
return ` data-animate="${kind}" style="--step:${safeStep}"`;
|
||||
}
|
||||
|
||||
// Stable semantic hooks for the standalone HTML explorer. IDs already pass
|
||||
// the schema's conservative identifier pattern; escape again at the markup
|
||||
// boundary so these helpers remain safe if that contract expands later.
|
||||
export function focusNodeAttrs(id, label, metadata = {}, locale) {
|
||||
const optional = [
|
||||
['data-node-kind', metadata.kind],
|
||||
['data-node-sublabel', metadata.sublabel],
|
||||
['data-node-tag', metadata.tag],
|
||||
['data-node-context', metadata.context],
|
||||
['data-node-brand', metadata.brand],
|
||||
['data-node-brand-id', metadata.brandId],
|
||||
['data-node-brand-status', metadata.brandStatus],
|
||||
['data-node-brand-source', metadata.brandSource],
|
||||
].filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '')
|
||||
.map(([name, value]) => ` ${name}="${esc(String(value))}"`)
|
||||
.join('');
|
||||
const detail = [metadata.sublabel, metadata.context, metadata.brand]
|
||||
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '')
|
||||
.join(', ');
|
||||
const aria = detail
|
||||
? translateMessage(locale, 'node.focus.detail', { label, detail })
|
||||
: translateMessage(locale, 'node.focus', { label });
|
||||
return `id="node-${esc(id)}" data-node-id="${esc(id)}" data-node-label="${esc(label)}" tabindex="0" role="button" aria-label="${esc(aria)}" aria-pressed="false"${optional}`;
|
||||
}
|
||||
|
||||
// Native SVG titles preserve a compact details-on-demand fallback when the
|
||||
// canonical SVG is embedded inline outside the full Archify viewer.
|
||||
export function focusNodeTitle(label, metadata = {}) {
|
||||
const parts = [label, metadata.sublabel, metadata.context, metadata.tag, metadata.brand]
|
||||
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '');
|
||||
return `<title>${esc(parts.join(' · '))}</title>`;
|
||||
}
|
||||
|
||||
export function focusEdgeAttrs(from, to, label, key, id) {
|
||||
const named = label ? ` data-edge-label="${esc(label)}"` : '';
|
||||
const keyed = key !== undefined && key !== null ? ` data-edge-key="${esc(String(key))}"` : '';
|
||||
const identified = id !== undefined && id !== null && String(id).trim() !== ''
|
||||
? ` data-edge-id="${esc(String(id))}"`
|
||||
: '';
|
||||
return `data-edge-from="${esc(from)}" data-edge-to="${esc(to)}"${named}${keyed}${identified}`;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
export const DESKTOP_READABILITY_VIEWPORT = Object.freeze({ width: 1440, height: 900 });
|
||||
export const DESKTOP_READER_MIN_WIDTH = 960;
|
||||
export const DESKTOP_READER_HORIZONTAL_CHROME = 30;
|
||||
export const DESKTOP_READER_DIAGRAM_WIDTH = DESKTOP_READER_MIN_WIDTH - DESKTOP_READER_HORIZONTAL_CHROME;
|
||||
export const MIN_PROJECTED_NODE_TEXT_PX = 6;
|
||||
export const DECLARED_WIDE_READER_CONTRACT = 'declared-wide-v1';
|
||||
export const DECLARED_WIDE_READER_RATIO = 1.55;
|
||||
export const DECLARED_WIDE_READER_MAX_WIDTH = 1920;
|
||||
export const DECLARED_WIDE_REFERENCE_BODY_HORIZONTAL_PX = 64;
|
||||
export const DECLARED_WIDE_REFERENCE_DIAGRAM_HORIZONTAL_PX = 30;
|
||||
|
||||
export function projectedNodeTextPx(sourceFontPx, viewBoxWidth, diagramWidth = DESKTOP_READER_DIAGRAM_WIDTH) {
|
||||
if (![sourceFontPx, viewBoxWidth, diagramWidth].every(Number.isFinite) || viewBoxWidth <= 0 || diagramWidth <= 0) {
|
||||
return Number.NaN;
|
||||
}
|
||||
return sourceFontPx * Math.min(1, diagramWidth / viewBoxWidth);
|
||||
}
|
||||
|
||||
export function minimumReadableSourceTextPx(
|
||||
viewBoxWidth,
|
||||
diagramWidth = DESKTOP_READER_DIAGRAM_WIDTH,
|
||||
minimumProjectedPx = MIN_PROJECTED_NODE_TEXT_PX,
|
||||
) {
|
||||
if (![viewBoxWidth, diagramWidth, minimumProjectedPx].every(Number.isFinite)
|
||||
|| viewBoxWidth <= 0
|
||||
|| diagramWidth <= 0
|
||||
|| minimumProjectedPx <= 0) {
|
||||
return Number.NaN;
|
||||
}
|
||||
return minimumProjectedPx / Math.min(1, diagramWidth / viewBoxWidth);
|
||||
}
|
||||
|
||||
// This is deliberately separate from the legacy 930px projection. Architecture
|
||||
// boundary convergence depends on that legacy default, while only a recognized
|
||||
// v2 wide Reader may use this declared-width proof.
|
||||
export function declaredWideReadabilityBudget({
|
||||
viewBoxWidth,
|
||||
viewBoxHeight,
|
||||
minimumSourceTextPx,
|
||||
requestedMinimumTextPx,
|
||||
viewportWidth = DESKTOP_READABILITY_VIEWPORT.width,
|
||||
bodyHorizontalPx = DECLARED_WIDE_REFERENCE_BODY_HORIZONTAL_PX,
|
||||
diagramHorizontalPx = DECLARED_WIDE_REFERENCE_DIAGRAM_HORIZONTAL_PX,
|
||||
minimumReaderWidth = DESKTOP_READER_MIN_WIDTH,
|
||||
maximumReaderWidth = DECLARED_WIDE_READER_MAX_WIDTH,
|
||||
} = {}) {
|
||||
const values = [
|
||||
viewBoxWidth, viewBoxHeight, minimumSourceTextPx, requestedMinimumTextPx,
|
||||
viewportWidth, bodyHorizontalPx, diagramHorizontalPx, minimumReaderWidth, maximumReaderWidth,
|
||||
];
|
||||
if (!values.every(Number.isFinite) || viewBoxWidth <= 0 || viewBoxHeight <= 0
|
||||
|| minimumSourceTextPx <= 0 || requestedMinimumTextPx <= 0 || viewportWidth <= 0
|
||||
|| bodyHorizontalPx < 0 || diagramHorizontalPx < 0 || minimumReaderWidth <= 0
|
||||
|| maximumReaderWidth < minimumReaderWidth || viewBoxWidth / viewBoxHeight < DECLARED_WIDE_READER_RATIO) {
|
||||
return null;
|
||||
}
|
||||
const requestedTargetPx = Math.max(MIN_PROJECTED_NODE_TEXT_PX, requestedMinimumTextPx);
|
||||
const requestedScale = Math.min(1, requestedTargetPx / minimumSourceTextPx);
|
||||
const desiredReaderWidth = Math.max(minimumReaderWidth, viewBoxWidth * requestedScale + diagramHorizontalPx);
|
||||
const viewportCap = Math.max(0, viewportWidth - bodyHorizontalPx);
|
||||
const cap = Math.min(maximumReaderWidth, viewportCap);
|
||||
const actualReaderWidth = Math.min(desiredReaderWidth, cap);
|
||||
const guaranteedSvgWidth = Math.max(0, actualReaderWidth - diagramHorizontalPx);
|
||||
const projectedMinimumTextPx = projectedNodeTextPx(minimumSourceTextPx, viewBoxWidth, guaranteedSvgWidth);
|
||||
const limit = actualReaderWidth < desiredReaderWidth
|
||||
? (viewportCap <= maximumReaderWidth ? 'viewport-cap' : 'reader-cap')
|
||||
: 'source-size';
|
||||
return {
|
||||
requestedTargetPx,
|
||||
requestedScale,
|
||||
desiredReaderWidth,
|
||||
viewportCap,
|
||||
maximumReaderWidth,
|
||||
actualReaderWidth,
|
||||
guaranteedSvgWidth,
|
||||
projectedMinimumTextPx,
|
||||
hardFloorPx: MIN_PROJECTED_NODE_TEXT_PX,
|
||||
hardFloorMet: projectedMinimumTextPx >= MIN_PROJECTED_NODE_TEXT_PX,
|
||||
requestedTargetMet: projectedMinimumTextPx >= requestedMinimumTextPx,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
// Vertical chrome that always stacks with the SVG at the 1440x900 desktop
|
||||
// viewport, measured from the delivered Viewer with the shortest one-line
|
||||
// header and no cards: body padding 12, header 39, diagram padding/border 75.
|
||||
// Cards are excluded so the prediction stays a lower bound.
|
||||
export const DESKTOP_FIXED_VERTICAL_CHROME_PX = Object.freeze({ body: 12, header: 39, diagram: 75 });
|
||||
|
||||
// A canvas the Reader can neither narrow (viewBox ratio below the wide
|
||||
// threshold) nor scroll readably (no intrinsic-height fit) renders at the full
|
||||
// reader width, so its page height is a function of the viewBox alone. Returns
|
||||
// null when the Reader has a way to fit the page; otherwise the certain
|
||||
// overflow at 1440x900 before any cards are counted.
|
||||
export function predictedFixedWidthOverflow({
|
||||
viewBoxWidth,
|
||||
viewBoxHeight,
|
||||
readerFit,
|
||||
diagramType,
|
||||
viewport = DESKTOP_READABILITY_VIEWPORT,
|
||||
bodyHorizontalPx = DECLARED_WIDE_REFERENCE_BODY_HORIZONTAL_PX,
|
||||
diagramHorizontalPx = DECLARED_WIDE_REFERENCE_DIAGRAM_HORIZONTAL_PX,
|
||||
chrome = DESKTOP_FIXED_VERTICAL_CHROME_PX,
|
||||
} = {}) {
|
||||
if (![viewBoxWidth, viewBoxHeight].every(Number.isFinite) || viewBoxWidth <= 0 || viewBoxHeight <= 0) return null;
|
||||
const ratio = viewBoxWidth / viewBoxHeight;
|
||||
if (readerFit === 'intrinsic-height'
|
||||
|| (readerFit === 'authored-height' && diagramType === 'architecture')
|
||||
|| ratio >= DECLARED_WIDE_READER_RATIO) return null;
|
||||
const svgWidthPx = viewport.width - bodyHorizontalPx - diagramHorizontalPx;
|
||||
const svgHeightPx = Math.round(svgWidthPx * viewBoxHeight / viewBoxWidth);
|
||||
const fixedChromePx = chrome.body + chrome.header + chrome.diagram;
|
||||
const pageHeightPx = svgHeightPx + fixedChromePx;
|
||||
if (pageHeightPx <= viewport.height) return null;
|
||||
return {
|
||||
viewportWidth: viewport.width,
|
||||
viewportHeight: viewport.height,
|
||||
ratio: Math.round(ratio * 100) / 100,
|
||||
wideRatio: DECLARED_WIDE_READER_RATIO,
|
||||
svgWidthPx,
|
||||
svgHeightPx,
|
||||
fixedChromePx,
|
||||
pageHeightPx,
|
||||
overflowPx: pageHeightPx - viewport.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeFixedWidthOverflow(issue) {
|
||||
const maximumViewBoxHeight = Math.floor(issue.viewBoxWidth / issue.wideRatio);
|
||||
const wideViewBoxWidth = Math.ceil(issue.viewBoxHeight * issue.wideRatio);
|
||||
return `Preserve every node, relationship, and label. This ${issue.viewBoxWidth}x${issue.viewBoxHeight} canvas (ratio ${issue.ratio}) declares no intrinsic-height fit and is below the ${issue.wideRatio} wide ratio, so the desktop Reader can neither narrow it nor accept vertical scroll: it renders ${issue.svgHeightPx}px tall at the full ${issue.svgWidthPx}px width and the page reaches ${issue.pageHeightPx}px before cards against ${issue.viewportHeight}px, a certain visual-check failure. Either compact vertical spacing so meta.viewBox height is at most ${maximumViewBoxHeight} at this width, or spread content sideways so the width is at least ${wideViewBoxWidth} at this height; for architecture, omitting meta.viewBox lets the renderer size the canvas and declare the fit.`;
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const DIAGNOSTIC_MODE = process.env.ARCHIFY_DIAGNOSTIC_FORMAT === 'json';
|
||||
const recorded = [];
|
||||
const recordedMessages = new Set();
|
||||
const boundaryKey = Symbol.for('archify.renderer-diagnostic-boundary');
|
||||
let recordingSuppressionDepth = 0;
|
||||
|
||||
function plainObject(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
||||
}
|
||||
|
||||
function normalizedDiagnostic(diagnostic) {
|
||||
const message = String(diagnostic?.message || 'Archify could not classify this failure.').trim();
|
||||
return {
|
||||
code: String(diagnostic?.code || 'internal/unclassified'),
|
||||
severity: diagnostic?.severity === 'warning' ? 'warning' : 'error',
|
||||
message,
|
||||
subject: plainObject(diagnostic?.subject),
|
||||
evidence: plainObject(diagnostic?.evidence),
|
||||
supportedFixes: Array.isArray(diagnostic?.supportedFixes)
|
||||
? [...new Set(diagnostic.supportedFixes.map((fix) => String(fix).trim()).filter(Boolean))]
|
||||
: [],
|
||||
...(Array.isArray(diagnostic?.suppresses) ? {
|
||||
suppresses: [...new Set(diagnostic.suppresses.map((code) => String(code).trim()).filter(Boolean))],
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function recordDiagnostic(diagnostic) {
|
||||
if (!DIAGNOSTIC_MODE || recordingSuppressionDepth > 0) return;
|
||||
const normalized = normalizedDiagnostic(diagnostic);
|
||||
if (recordedMessages.has(normalized.message)) return;
|
||||
recordedMessages.add(normalized.message);
|
||||
recorded.push(normalized);
|
||||
}
|
||||
|
||||
export function withDiagnosticRecordingSuppressed(callback) {
|
||||
recordingSuppressionDepth += 1;
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
recordingSuppressionDepth -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function throwDiagnosticError(message, diagnostics) {
|
||||
for (const diagnostic of diagnostics || []) recordDiagnostic(diagnostic);
|
||||
const error = new Error(message);
|
||||
error.archifyDiagnostics = (diagnostics || []).map(normalizedDiagnostic);
|
||||
throw error;
|
||||
}
|
||||
|
||||
export function throwDiagnosticProblems(prefix, problems, { code = 'layout/constraint', subject = {}, diagnostics: details = [] } = {}) {
|
||||
const messages = (problems || []).map((problem) => String(problem));
|
||||
const byMessage = new Map(details.map((entry) => [entry.message, entry]));
|
||||
const diagnostics = messages.map((message) => normalizedDiagnostic(byMessage.get(message) || {
|
||||
code,
|
||||
severity: 'error',
|
||||
message,
|
||||
subject,
|
||||
evidence: {},
|
||||
supportedFixes: [],
|
||||
}));
|
||||
throwDiagnosticError(`${prefix}:\n- ${messages.join('\n- ')}`, diagnostics);
|
||||
}
|
||||
|
||||
function fallbackDiagnostic(error) {
|
||||
const input = process.argv[2] ? path.resolve(process.argv[2]) : undefined;
|
||||
return normalizedDiagnostic({
|
||||
code: 'internal/unclassified',
|
||||
severity: 'error',
|
||||
message: error?.message || 'Renderer failed without a diagnostic.',
|
||||
subject: { input },
|
||||
evidence: { errorName: error?.name || 'Error' },
|
||||
supportedFixes: [],
|
||||
});
|
||||
}
|
||||
export function rendererFailure(error) {
|
||||
const attached = Array.isArray(error?.archifyDiagnostics)
|
||||
? error.archifyDiagnostics.map(normalizedDiagnostic)
|
||||
: [];
|
||||
// Earlier diagnostics do not classify a later, unrelated implementation error.
|
||||
const diagnostics = attached.length
|
||||
? (recorded.length ? recorded : attached)
|
||||
: [fallbackDiagnostic(error)];
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
ok: false,
|
||||
source: 'renderer',
|
||||
error: error?.message || 'Renderer failed without a diagnostic.',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
// Match the public CLI's text format without making its standalone doctor
|
||||
// bootstrap depend on this renderer runtime being present.
|
||||
function formatDiagnostics(error, diagnostics = []) {
|
||||
if (!diagnostics.length) return error;
|
||||
return [
|
||||
error,
|
||||
...diagnostics.map((entry) => {
|
||||
const fix = entry.supportedFixes?.length ? ` Fix: ${entry.supportedFixes.join('; ')}.` : '';
|
||||
return `[${entry.code}] ${entry.message}${fix}`;
|
||||
}),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const readerSignal = new Int32Array(new SharedArrayBuffer(4));
|
||||
|
||||
function waitForReader() {
|
||||
// Sleep instead of spinning on EAGAIN. A retry budget looks like a safeguard
|
||||
// and behaves like a truncation gate: a spinning loop burns thousands of
|
||||
// attempts in a few milliseconds, so a reader that is merely slow to start
|
||||
// exhausts it and loses the tail of the receipt. Waiting costs nothing while
|
||||
// the reader catches up, and a reader that goes away raises EPIPE, which the
|
||||
// caller already treats as a real write failure.
|
||||
Atomics.wait(readerSignal, 0, 0, 1);
|
||||
}
|
||||
|
||||
export function installRendererDiagnosticBoundary() {
|
||||
if (globalThis[boundaryKey]) return;
|
||||
globalThis[boundaryKey] = true;
|
||||
if (!DIAGNOSTIC_MODE) {
|
||||
process.once('uncaughtException', (error) => {
|
||||
// Only errors classified at their operation boundary are author-facing.
|
||||
// Preserve Node's debugging information for unexpected implementation errors.
|
||||
if (!error?.archifyDiagnostics?.length) {
|
||||
// The once-listener is already removed. Rethrow outside the exception
|
||||
// handler so Node retains its normal stack and exit code (not code 7).
|
||||
process.nextTick(() => { throw error; });
|
||||
return;
|
||||
}
|
||||
const payload = `${formatDiagnostics(error.message, error.archifyDiagnostics)}\n`;
|
||||
process.stderr.once('error', () => process.exit(1));
|
||||
process.stderr.write(payload, () => process.exit(1));
|
||||
});
|
||||
return;
|
||||
}
|
||||
process.on('uncaughtException', (error) => {
|
||||
const payload = `${JSON.stringify(rendererFailure(error))}\n`;
|
||||
try {
|
||||
// stderr may be a pipe. fs.writeSync performs a PARTIAL write once the
|
||||
// payload exceeds the OS pipe buffer (8KB on macOS) and returns the byte
|
||||
// count actually written. Ignoring that return value silently truncated
|
||||
// large diagnostic payloads mid-JSON, so the parent CLI's JSON.parse
|
||||
// failed and the fail-closed boundary reported internal/unclassified
|
||||
// instead of the diagnostics we had already computed. Loop until drained.
|
||||
// A full pipe also makes writeSync throw EAGAIN; wait for the reader
|
||||
// rather than treat it as a stream failure, otherwise the tail is
|
||||
// dropped just the same.
|
||||
const buffer = Buffer.from(payload, 'utf8');
|
||||
let written = 0;
|
||||
while (written < buffer.length) {
|
||||
try {
|
||||
written += fs.writeSync(process.stderr.fd, buffer, written, buffer.length - written);
|
||||
} catch (writeError) {
|
||||
if (writeError?.code === 'EAGAIN') {
|
||||
waitForReader();
|
||||
continue;
|
||||
}
|
||||
throw writeError;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// The renderer is already failing. Avoid replacing its real error with a
|
||||
// secondary stream failure; the parent CLI still has the exit status.
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { throwDiagnosticError } from './diagnostics.mjs';
|
||||
|
||||
const DEPLOYMENT_PROFILE = 'deployment-ownership';
|
||||
const DEPLOYMENT_BOUNDARY_KINDS = new Set(['region', 'security-group']);
|
||||
const PRIVATE_STATE_TYPES = new Set(['database']);
|
||||
|
||||
function subject(collection, index, item = {}) {
|
||||
return {
|
||||
diagramType: 'architecture',
|
||||
profile: DEPLOYMENT_PROFILE,
|
||||
collection,
|
||||
index,
|
||||
...(item.id ? { id: item.id } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function membership(boundaries, componentId, kind) {
|
||||
return boundaries
|
||||
.map((boundary, index) => ({ boundary, index }))
|
||||
.filter(({ boundary }) => boundary.kind === kind && boundary.wraps.includes(componentId));
|
||||
}
|
||||
|
||||
export function deploymentOwnershipDiagnostics(diagram) {
|
||||
const components = Array.isArray(diagram.components) ? diagram.components : [];
|
||||
const boundaries = (Array.isArray(diagram.boundaries) ? diagram.boundaries : [])
|
||||
.map((boundary) => ({ ...boundary, wraps: Array.isArray(boundary.wraps) ? boundary.wraps : [] }));
|
||||
const connections = Array.isArray(diagram.connections) ? diagram.connections : [];
|
||||
const diagnostics = [];
|
||||
|
||||
for (const kind of DEPLOYMENT_BOUNDARY_KINDS) {
|
||||
const count = boundaries.filter((boundary) => boundary.kind === kind).length;
|
||||
if (count > 0) continue;
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-boundary-kind',
|
||||
severity: 'error',
|
||||
message: `Deployment ownership requires at least one ${kind} boundary.`,
|
||||
subject: subject('boundaries', -1),
|
||||
evidence: { requiredKind: kind, found: count },
|
||||
supportedFixes: [`add one ${kind} boundary with an explicit wraps list`],
|
||||
});
|
||||
}
|
||||
|
||||
components.forEach((component, index) => {
|
||||
if (component.type === 'external') return;
|
||||
if (typeof component.tag !== 'string' || component.tag.trim() === '') {
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-owner-missing',
|
||||
severity: 'error',
|
||||
message: `Deployment component ${JSON.stringify(component.id)} does not name its owner in tag.`,
|
||||
subject: subject('components', index, component),
|
||||
evidence: { componentType: component.type, ownerField: 'tag' },
|
||||
supportedFixes: [`set /components/${index}/tag to the responsible team or owner`],
|
||||
});
|
||||
}
|
||||
|
||||
const regions = membership(boundaries, component.id, 'region');
|
||||
if (regions.length === 0) {
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-region-scope',
|
||||
severity: 'error',
|
||||
message: `Deployment component ${JSON.stringify(component.id)} is not assigned to a region boundary.`,
|
||||
subject: subject('components', index, component),
|
||||
evidence: { componentType: component.type, regionMemberships: 0 },
|
||||
supportedFixes: ['add the component id to the real region boundary wraps list'],
|
||||
});
|
||||
} else if (regions.length > 1) {
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-region-ambiguous',
|
||||
severity: 'error',
|
||||
message: `Deployment component ${JSON.stringify(component.id)} belongs to more than one region boundary.`,
|
||||
subject: subject('components', index, component),
|
||||
evidence: {
|
||||
componentType: component.type,
|
||||
regions: regions.map(({ boundary, index: boundaryIndex }) => ({ boundaryIndex, label: boundary.label })),
|
||||
},
|
||||
supportedFixes: ['keep the component id in exactly one real region boundary wraps list'],
|
||||
});
|
||||
}
|
||||
|
||||
if (PRIVATE_STATE_TYPES.has(component.type)) {
|
||||
const privateScopes = membership(boundaries, component.id, 'security-group');
|
||||
if (privateScopes.length === 0) {
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-private-state',
|
||||
severity: 'error',
|
||||
message: `Stateful component ${JSON.stringify(component.id)} is not assigned to a private security-group boundary.`,
|
||||
subject: subject('components', index, component),
|
||||
evidence: { componentType: component.type, privateMemberships: 0 },
|
||||
supportedFixes: ['add the component id to the real private security-group boundary wraps list'],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
boundaries.forEach((boundary, index) => {
|
||||
if (boundary.kind !== 'security-group') return;
|
||||
const members = boundary.wraps.map((id) => ({
|
||||
id,
|
||||
regions: membership(boundaries, id, 'region').map(({ boundary: region, index: boundaryIndex }) => ({
|
||||
boundaryIndex,
|
||||
label: region.label,
|
||||
})),
|
||||
}));
|
||||
const regionIndexes = new Set(members.flatMap((member) => member.regions.map((region) => region.boundaryIndex)));
|
||||
const consistent = members.length > 0
|
||||
&& members.every((member) => member.regions.length === 1)
|
||||
&& regionIndexes.size === 1;
|
||||
if (consistent) return;
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-private-region-consistency',
|
||||
severity: 'error',
|
||||
message: `Private boundary ${JSON.stringify(boundary.label)} must contain components from exactly one shared region.`,
|
||||
subject: subject('boundaries', index, boundary),
|
||||
evidence: { boundaryKind: boundary.kind, members },
|
||||
supportedFixes: ['assign every private-boundary component to exactly one shared region boundary'],
|
||||
});
|
||||
});
|
||||
|
||||
connections.forEach((connection, index) => {
|
||||
const crossedBoundaries = boundaries
|
||||
.map((boundary, boundaryIndex) => ({
|
||||
boundaryIndex,
|
||||
kind: boundary.kind,
|
||||
label: boundary.label,
|
||||
fromInside: boundary.wraps.includes(connection.from),
|
||||
toInside: boundary.wraps.includes(connection.to),
|
||||
}))
|
||||
.filter((boundary) => DEPLOYMENT_BOUNDARY_KINDS.has(boundary.kind) && boundary.fromInside !== boundary.toInside);
|
||||
if (crossedBoundaries.length === 0 || (typeof connection.label === 'string' && connection.label.trim() !== '')) return;
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-crossing-mechanism',
|
||||
severity: 'error',
|
||||
message: `Cross-boundary connection ${JSON.stringify(connection.id || `${connection.from}->${connection.to}`)} does not name its mechanism.`,
|
||||
subject: subject('connections', index, connection),
|
||||
evidence: {
|
||||
from: connection.from,
|
||||
to: connection.to,
|
||||
crossedBoundaries: crossedBoundaries.map(({ boundaryIndex, kind, label }) => ({ boundaryIndex, kind, label })),
|
||||
},
|
||||
supportedFixes: [`set /connections/${index}/label to the real cross-boundary mechanism`],
|
||||
});
|
||||
});
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export function validateEngineeringProfile(diagramType, diagram) {
|
||||
const profile = diagram.meta?.engineering_profile;
|
||||
if (!profile) return;
|
||||
if (diagramType !== 'architecture' || profile !== DEPLOYMENT_PROFILE) return;
|
||||
const diagnostics = deploymentOwnershipDiagnostics(diagram);
|
||||
if (!diagnostics.length) return;
|
||||
throwDiagnosticError(
|
||||
`Engineering profile ${JSON.stringify(profile)} failed:\n${diagnostics.map((entry) => `- ${entry.message}`).join('\n')}`,
|
||||
diagnostics,
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1941
File diff suppressed because it is too large
Load Diff
+603
@@ -0,0 +1,603 @@
|
||||
export const SUPPORTED_LOCALES = ['en', 'zh-CN'];
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
|
||||
const ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (character) => ESCAPE_MAP[character]);
|
||||
}
|
||||
|
||||
// One catalog feeds renderer-time SVG/HTML copy and the selected runtime
|
||||
// catalog embedded in each standalone artifact. Keeping both built-in locales in one
|
||||
// tuple makes missing translations impossible to hide behind an English
|
||||
// fallback during development.
|
||||
const MESSAGE_PAIRS = {
|
||||
'page.title': ['{title} Diagram', '{title}'],
|
||||
'diagram.description.architecture': ['An architecture diagram generated by Archify.', '由 Archify 生成的架构图。'],
|
||||
'diagram.description.workflow': ['A workflow diagram generated by Archify.', '由 Archify 生成的工作流图。'],
|
||||
'diagram.description.sequence': ['A sequence diagram generated by Archify.', '由 Archify 生成的时序图。'],
|
||||
'diagram.description.dataflow': ['A data-flow diagram generated by Archify.', '由 Archify 生成的数据流图。'],
|
||||
'diagram.description.lifecycle': ['A lifecycle diagram generated by Archify.', '由 Archify 生成的生命周期图。'],
|
||||
'node.focus': ['Focus {label}', '聚焦{label}'],
|
||||
'node.focus.detail': ['Focus {label}, {detail}', '聚焦{label},{detail}'],
|
||||
'node.context.architecture': ['Architecture component', '架构组件'],
|
||||
'node.context.workflow': ['Workflow node', '工作流节点'],
|
||||
'node.context.sequence': ['Sequence participant', '时序参与者'],
|
||||
'node.context.dataflow': ['Data-flow node', '数据流节点'],
|
||||
'node.context.lifecycle': ['Lifecycle state', '生命周期状态'],
|
||||
'legend.title': ['Legend', '图例'],
|
||||
|
||||
'legend.architecture.frontend': ['Frontend', '前端'],
|
||||
'legend.architecture.backend': ['Backend', '后端'],
|
||||
'legend.architecture.database': ['Database', '数据库'],
|
||||
'legend.architecture.cloud': ['Cloud', '云服务'],
|
||||
'legend.architecture.security': ['Security', '安全'],
|
||||
'legend.architecture.messagebus': ['Message bus', '消息总线'],
|
||||
'legend.architecture.external': ['External', '外部系统'],
|
||||
'legend.workflow.frontend': ['User UI', '用户界面'],
|
||||
'legend.workflow.backend': ['Agent logic', 'Agent 逻辑'],
|
||||
'legend.workflow.security': ['Policy', '策略'],
|
||||
'legend.workflow.messagebus': ['Tool action', '工具操作'],
|
||||
'legend.workflow.database': ['Context / trace', '上下文 / 追踪'],
|
||||
'legend.workflow.cloud': ['Cloud service', '云服务'],
|
||||
'legend.workflow.external': ['External system', '外部系统'],
|
||||
'legend.sequence.emphasis': ['main request', '主路径请求'],
|
||||
'legend.sequence.return': ['return', '返回'],
|
||||
'legend.sequence.security': ['security', '安全'],
|
||||
'legend.sequence.dashed': ['async trace', '异步追踪'],
|
||||
'legend.sequence.default': ['message', '普通消息'],
|
||||
'legend.dataflow.emphasis': ['primary data', '主要数据'],
|
||||
'legend.dataflow.security': ['policy / PII', '策略 / PII'],
|
||||
'legend.dataflow.dashed': ['async batch', '异步批处理'],
|
||||
'legend.dataflow.database': ['data store', '数据存储'],
|
||||
'legend.dataflow.default': ['data flow', '数据流'],
|
||||
'legend.lifecycle.start': ['initial state', '起点'],
|
||||
'legend.lifecycle.active': ['active', '进行中'],
|
||||
'legend.lifecycle.waiting': ['waiting', '等待'],
|
||||
'legend.lifecycle.decision': ['decision', '决策'],
|
||||
'legend.lifecycle.success': ['success', '成功'],
|
||||
'legend.lifecycle.failure': ['failure / exit', '失败 / 退出'],
|
||||
'legend.lifecycle.neutral': ['neutral', '中性状态'],
|
||||
'legend.lifecycle.external': ['external', '外部状态'],
|
||||
'legend.lifecycle.final': ['final state', '终态'],
|
||||
|
||||
'viewer.kind.frontend': ['Frontend', '前端'],
|
||||
'viewer.kind.backend': ['Backend', '后端'],
|
||||
'viewer.kind.database': ['Database', '数据库'],
|
||||
'viewer.kind.cloud': ['Cloud', '云服务'],
|
||||
'viewer.kind.security': ['Security', '安全'],
|
||||
'viewer.kind.messagebus': ['Message bus', '消息总线'],
|
||||
'viewer.kind.external': ['External', '外部系统'],
|
||||
'viewer.kind.neutral': ['Neutral', '中性'],
|
||||
'viewer.kind.node': ['Node', '节点'],
|
||||
'viewer.kind.start': ['Start', '开始'],
|
||||
'viewer.kind.active': ['Active', '活动'],
|
||||
'viewer.kind.waiting': ['Waiting', '等待'],
|
||||
'viewer.kind.decision': ['Decision', '决策'],
|
||||
'viewer.kind.success': ['Success', '成功'],
|
||||
'viewer.kind.failure': ['Failure', '失败'],
|
||||
|
||||
'viewer.toolbar.actions': ['Diagram actions', '图表操作'],
|
||||
'viewer.theme.toggle.title': ['Toggle theme (T)', '切换主题(T)'],
|
||||
'viewer.theme.toggle': ['Toggle color theme', '切换颜色主题'],
|
||||
'viewer.theme.dark': ['Dark', '深色'],
|
||||
'viewer.theme.light': ['Light', '浅色'],
|
||||
'viewer.preset.choose.title': ['Choose visual style (S cycles)', '选择视觉风格(S 循环切换)'],
|
||||
'viewer.preset.choose': ['Choose visual style', '选择视觉风格'],
|
||||
'viewer.preset.style': ['Style', '风格'],
|
||||
'viewer.preset.menu': ['Visual style', '视觉风格'],
|
||||
'viewer.preset.identity': ['Visual identity', '视觉表达'],
|
||||
'viewer.preset.cycles': ['to cycle', '循环切换'],
|
||||
'viewer.preset.classic': ['Classic', '经典'],
|
||||
'viewer.preset.classic.short': ['Classic', '经典'],
|
||||
'viewer.preset.classic.hint': ['Stable technical default', '稳定的技术默认风格'],
|
||||
'viewer.preset.flow': ['Signal Flow', '信号流'],
|
||||
'viewer.preset.flow.short': ['Flow', '流动'],
|
||||
'viewer.preset.flow.hint': ['Motion-forward presentation', '突出动态流向'],
|
||||
'viewer.preset.blueprint': ['Blueprint', '蓝图'],
|
||||
'viewer.preset.blueprint.hint': ['Engineering review', '工程评审'],
|
||||
'viewer.preset.editorial': ['Editorial', '编辑风格'],
|
||||
'viewer.preset.editorial.hint': ['Publication and launch notes', '适合发布与上线说明'],
|
||||
'viewer.preset.badge.signalFlow': ['SIGNAL FLOW', '信号流'],
|
||||
'viewer.preset.badge.blueprint': ['BLUEPRINT / REV 01', '蓝图 / 修订 01'],
|
||||
'viewer.preset.badge.editorial': ['EDITORIAL / FIELD NOTE', '编辑风格 / 现场笔记'],
|
||||
'viewer.preset.badge.editorialPlate': ['ARCHIFY / PLATE 04', 'ARCHIFY / 图版 04'],
|
||||
'viewer.preset.current': ['Visual style: {style}. Choose visual style', '当前视觉风格:{style}。选择视觉风格'],
|
||||
'viewer.motion.live': ['Live', '动态'],
|
||||
'viewer.motion.still': ['Still', '静态'],
|
||||
'viewer.motion.pause': ['Pause motion', '暂停动效'],
|
||||
'viewer.motion.resume': ['Resume motion', '恢复动效'],
|
||||
'viewer.motion.reduced': ['Motion paused by reduced-motion preference', '已根据减少动态效果偏好暂停动效'],
|
||||
'viewer.motion.hidden': ['Motion paused while this page is hidden', '页面不可见时已暂停动效'],
|
||||
'viewer.motion.yielding': ['Pause motion; currently yielding to {owner}', '暂停动效;当前让位于{owner}'],
|
||||
'viewer.motion.yielding.title': ['Live preview enabled · yielding to {owner}', '动态预览已启用 · 正在让位于{owner}'],
|
||||
'viewer.owner.route': ['Route Probe', '路径探测'],
|
||||
'viewer.owner.lens': ['Semantic Lens', '语义透镜'],
|
||||
'viewer.owner.relationship': ['Relationship Preview', '关系预览'],
|
||||
'viewer.owner.intent': ['Intent Trace', '意图追踪'],
|
||||
'viewer.owner.focus': ['semantic focus', '语义聚焦'],
|
||||
'viewer.owner.legend': ['legend preview', '图例预览'],
|
||||
'viewer.owner.reader': ['reader interaction', '读者交互'],
|
||||
'viewer.present.enter': ['Enter presentation stage', '进入演示模式'],
|
||||
'viewer.present.enter.title': ['Presentation stage (F)', '演示模式(F)'],
|
||||
'viewer.present.exit': ['Exit presentation stage', '退出演示模式'],
|
||||
'viewer.present.exit.title': ['Exit presentation stage (F or Escape)', '退出演示模式(F 或 Escape)'],
|
||||
'viewer.present.present': ['Present', '演示'],
|
||||
'viewer.present.exit.label': ['Exit', '退出'],
|
||||
|
||||
'viewer.export.button': ['Export', '导出'],
|
||||
'viewer.export.button.title': ['Export diagram (E)', '导出图表(E)'],
|
||||
'viewer.export.diagram': ['Export diagram', '导出图表'],
|
||||
'viewer.export.menu': ['Export', '导出'],
|
||||
'viewer.export.subtitle': ['Portable, clean outputs', '便携、整洁的输出'],
|
||||
'viewer.export.share': ['Share', '分享'],
|
||||
'viewer.export.shareCard': ['Share Card', '分享卡片'],
|
||||
'viewer.export.routeShareCard': ['Route Share Card', '路径分享卡片'],
|
||||
'viewer.export.reachShareCard': ['Reach Share Card', '可达范围分享卡片'],
|
||||
'viewer.export.copyDiagram': ['Copy diagram', '复制图表'],
|
||||
'viewer.export.clipboardPng': ['PNG to clipboard', '复制 PNG 到剪贴板'],
|
||||
'viewer.export.raster': ['Raster images', '位图'],
|
||||
'viewer.export.image': ['Image', '图像'],
|
||||
'viewer.export.lossless': ['Lossless image', '无损图像'],
|
||||
'viewer.export.compact': ['Compact image', '紧凑图像'],
|
||||
'viewer.export.modern': ['Modern image', '现代图像格式'],
|
||||
'viewer.export.vectorMotion': ['Vector and motion', '矢量与动效'],
|
||||
'viewer.export.vectorMotion.heading': ['Vector & motion', '矢量与动效'],
|
||||
'viewer.export.editable': ['Editable vector', '可编辑矢量图'],
|
||||
'viewer.export.svg.auto': ['SVG · Auto', 'SVG · 自动'],
|
||||
'viewer.export.svg.auto.hint': ['Matches host theme', '匹配宿主主题'],
|
||||
'viewer.export.svg.light': ['SVG · Light', 'SVG · 浅色'],
|
||||
'viewer.export.svg.light.hint': ['Always light', '始终浅色'],
|
||||
'viewer.export.svg.dark': ['SVG · Dark', 'SVG · 深色'],
|
||||
'viewer.export.svg.dark.hint': ['Always dark', '始终深色'],
|
||||
'viewer.export.motion6s': ['6s motion', '6 秒动效'],
|
||||
'viewer.export.unsupported': ['Not supported by this browser', '当前浏览器不支持'],
|
||||
'viewer.export.clipboardUnsupported': ['Clipboard image write not supported by this browser', '当前浏览器不支持写入图片剪贴板'],
|
||||
'viewer.export.clipboardUnsupported.short': ['Clipboard image write not supported in this browser.', '此浏览器不支持写入图片剪贴板。'],
|
||||
'viewer.export.motionUnavailable': ['Motion capture unavailable in this browser', '当前浏览器无法录制动效'],
|
||||
'viewer.export.webmUnavailable': ['WebM unavailable in this browser', '当前浏览器不支持 WebM'],
|
||||
'viewer.export.failed': ['Export failed: {message}', '导出失败:{message}'],
|
||||
'viewer.export.unknownVariant': ['Unknown Share Card variant: {variant}', '未知的分享卡片类型:{variant}'],
|
||||
'viewer.export.routeRequired': ['Trace a route before exporting a Route Share Card', '请先追踪路径,再导出路径分享卡片'],
|
||||
'viewer.export.reachRequired': ['Trace authored reach before exporting a Reach Share Card', '请先追踪编写可达范围,再导出可达范围分享卡片'],
|
||||
'viewer.export.unknown': ['unknown', '未知错误'],
|
||||
'viewer.export.routeFailed': ['Route Share Card export failed: {message}', '路径分享卡片导出失败:{message}'],
|
||||
'viewer.export.reachFailed': ['Reach Share Card export failed: {message}', '可达范围分享卡片导出失败:{message}'],
|
||||
'viewer.export.copyFailed': ['Copy failed: {message}', '复制失败:{message}'],
|
||||
'viewer.export.copiedPng': ['Copied PNG to clipboard', '已将 PNG 复制到剪贴板'],
|
||||
'viewer.export.downloadedRoute': ['Downloaded Route Share Card', '已下载路径分享卡片'],
|
||||
'viewer.export.downloadedReach': ['Downloaded Reach Share Card', '已下载可达范围分享卡片'],
|
||||
'viewer.export.downloadedWebm': ['Downloaded WebM', '已下载 WebM'],
|
||||
'viewer.export.recording': ['Recording 6 seconds of motion…', '正在录制 6 秒动效…'],
|
||||
'viewer.export.card.routeSummary.one': ['Path: {source} → {target} · {count} step', '路径:{source} → {target} · {count} 步'],
|
||||
'viewer.export.card.routeSummary.other': ['Path: {source} → {target} · {count} steps', '路径:{source} → {target} · {count} 步'],
|
||||
'viewer.export.card.reachSummary': ['Authored {direction} from {origin} · {nodes} · {links} · max {hops}', '从{origin}开始的编写{direction} · {nodes} · {links} · 最深 {hops}'],
|
||||
'viewer.export.card.node.one': ['{count} node', '{count} 个节点'],
|
||||
'viewer.export.card.node.other': ['{count} nodes', '{count} 个节点'],
|
||||
'viewer.export.card.link.one': ['{count} link', '{count} 条连接'],
|
||||
'viewer.export.card.link.other': ['{count} links', '{count} 条连接'],
|
||||
'viewer.export.card.hop.one': ['{count} hop', '{count} 跳'],
|
||||
'viewer.export.card.hop.other': ['{count} hops', '{count} 跳'],
|
||||
'viewer.export.card.routeBadge': ['ARCHIFY · ROUTE · {hops}', 'ARCHIFY · 路径 · {hops}'],
|
||||
'viewer.export.card.reachBadge': ['ARCHIFY · {direction} REACH', 'ARCHIFY · {direction}可达范围'],
|
||||
'viewer.export.direction.upstream': ['Upstream', '上游'],
|
||||
'viewer.export.direction.downstream': ['Downstream', '下游'],
|
||||
'viewer.export.error.canvasUnavailable': ['Canvas unavailable for {label}', '无法为{label}使用画布'],
|
||||
'viewer.export.error.contextUnavailable': ['2D canvas context unavailable for {label}', '无法为{label}创建二维画布上下文'],
|
||||
'viewer.export.error.toBlobUnavailable': ['canvas.toBlob unavailable for {label}', '{label}无法使用 canvas.toBlob'],
|
||||
'viewer.export.error.toBlobNull': ['canvas.toBlob returned no data for {label}', '{label}的 canvas.toBlob 未返回数据'],
|
||||
'viewer.export.error.variantsCombined': ['Share Card variants cannot be combined', '无法同时组合多种分享卡片类型'],
|
||||
'viewer.export.error.viewerState': ['Share Card export could not remove temporary viewer state', '分享卡片导出无法移除临时 Viewer 状态'],
|
||||
'viewer.export.error.routeState': ['Route Card export could not preserve the resolved route safely', '路径卡片导出无法安全保留已解析路径'],
|
||||
'viewer.export.error.reachState': ['Reach Card export could not preserve authored reach safely', '可达范围卡片导出无法安全保留编写的可达范围'],
|
||||
'viewer.export.error.webmRequirements': ['WebM motion export requires a trace animation and browser MediaRecorder support', 'WebM 动效导出需要追踪动画及浏览器 MediaRecorder 支持'],
|
||||
'viewer.export.error.mediaRecorder': ['MediaRecorder failed', 'MediaRecorder 录制失败'],
|
||||
'viewer.export.error.emptyWebm': ['MediaRecorder produced an empty WebM', 'MediaRecorder 生成了空的 WebM'],
|
||||
'viewer.export.error.webmBackground': ['SVG background could not be loaded for WebM export', '无法为 WebM 导出加载 SVG 背景'],
|
||||
|
||||
'viewer.guide.title': ['Explore this system', '探索此系统'],
|
||||
'viewer.focus.selectedNodes': ['{count} selected nodes', '已选择 {count} 个节点'],
|
||||
|
||||
'viewer.guide.eyebrow': ['Diagram guide', '图表指南'],
|
||||
'viewer.guide.close': ['Close diagram guide', '关闭图表指南'],
|
||||
'viewer.guide.inspecting': ['Inspecting compiled semantics', '正在检查已编译语义'],
|
||||
'viewer.guide.actions': ['Diagram exploration actions', '图表探索操作'],
|
||||
'viewer.guide.find': ['Find any node', '查找任意节点'],
|
||||
'viewer.guide.find.hint': ['Search labels, responsibilities, kinds, and stable IDs.', '搜索标签、职责、类型和稳定 ID。'],
|
||||
'viewer.guide.route': ['Trace a route', '追踪路径'],
|
||||
'viewer.guide.route.aria': ['Trace a directed route', '追踪有向路径'],
|
||||
'viewer.guide.route.hint': ['Ask how two semantic nodes connect in authored direction.', '查看两个语义节点如何按编写方向连接。'],
|
||||
'viewer.guide.map': ['See the whole system', '查看完整系统'],
|
||||
'viewer.guide.map.hint': ['Open Semantic Radar with a live viewport and stable nodes.', '打开带实时视口和稳定节点的语义雷达。'],
|
||||
'viewer.guide.lens': ['Compare semantic kinds', '比较语义类型'],
|
||||
'viewer.guide.lens.hint': ['Count roles, reveal their traffic, and compare direct authored links.', '统计角色、显示流量并比较直接编写的连接。'],
|
||||
'viewer.guide.present': ['Enter Presentation Stage', '进入演示模式'],
|
||||
'viewer.guide.present.hint': ['Give the live diagram the viewport without changing export.', '让实时图表占满视口,同时不改变导出。'],
|
||||
'viewer.guide.shortcuts': ['Additional keyboard shortcuts', '其他键盘快捷键'],
|
||||
'viewer.guide.shortcut.export': ['Export', '导出'],
|
||||
'viewer.guide.shortcut.theme': ['Theme', '主题'],
|
||||
'viewer.guide.shortcut.style': ['Style', '风格'],
|
||||
'viewer.guide.shortcut.reset': ['Reset', '重置'],
|
||||
'viewer.guide.shortcut.zoomIn': ['Zoom in', '放大'],
|
||||
'viewer.guide.shortcut.zoomOut': ['Zoom out', '缩小'],
|
||||
'viewer.guide.shortcut.close': ['Close', '关闭'],
|
||||
'viewer.guide.facts': ['{nodes} · {relationships}', '{nodes} · {relationships}'],
|
||||
'viewer.guide.fact.node.one': ['{count} semantic node', '{count} 个语义节点'],
|
||||
'viewer.guide.fact.node.other': ['{count} semantic nodes', '{count} 个语义节点'],
|
||||
'viewer.guide.fact.relationship.one': ['{count} relationship', '{count} 条关系'],
|
||||
'viewer.guide.fact.relationship.other': ['{count} relationships', '{count} 条关系'],
|
||||
'viewer.guide.open': ['Open diagram guide', '打开图表指南'],
|
||||
|
||||
'viewer.finder.title': ['Find a node', '查找节点'],
|
||||
'viewer.finder.close': ['Close node finder', '关闭节点查找器'],
|
||||
'viewer.finder.placeholder': ['Search labels or IDs', '搜索标签或 ID'],
|
||||
'viewer.finder.search': ['Search diagram nodes', '搜索图表节点'],
|
||||
'viewer.finder.results': ['Diagram nodes', '图表节点'],
|
||||
'viewer.finder.empty': ['No matching nodes', '没有匹配的节点'],
|
||||
'viewer.finder.result.focus': ['Focus {label}', '聚焦{label}'],
|
||||
'viewer.finder.result.routeStart': ['Choose {label} as route start', '选择{label}作为路径起点'],
|
||||
'viewer.finder.result.routeTarget': ['Choose {label} as route destination, {links}', '选择{label}作为路径终点,{links}'],
|
||||
'viewer.finder.status.empty': ['No matching nodes', '没有匹配的节点'],
|
||||
'viewer.finder.status.count.one': ['{count} matching node', '{count} 个匹配节点'],
|
||||
'viewer.finder.status.count.other': ['{count} matching nodes', '{count} 个匹配节点'],
|
||||
'viewer.finder.noun.nodes': ['nodes', '个节点'],
|
||||
'viewer.finder.link.one': ['{count} link', '{count} 条连接'],
|
||||
'viewer.finder.link.other': ['{count} links', '{count} 条连接'],
|
||||
'viewer.finder.result.focus.one': ['Focus {label}, {count} related connection', '聚焦{label},{count} 条相关连接'],
|
||||
'viewer.finder.result.focus.other': ['Focus {label}, {count} related connections', '聚焦{label},{count} 条相关连接'],
|
||||
'viewer.finder.status.filtered': ['{visible} of {available} {noun}', '{visible}/{available} {noun}'],
|
||||
'viewer.finder.status.all': ['{available} {noun}', '{available} {noun}'],
|
||||
|
||||
'viewer.passport.eyebrow': ['Semantic passport', '语义护照'],
|
||||
'viewer.passport.metadata': ['Node metadata', '节点元数据'],
|
||||
'viewer.passport.evidence': ['Verified source evidence', '已验证的源代码证据'],
|
||||
'viewer.passport.verified': ['Verified source', '已验证来源'],
|
||||
'viewer.passport.verificationScope': ['Verified against local Git at the pinned revision. Remote access has not been checked.', '已按固定修订版本验证本地 Git 证据,未检查远程访问权限。'],
|
||||
'viewer.passport.reach': ['Authored reach', '编写可达范围'],
|
||||
'viewer.passport.reach.trace': ['Trace authored reachability', '追踪编写的可达性'],
|
||||
'viewer.passport.upstream': ['Upstream', '上游'],
|
||||
'viewer.passport.downstream': ['Downstream', '下游'],
|
||||
'viewer.passport.upstream.trace': ['Trace upstream authored reachability', '追踪上游编写可达性'],
|
||||
'viewer.passport.downstream.trace': ['Trace downstream authored reachability', '追踪下游编写可达性'],
|
||||
'viewer.passport.close': ['Close semantic passport', '关闭语义护照'],
|
||||
'viewer.passport.move': ['Move semantic passport. Drag, use arrow keys, or press Home to reset.', '移动语义护照。可拖动、使用方向键移动,或按 Home 恢复自动位置。'],
|
||||
'viewer.passport.copy': ['Copy link', '复制链接'],
|
||||
'viewer.passport.copy.focus': ['Copy link to focused node', '复制聚焦节点的链接'],
|
||||
'viewer.passport.relations': ['Relations', '关系'],
|
||||
'viewer.passport.relations.show': ['Show connected relationships', '显示关联关系'],
|
||||
'viewer.passport.relations.hide': ['Hide connected relationships', '隐藏关联关系'],
|
||||
'viewer.passport.relations.list': ['Connected relationships', '关联关系'],
|
||||
'viewer.passport.copyRelation': ['Copy relation', '复制关系'],
|
||||
'viewer.passport.copyNode': ['Copy node', '复制节点'],
|
||||
'viewer.passport.copyPinned': ['Copy link to pinned relationship', '复制固定关系的链接'],
|
||||
'viewer.passport.copySource': ['Copy link to source node', '复制来源节点的链接'],
|
||||
'viewer.passport.copy.focused.success': ['Focused node link copied', '已复制聚焦节点链接'],
|
||||
'viewer.passport.copy.pinned.success': ['Pinned relationship link copied', '已复制固定关系链接'],
|
||||
'viewer.passport.copy.focused.failed': ['Could not copy focused node link', '无法复制聚焦节点链接'],
|
||||
'viewer.passport.copy.pinned.failed': ['Could not copy pinned relationship link', '无法复制固定关系链接'],
|
||||
'viewer.passport.relationship.none': ['No connected relationships', '没有关联关系'],
|
||||
'viewer.passport.relationship.count.one': ['{count} relation', '{count} 条关系'],
|
||||
'viewer.passport.relationship.count.other': ['{count} relations', '{count} 条关系'],
|
||||
'viewer.passport.relationship.show.one': ['Show {count} connected relationship', '显示 {count} 条关联关系'],
|
||||
'viewer.passport.relationship.show.other': ['Show {count} connected relationships', '显示 {count} 条关联关系'],
|
||||
'viewer.passport.relationship.summary': ['{out} outgoing · {in} incoming{loops}', '{out} 条出向 · {in} 条入向{loops}'],
|
||||
'viewer.passport.relationship.loops': [' · {count} loop', ' · {count} 条自环'],
|
||||
'viewer.passport.relationship.explorer': ['Direct relationship explorer', '直接关系浏览器'],
|
||||
'viewer.passport.relationship.help': ['Use arrow keys to explore relationships. Press Enter or Space to pin details; Escape clears.', '使用方向键浏览关系。按 Enter 或空格键固定详情;按 Escape 清除。'],
|
||||
'viewer.passport.relationship.loopsBack': ['loops back', '回环'],
|
||||
'viewer.passport.relationship.connectsTo': ['connects to', '连接到'],
|
||||
'viewer.passport.relationship.connectsFrom': ['connects from', '连接自'],
|
||||
'viewer.passport.relationship.pinned': ['Pinned relationship · {from} → {to} · {label}', '已固定关系 · {from} → {to} · {label}'],
|
||||
'viewer.passport.relationship.inspect': ['Inspect relationship {index} of {total}: {from} to {to}, {label}. Press Enter for details.', '检查第 {index}/{total} 条关系:{from} 到 {to},{label}。按 Enter 查看详情。'],
|
||||
'viewer.passport.relationship.group.out': ['Outgoing', '出向'],
|
||||
'viewer.passport.relationship.group.in': ['Incoming', '入向'],
|
||||
'viewer.passport.relationship.group.loop': ['Self loops', '自环'],
|
||||
'viewer.passport.relationship.row': ['{group}: {relationship}, {neighbor}', '{group}:{relationship},{neighbor}'],
|
||||
'viewer.passport.relationship.direction.out': ['OUT →', '出 →'],
|
||||
'viewer.passport.relationship.direction.in': ['← IN', '← 入'],
|
||||
'viewer.passport.relationship.direction.loop': ['LOOP', '自环'],
|
||||
'viewer.passport.sourceCount.one': ['{count} verified source reference', '{count} 个已验证来源引用'],
|
||||
'viewer.passport.sourceCount.other': ['{count} verified source references', '{count} 个已验证来源引用'],
|
||||
'viewer.passport.sourceMarker': ['SRC', '来源'],
|
||||
'viewer.passport.beacon.one': ['{count} verified source; focus this node to inspect', '{count} 个已验证来源;聚焦此节点以检查'],
|
||||
'viewer.passport.beacon.other': ['{count} verified sources; focus this node to inspect', '{count} 个已验证来源;聚焦此节点以检查'],
|
||||
'viewer.passport.repository.open': ['Open verified repository revision {revision}', '打开已验证的仓库修订版本 {revision}'],
|
||||
'viewer.passport.source.open': ['Open verified source {path} at revision {revision}', '打开修订版本 {revision} 中已验证的来源 {path}'],
|
||||
'viewer.passport.source.openLink': ['Open ↗', '打开 ↗'],
|
||||
'viewer.passport.reach.upstream.one': ['Trace {count} upstream authored node', '追踪 {count} 个上游编写节点'],
|
||||
'viewer.passport.reach.upstream.other': ['Trace {count} upstream authored nodes', '追踪 {count} 个上游编写节点'],
|
||||
'viewer.passport.reach.downstream.one': ['Trace {count} downstream authored node', '追踪 {count} 个下游编写节点'],
|
||||
'viewer.passport.reach.downstream.other': ['Trace {count} downstream authored nodes', '追踪 {count} 个下游编写节点'],
|
||||
'viewer.passport.reach.noUpstream': ['No upstream authored nodes', '没有上游编写节点'],
|
||||
'viewer.passport.reach.noDownstream': ['No downstream authored nodes', '没有下游编写节点'],
|
||||
'viewer.passport.reach.status': ['{direction} · {nodes} nodes · {links} links · max {hops} hops', '{direction} · {nodes} 个节点 · {links} 条连接 · 最深 {hops} 跳'],
|
||||
|
||||
'viewer.route.eyebrow': ['Path', '路径'],
|
||||
'viewer.route.start': ['Click where the path starts', '点击路径的起点'],
|
||||
'viewer.route.start.find': ['Find start', '查找起点'],
|
||||
'viewer.route.start.find.aria': ['Find a route start', '查找路径起点'],
|
||||
'viewer.route.copy': ['Copy link', '复制链接'],
|
||||
'viewer.route.copy.aria': ['Copy link to traced route', '复制已追踪路径的链接'],
|
||||
'viewer.route.clear': ['Clear', '清除'],
|
||||
'viewer.route.clear.aria': ['Clear route probe', '清除路径探测'],
|
||||
'viewer.route.traced': ['Path', '路径'],
|
||||
'viewer.route.pickTwo': ['Click two nodes on the diagram', '在图上点两个节点'],
|
||||
'viewer.route.pickOne': ['Click a node on the diagram', '在图上点一个节点'],
|
||||
'viewer.route.controls': ['Route journey controls', '路径旅程控制'],
|
||||
'viewer.route.previous': ['Previous route position', '上一个路径位置'],
|
||||
'viewer.route.play': ['Play route journey', '播放路径旅程'],
|
||||
'viewer.route.pause': ['Pause route journey', '暂停路径旅程'],
|
||||
'viewer.route.replay': ['Replay route journey', '重播路径旅程'],
|
||||
'viewer.route.next': ['Next route position', '下一个路径位置'],
|
||||
'viewer.route.journey': ['Journey', '旅程'],
|
||||
'viewer.route.pause.label': ['Pause', '暂停'],
|
||||
'viewer.route.replay.label': ['Replay', '重播'],
|
||||
'viewer.route.overview': ['Overview', '总览'],
|
||||
'viewer.route.overview.aria': ['Show complete route overview', '显示完整路径总览'],
|
||||
'viewer.route.instructions': ['Click a start, then an end. Paths follow the arrows.', '先点起点,再点终点,沿箭头方向找路。'],
|
||||
'viewer.route.destination': ['Where does the path from {label} end?', '从{label}出发,走到哪里?'],
|
||||
'viewer.route.destination.find': ['Find target', '查找目标'],
|
||||
'viewer.route.destination.find.aria': ['Find a reachable route destination', '查找可达的路径目标'],
|
||||
'viewer.route.differentDestination': ['Choose a different destination', '选择其他目标'],
|
||||
'viewer.route.distinct': ['Pick two different nodes.', '请选两个不同的节点。'],
|
||||
'viewer.route.unreachable': ['Cannot reach {label} from here', '走不到{label}'],
|
||||
'viewer.route.unreachable.detail': ['{target} cannot be reached from {source} along the arrows. Pick a highlighted node.', '沿箭头方向,从{source}走不到{target}。请选一个高亮节点。'],
|
||||
'viewer.route.start.instructions': ['Next, pick the end. Only nodes you can reach will light up.', '接着选终点,能走到的节点会亮起。'],
|
||||
'viewer.route.copy.success': ['Traced route link copied', '已复制路径链接'],
|
||||
'viewer.route.copy.failed': ['Could not copy traced route link', '无法复制路径链接'],
|
||||
'viewer.route.position': ['Route position {index} of {total}: {label}', '路径位置 {index}/{total}:{label}'],
|
||||
'viewer.route.step': ['Step {index} of {total} · {phase} · {label}', '第 {index}/{total} 步 · {phase} · {label}'],
|
||||
'viewer.route.motionRequired': ['Automatic journey requires Live motion', '自动旅程需要动态模式'],
|
||||
'viewer.route.trigger.clear': ['Clear traced route', '清除已追踪路径'],
|
||||
'viewer.route.overview.status': ['{nodes} · {hops} · shortest path', '{nodes} · {hops} · 最短路径'],
|
||||
'viewer.route.overview.node.one': ['{count} node', '{count} 个节点'],
|
||||
'viewer.route.overview.node.other': ['{count} nodes', '{count} 个节点'],
|
||||
'viewer.route.overview.hop.one': ['{count} step', '{count} 步'],
|
||||
'viewer.route.overview.hop.other': ['{count} steps', '{count} 步'],
|
||||
'viewer.route.phase.playing': ['Playing', '播放中'],
|
||||
'viewer.route.phase.complete': ['Complete', '已完成'],
|
||||
'viewer.route.phase.inspecting': ['Inspecting', '检查中'],
|
||||
'viewer.route.destination.count.one': ['{count} reachable node is highlighted. Click it.', '有 {count} 个能走到的节点已高亮,点一下。'],
|
||||
'viewer.route.destination.count.other': ['{count} reachable nodes are highlighted. Click one.', '有 {count} 个能走到的节点已高亮,点一个。'],
|
||||
'viewer.route.noOutgoing': ['No arrows lead out of this node. Clear it and pick another start.', '这个节点没有向外的箭头。清除后换一个起点。'],
|
||||
'viewer.route.result.title': ['{source} to {target}', '{source} 到 {target}'],
|
||||
'viewer.route.finder.source.title': ['Choose route start', '选择路径起点'],
|
||||
'viewer.route.finder.source.placeholder': ['Search route sources', '搜索路径来源'],
|
||||
'viewer.route.finder.source.empty': ['No matching route sources', '没有匹配的路径来源'],
|
||||
'viewer.route.finder.source.results': ['Nodes that can start a route', '可作为路径起点的节点'],
|
||||
'viewer.route.finder.source.noun': ['route sources', '个路径来源'],
|
||||
'viewer.route.finder.source.badge': ['start', '起点'],
|
||||
'viewer.route.finder.target.title': ['Destination from {label}', '从{label}出发的目标'],
|
||||
'viewer.route.finder.target.placeholder': ['Search reachable destinations', '搜索可达目标'],
|
||||
'viewer.route.finder.target.empty': ['No matching reachable destinations', '没有匹配的可达目标'],
|
||||
'viewer.route.finder.target.results': ['Reachable route destinations', '可达路径目标'],
|
||||
'viewer.route.finder.target.noun': ['reachable destinations', '个可达目标'],
|
||||
'viewer.route.hop.one': ['{count} hop', '{count} 跳'],
|
||||
'viewer.route.hop.other': ['{count} hops', '{count} 跳'],
|
||||
|
||||
'viewer.lens.eyebrow': ['Semantic lens', '语义透镜'],
|
||||
'viewer.lens.title': ['Compare system roles', '比较系统角色'],
|
||||
'viewer.lens.close': ['Close semantic lens', '关闭语义透镜'],
|
||||
'viewer.lens.instruction': ['Choose up to two semantic kinds. One reveals its real traffic; two compare only direct authored relationships.', '最多选择两种语义类型。选择一种可显示其真实流量;选择两种只比较直接编写的关系。'],
|
||||
'viewer.lens.kinds': ['Semantic kinds', '语义类型'],
|
||||
'viewer.lens.choose': ['Choose a kind to inspect its nodes and touching relationships.', '选择一种类型以检查其节点和相连关系。'],
|
||||
'viewer.lens.copy': ['Copy link to semantic lens', '复制语义透镜链接'],
|
||||
'viewer.lens.clear': ['Clear semantic lens', '清除语义透镜'],
|
||||
'viewer.lens.open': ['Open semantic lens', '打开语义透镜'],
|
||||
'viewer.lens.openActive': ['Open active semantic lens', '打开当前语义透镜'],
|
||||
'viewer.lens.legend': ['Semantic legend', '语义图例'],
|
||||
'viewer.lens.legend.inspect.one': ['Inspect {label}, {count} node', '检查{label},{count} 个节点'],
|
||||
'viewer.lens.legend.inspect.other': ['Inspect {label}, {count} nodes', '检查{label},{count} 个节点'],
|
||||
'viewer.lens.kind.count.one': ['{label}, {count} node', '{label},{count} 个节点'],
|
||||
'viewer.lens.kind.count.other': ['{label}, {count} nodes', '{label},{count} 个节点'],
|
||||
'viewer.lens.compare.one': ['{first} → {second}: {forward} · {second} → {first}: {reverse} · {count} direct relationship', '{first} → {second}:{forward} · {second} → {first}:{reverse} · 共 {count} 条直接关系'],
|
||||
'viewer.lens.compare.other': ['{first} → {second}: {forward} · {second} → {first}: {reverse} · {count} direct relationships', '{first} → {second}:{forward} · {second} → {first}:{reverse} · 共 {count} 条直接关系'],
|
||||
'viewer.lens.single': ['{nodes} · {relationships} · connected peers remain visible', '{nodes} · {relationships} · 已连接节点保持可见'],
|
||||
'viewer.lens.node.one': ['{count} {label} node', '{count} 个{label}节点'],
|
||||
'viewer.lens.node.other': ['{count} {label} nodes', '{count} 个{label}节点'],
|
||||
'viewer.lens.relationship.one': ['{count} touching relationship', '{count} 条相连关系'],
|
||||
'viewer.lens.relationship.other': ['{count} touching relationships', '{count} 条相连关系'],
|
||||
|
||||
'viewer.radar.title': ['Semantic radar', '语义雷达'],
|
||||
'viewer.radar.building': ['Building overview', '正在构建总览'],
|
||||
'viewer.radar.openFull': ['Open full semantic radar', '打开完整语义雷达'],
|
||||
'viewer.radar.open': ['Open radar', '打开雷达'],
|
||||
'viewer.radar.close': ['Close semantic radar', '关闭语义雷达'],
|
||||
'viewer.radar.surface': ['Diagram overview. Click a node to focus it, or use arrow keys to pan.', '图表总览。点击节点进行聚焦,或使用方向键平移。'],
|
||||
'viewer.radar.click': ['Click node', '点击节点'],
|
||||
'viewer.radar.drag': ['Drag to pan', '拖动平移'],
|
||||
'viewer.radar.space': ['Semantic radar needs more MAP space.', '语义雷达需要更多地图可见空间。'],
|
||||
'viewer.radar.nodes': ['Semantic diagram radar nodes', '语义图表雷达节点'],
|
||||
'viewer.radar.focus': ['Focus {label} from Semantic Radar', '从语义雷达聚焦{label}'],
|
||||
'viewer.radar.status': ['{count} nodes · {viewport}', '{count} 个节点 · {viewport}'],
|
||||
'viewer.radar.fullMap': ['{count} nodes · full map', '{count} 个节点 · 完整地图'],
|
||||
'viewer.radar.compacted': ['Radar compacted to avoid covering the Semantic Passport or MAP controls.', '已收紧雷达,避免遮挡语义护照或地图控件。'],
|
||||
'viewer.radar.cancelWaiting': ['Cancel semantic radar waiting for more MAP space', '取消等待更多地图空间的语义雷达'],
|
||||
'viewer.radar.needsSpace': ['Semantic radar needs more visible MAP space', '语义雷达需要更多可见地图空间'],
|
||||
'viewer.radar.viewport.full': ['full map', '完整地图'],
|
||||
'viewer.radar.viewport.width': ['{percent}% width', '宽度 {percent}%'],
|
||||
'viewer.radar.viewport.scale': ['{percent}% viewport', '视口 {percent}%'],
|
||||
|
||||
'viewer.nav.controls': ['Diagram view controls', '图表视图控制'],
|
||||
'viewer.nav.route': ['Trace a directed route', '追踪有向路径'],
|
||||
'viewer.nav.route.title': ['Trace route (R)', '追踪路径(R)'],
|
||||
'viewer.nav.route.short': ['PATH', '路径'],
|
||||
'viewer.nav.radar': ['Open semantic radar', '打开语义雷达'],
|
||||
'viewer.nav.radar.title': ['Semantic radar (M)', '语义雷达(M)'],
|
||||
'viewer.nav.radar.short': ['MAP', '地图'],
|
||||
'viewer.nav.lens': ['Open semantic lens', '打开语义透镜'],
|
||||
'viewer.nav.lens.title': ['Semantic lens (L)', '语义透镜(L)'],
|
||||
'viewer.nav.lens.short': ['LENS', '透镜'],
|
||||
'viewer.nav.find': ['Find a node', '查找节点'],
|
||||
'viewer.nav.find.title': ['Find a node (/)', '查找节点(/)'],
|
||||
'viewer.outline.title': ['Node index', '节点索引'],
|
||||
'viewer.rail.show': ['Notes & index', '要点与索引'],
|
||||
'viewer.rail.controls': ['Side panel', '侧栏'],
|
||||
'viewer.rail.collapse': ['Collapse side panel', '收起侧栏'],
|
||||
'viewer.rail.bottom': ['Move panel below the diagram', '移到图下方'],
|
||||
'viewer.rail.right': ['Move panel beside the diagram', '移到图右侧'],
|
||||
'viewer.nav.guide': ['Open diagram guide', '打开图表指南'],
|
||||
'viewer.nav.guide.title': ['Diagram guide (?)', '图表指南(?)'],
|
||||
'viewer.nav.zoomOut': ['Zoom out', '缩小'],
|
||||
'viewer.nav.zoomOut.title': ['Zoom out (-)', '缩小(-)'],
|
||||
'viewer.nav.reset': ['Reset diagram view', '重置图表视图'],
|
||||
'viewer.nav.reset.title': ['Reset view (0)', '重置视图(0)'],
|
||||
'viewer.nav.read': ['READ', '阅读'],
|
||||
'viewer.nav.zoomIn': ['Zoom in', '放大'],
|
||||
'viewer.nav.zoomIn.title': ['Zoom in (+)', '放大(+)'],
|
||||
'viewer.nav.camera': ['{hint}. Reset diagram view', '{hint}。重置图表视图'],
|
||||
'viewer.nav.camera.title': ['{semantic}{hint} · reset view (0)', '{semantic}{hint} · 重置视图(0)'],
|
||||
'viewer.nav.camera.semantic': ['Semantic camera active · ', '语义相机已启用 · '],
|
||||
'viewer.nav.level.map': ['MAP', '概览'],
|
||||
'viewer.nav.level.read': ['READ', '阅读'],
|
||||
'viewer.nav.level.full': ['FULL', '完整'],
|
||||
'viewer.nav.level.auto': ['AUTO', '自动'],
|
||||
'viewer.nav.detail.map': ['Zoom in to reveal relationship labels and node context', '放大以显示关系标签和节点上下文'],
|
||||
'viewer.nav.detail.read': ['Zoom in again to reveal tags and annotations', '再次放大以显示标签和注释'],
|
||||
'viewer.nav.detail.full': ['Full diagram detail', '完整图表详情'],
|
||||
|
||||
'viewer.intent.summary': ['{label}. {out} outgoing, {in} incoming{loops}. {total} connections. Press Enter for details.', '{label}。{out} 条出向,{in} 条入向{loops}。共 {total} 条连接。按 Enter 查看详情。'],
|
||||
'viewer.intent.loops': [', {count} self loop', ',{count} 条自环'],
|
||||
|
||||
'viewer.common.copied': ['Copied', '已复制'],
|
||||
'viewer.common.copyFailed': ['Copy failed', '复制失败'],
|
||||
'viewer.common.copyLink': ['Copy link', '复制链接'],
|
||||
'viewer.common.clear': ['Clear', '清除'],
|
||||
'viewer.common.close': ['Close', '关闭'],
|
||||
};
|
||||
|
||||
for (const [key, messages] of Object.entries(MESSAGE_PAIRS)) {
|
||||
if (messages.length !== SUPPORTED_LOCALES.length || messages.some((message) => typeof message !== 'string')) {
|
||||
throw new Error(`Incomplete Archify i18n tuple ${JSON.stringify(key)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const BUILTIN_CATALOGS = Object.fromEntries(SUPPORTED_LOCALES.map((locale, index) => [
|
||||
locale,
|
||||
Object.fromEntries(Object.entries(MESSAGE_PAIRS).map(([key, pair]) => [key, pair[index]])),
|
||||
]));
|
||||
|
||||
const EN = BUILTIN_CATALOGS.en;
|
||||
|
||||
const CANONICAL_KEYS = Object.keys(EN);
|
||||
const PLACEHOLDER_PATTERN = /\{([a-zA-Z0-9_]+)\}/g;
|
||||
|
||||
function extractPlaceholders(message) {
|
||||
return new Set([...String(message).matchAll(PLACEHOLDER_PATTERN)].map((match) => match[1]));
|
||||
}
|
||||
|
||||
const CANONICAL_PLACEHOLDERS = Object.fromEntries(
|
||||
CANONICAL_KEYS.map((key) => [key, extractPlaceholders(EN[key])]),
|
||||
);
|
||||
|
||||
function placeholdersMatch(expected, actual) {
|
||||
if (expected.size !== actual.size) return false;
|
||||
for (const token of expected) if (!actual.has(token)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Runtime catalogs registered by registerLocale(), keyed by whatever locale
|
||||
// tag the caller supplied (e.g. an agent-authored 'fr'). Kept separate from
|
||||
// BUILTIN_CATALOGS so a caller can never accidentally shadow a shipped
|
||||
// catalog with a partial one.
|
||||
const RUNTIME_CATALOGS = new Map();
|
||||
|
||||
function catalogFor(locale) {
|
||||
return RUNTIME_CATALOGS.get(locale) || BUILTIN_CATALOGS[locale];
|
||||
}
|
||||
|
||||
// Validates caller-supplied translation data against the canonical (English)
|
||||
// message-key set. Pure and side-effect free: registerLocale() calls this
|
||||
// and additionally builds/installs the resolved catalog.
|
||||
export function validateTranslations(translations = {}) {
|
||||
const supplied = Object.keys(translations || {});
|
||||
const suppliedSet = new Set(supplied);
|
||||
const missingKeys = CANONICAL_KEYS.filter((key) => !suppliedSet.has(key));
|
||||
const unknownKeys = supplied.filter((key) => !Object.hasOwn(CANONICAL_PLACEHOLDERS, key));
|
||||
const placeholderMismatches = [];
|
||||
const usableKeys = [];
|
||||
for (const key of supplied) {
|
||||
if (!Object.hasOwn(CANONICAL_PLACEHOLDERS, key)) continue;
|
||||
const value = translations[key];
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
placeholderMismatches.push({ key, expected: [...CANONICAL_PLACEHOLDERS[key]].sort(), actual: null });
|
||||
continue;
|
||||
}
|
||||
const actual = extractPlaceholders(value);
|
||||
if (placeholdersMatch(CANONICAL_PLACEHOLDERS[key], actual)) {
|
||||
usableKeys.push(key);
|
||||
} else {
|
||||
placeholderMismatches.push({
|
||||
key,
|
||||
expected: [...CANONICAL_PLACEHOLDERS[key]].sort(),
|
||||
actual: [...actual].sort(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
totalKeys: CANONICAL_KEYS.length,
|
||||
coveredKeys: usableKeys.length,
|
||||
coverage: CANONICAL_KEYS.length ? usableKeys.length / CANONICAL_KEYS.length : 1,
|
||||
missingKeys,
|
||||
unknownKeys,
|
||||
placeholderMismatches,
|
||||
};
|
||||
}
|
||||
|
||||
// Registers a fully-resolved catalog for an arbitrary locale tag, built by
|
||||
// layering validated translations over the English base. A key that is
|
||||
// missing, non-string, or whose interpolation placeholders don't match the
|
||||
// canonical set falls back to its English string — partial or malformed
|
||||
// translation data can never break rendering. Returns the same coverage
|
||||
// report validateTranslations() would, for the caller to surface as an
|
||||
// explicit fallback/coverage diagnostic before rendering.
|
||||
export function registerLocale(locale, translations = {}) {
|
||||
const report = validateTranslations(translations);
|
||||
const catalog = { ...EN };
|
||||
for (const key of CANONICAL_KEYS) {
|
||||
const value = translations?.[key];
|
||||
if (typeof value !== 'string' || value.length === 0) continue;
|
||||
if (placeholdersMatch(CANONICAL_PLACEHOLDERS[key], extractPlaceholders(value))) {
|
||||
catalog[key] = value;
|
||||
}
|
||||
}
|
||||
RUNTIME_CATALOGS.set(locale, catalog);
|
||||
return { locale, ...report };
|
||||
}
|
||||
|
||||
export function resolveLocale(locale) {
|
||||
return catalogFor(locale) ? locale : DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function formatMessage(template, values = {}) {
|
||||
return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => (
|
||||
Object.hasOwn(values, key) ? String(values[key]) : match
|
||||
));
|
||||
}
|
||||
|
||||
export function translateMessage(locale, key, values = {}) {
|
||||
const resolved = resolveLocale(locale);
|
||||
const catalog = catalogFor(resolved);
|
||||
if (!Object.hasOwn(catalog, key)) {
|
||||
throw new Error(`Missing Archify i18n message ${JSON.stringify(key)} for ${resolved}`);
|
||||
}
|
||||
return formatMessage(catalog[key], values);
|
||||
}
|
||||
|
||||
export function translateCount(locale, key, count, values = {}) {
|
||||
const suffix = count === 1 ? 'one' : 'other';
|
||||
return translateMessage(locale, `${key}.${suffix}`, { ...values, count });
|
||||
}
|
||||
|
||||
export function viewerCatalog(locale) {
|
||||
const resolved = resolveLocale(locale);
|
||||
return Object.fromEntries(Object.entries(catalogFor(resolved)).filter(([key]) => key.startsWith('viewer.')));
|
||||
}
|
||||
|
||||
export function localizeTemplate(template, locale) {
|
||||
return template.replace(/\{\{i18n:([a-zA-Z0-9_.-]+)\}\}/g, (_match, key) => escapeHtml(translateMessage(locale, key)));
|
||||
}
|
||||
|
||||
export function catalogKeys() {
|
||||
return [...CANONICAL_KEYS];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Serialize computed layout for dry-run / inspect (#9). */
|
||||
|
||||
export function componentBox(c) {
|
||||
return {
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
label: c.label,
|
||||
x: Math.round(c.x),
|
||||
y: Math.round(c.y),
|
||||
width: c.width,
|
||||
height: c.height,
|
||||
...(Number.isInteger(c.row) ? { row: c.row } : {}),
|
||||
...(Number.isInteger(c.col) ? { col: c.col } : {}),
|
||||
...(Array.isArray(c.pos) ? { pos: c.pos.map(Math.round) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function boundaryBox(b) {
|
||||
return {
|
||||
kind: b.kind,
|
||||
label: b.label,
|
||||
x: Math.round(b.x),
|
||||
y: Math.round(b.y),
|
||||
width: Math.round(b.width),
|
||||
height: Math.round(b.height),
|
||||
wraps: b.wraps,
|
||||
};
|
||||
}
|
||||
|
||||
export function connectionPath(conn, routed, labelAt) {
|
||||
return {
|
||||
from: conn.from,
|
||||
to: conn.to,
|
||||
label: conn.label ?? null,
|
||||
variant: conn.variant ?? 'default',
|
||||
route: conn.route ?? 'auto',
|
||||
// These points are repair inputs: rounding a fractional port makes a
|
||||
// reused waypoint diagonal relative to the actual endpoint.
|
||||
points: routed.points.map(([x, y]) => [x, y]),
|
||||
...(labelAt ? { labelAt: labelAt.map(Math.round) } : {}),
|
||||
};
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
import { throwDiagnosticError } from './diagnostics.mjs';
|
||||
import { rectsOverlap, segmentIntersectsRect } from './geometry.mjs';
|
||||
import { esc, textUnits } from './utils.mjs';
|
||||
import { translateMessage } from './i18n.mjs';
|
||||
|
||||
const DEFAULT_FONT_SIZE = 8;
|
||||
const DEFAULT_ITEM_GAP = 22;
|
||||
const DEFAULT_LINE_GAP = 22;
|
||||
const DEFAULT_SWATCH_GAP = 8;
|
||||
const TEXT_ADVANCE_EM = 0.62;
|
||||
const INTERACTIVE_BADGE_ALLOWANCE = 21;
|
||||
|
||||
export function relationshipLegendObstacles(relations, { pointsFor, labelRectFor } = {}) {
|
||||
const obstacles = [];
|
||||
for (const [index, relation] of (Array.isArray(relations) ? relations : []).entries()) {
|
||||
const points = typeof pointsFor === 'function' ? pointsFor(relation, index) : [];
|
||||
const finitePoints = (Array.isArray(points) ? points : []).filter((point) => (
|
||||
Array.isArray(point) && point.length === 2 && point.every(Number.isFinite)
|
||||
));
|
||||
for (let pointIndex = 0; pointIndex < finitePoints.length - 1; pointIndex += 1) {
|
||||
obstacles.push({
|
||||
kind: 'relationship-segment',
|
||||
start: finitePoints[pointIndex],
|
||||
end: finitePoints[pointIndex + 1],
|
||||
});
|
||||
}
|
||||
const labelRect = typeof labelRectFor === 'function' ? labelRectFor(relation, index) : null;
|
||||
if (labelRect && [labelRect.x, labelRect.y, labelRect.width, labelRect.height].every(Number.isFinite)) {
|
||||
obstacles.push({ kind: 'relationship-label', ...labelRect });
|
||||
}
|
||||
}
|
||||
return obstacles;
|
||||
}
|
||||
|
||||
export function resolveLegend(config, catalog, presentKinds) {
|
||||
const mode = config?.mode || 'auto';
|
||||
if (mode === 'hidden') return [];
|
||||
const present = presentKinds instanceof Set ? presentKinds : new Set(presentKinds || []);
|
||||
const overrides = config?.entries || {};
|
||||
|
||||
return catalog.flatMap((catalogEntry) => {
|
||||
const override = overrides[catalogEntry.kind] || {};
|
||||
const selectedByMode = mode === 'all' || present.has(catalogEntry.kind);
|
||||
const visible = override.visible === true || (selectedByMode && override.visible !== false);
|
||||
if (!visible) return [];
|
||||
return [{
|
||||
...catalogEntry,
|
||||
label: override.label || catalogEntry.label,
|
||||
present: present.has(catalogEntry.kind),
|
||||
interactive: catalogEntry.interactive !== false && present.has(catalogEntry.kind),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function measuredEntryWidth(entry, fontSize, swatchGap) {
|
||||
const swatchWidth = entry.swatchWidth ?? 14;
|
||||
return Math.ceil(
|
||||
swatchWidth
|
||||
+ swatchGap
|
||||
+ textUnits(entry.label) * fontSize * TEXT_ADVANCE_EM
|
||||
+ (entry.interactive ? INTERACTIVE_BADGE_ALLOWANCE : 0),
|
||||
);
|
||||
}
|
||||
|
||||
// One pure footprint calculation owns both auto-viewBox sizing and final SVG
|
||||
// placement. Callers must not maintain a second approximation of legend width
|
||||
// or row count; that would make generated geometry disagree with validation.
|
||||
export function legendFootprint(entries, {
|
||||
width,
|
||||
fontSize = DEFAULT_FONT_SIZE,
|
||||
itemGap = DEFAULT_ITEM_GAP,
|
||||
lineGap = DEFAULT_LINE_GAP,
|
||||
swatchGap = DEFAULT_SWATCH_GAP,
|
||||
} = {}) {
|
||||
if (!entries.length) {
|
||||
return { measured: [], rows: [], rowCount: 0, minWidth: 0, extraHeight: 0 };
|
||||
}
|
||||
const measured = entries.map((entry) => ({
|
||||
...entry,
|
||||
width: measuredEntryWidth(entry, fontSize, entry.swatchGap ?? swatchGap),
|
||||
}));
|
||||
const rows = [[]];
|
||||
let cursor = 0;
|
||||
for (const entry of measured) {
|
||||
const row = rows.at(-1);
|
||||
const required = (row.length ? itemGap : 0) + entry.width;
|
||||
if (row.length && cursor + required > width) {
|
||||
rows.push([entry]);
|
||||
cursor = entry.width;
|
||||
} else {
|
||||
row.push(entry);
|
||||
cursor += required;
|
||||
}
|
||||
}
|
||||
return {
|
||||
measured,
|
||||
rows,
|
||||
rowCount: rows.length,
|
||||
minWidth: measured.reduce((width, entry) => Math.max(width, entry.width), 0),
|
||||
extraHeight: (rows.length - 1) * lineGap,
|
||||
};
|
||||
}
|
||||
|
||||
export function measureLegend(entries, {
|
||||
x,
|
||||
baselineY,
|
||||
width,
|
||||
fontSize = DEFAULT_FONT_SIZE,
|
||||
itemGap = DEFAULT_ITEM_GAP,
|
||||
lineGap = DEFAULT_LINE_GAP,
|
||||
swatchGap = DEFAULT_SWATCH_GAP,
|
||||
minTitleY = 0,
|
||||
obstacles = [],
|
||||
unfit = 'error',
|
||||
diagramType = 'diagram',
|
||||
} = {}) {
|
||||
if (!entries.length) return { entries: [], rowCount: 0, titleY: null };
|
||||
const footprint = legendFootprint(entries, { width, fontSize, itemGap, lineGap, swatchGap });
|
||||
const tooWide = footprint.measured.find((entry) => entry.width > width);
|
||||
if (tooWide) {
|
||||
if (unfit === 'hide') return null;
|
||||
const message = `[legend/label-too-wide] ${diagramType} legend label for "${tooWide.kind}" needs ${tooWide.width}px but only ${width}px is available.`;
|
||||
throwDiagnosticError(message, [{
|
||||
code: 'legend/label-too-wide',
|
||||
severity: 'error',
|
||||
message,
|
||||
subject: { diagramType, path: `/meta/legend/entries/${tooWide.kind}/label` },
|
||||
evidence: { kind: tooWide.kind, measuredWidthPx: tooWide.width, availableWidthPx: width },
|
||||
supportedFixes: ['shorten the legend label or use a wider viewBox'],
|
||||
}]);
|
||||
}
|
||||
|
||||
const titleY = baselineY - footprint.extraHeight - 20;
|
||||
const legendTopY = titleY - 10;
|
||||
if (legendTopY < minTitleY) {
|
||||
if (unfit === 'hide') return null;
|
||||
const message = `[legend/vertical-overflow] ${diagramType} legend needs ${footprint.rowCount} rows, which would start at y=${legendTopY} above the available legend band at y=${minTitleY}.`;
|
||||
throwDiagnosticError(message, [{
|
||||
code: 'legend/vertical-overflow',
|
||||
severity: 'error',
|
||||
message,
|
||||
subject: { diagramType, path: '/meta/legend' },
|
||||
evidence: { rowCount: footprint.rowCount, requiredTopY: legendTopY, availableTopY: minTitleY },
|
||||
supportedFixes: ['shorten legend labels, hide nonessential entries, or use a wider viewBox'],
|
||||
}]);
|
||||
}
|
||||
|
||||
const positioned = [];
|
||||
footprint.rows.forEach((row, rowIndex) => {
|
||||
let entryX = x;
|
||||
const baseline = baselineY - (footprint.rowCount - rowIndex - 1) * lineGap;
|
||||
for (const entry of row) {
|
||||
positioned.push({ ...entry, x: entryX, baseline, row: rowIndex });
|
||||
entryX += entry.width + itemGap;
|
||||
}
|
||||
});
|
||||
|
||||
const legendRects = [
|
||||
{ kind: 'title', x, y: legendTopY, width: 48, height: 14 },
|
||||
...positioned.map((entry) => ({
|
||||
kind: entry.kind,
|
||||
x: entry.x,
|
||||
y: entry.baseline - 10,
|
||||
width: entry.width,
|
||||
height: 14,
|
||||
})),
|
||||
];
|
||||
const collision = legendRects.find((legendRect) => obstacles.some((obstacle) => (
|
||||
Array.isArray(obstacle.start) && Array.isArray(obstacle.end)
|
||||
? segmentIntersectsRect({ start: obstacle.start, end: obstacle.end }, legendRect)
|
||||
: rectsOverlap(obstacle, legendRect)
|
||||
)));
|
||||
if (collision) {
|
||||
if (unfit === 'hide') return null;
|
||||
const message = `[legend/content-overlap] ${diagramType} legend entry "${collision.kind}" overlaps authored relationship geometry.`;
|
||||
throwDiagnosticError(message, [{
|
||||
code: 'legend/content-overlap',
|
||||
severity: 'error',
|
||||
message,
|
||||
subject: { diagramType, path: '/meta/legend' },
|
||||
evidence: { legendKind: collision.kind, legendRect: collision },
|
||||
supportedFixes: ['shorten or hide legend entries, use a wider viewBox, or move the authored relationship route/label out of the legend band'],
|
||||
}]);
|
||||
}
|
||||
|
||||
return {
|
||||
entries: positioned,
|
||||
rowCount: footprint.rowCount,
|
||||
titleY,
|
||||
fontSize,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderLegend({ entries, layout, renderSwatch, locale }) {
|
||||
if (!entries.length) return '';
|
||||
const measured = measureLegend(entries, layout);
|
||||
if (!measured) return '';
|
||||
const hasInteractiveEntries = measured.entries.some((entry) => entry.interactive);
|
||||
const renderedFontSize = measured.fontSize < 8 ? measured.fontSize + 0.5 : measured.fontSize + 2;
|
||||
const rootAttributes = hasInteractiveEntries ? ' data-legend="" data-legend-bridge=""' : ' data-legend=""';
|
||||
const parts = [
|
||||
` <g${rootAttributes}>`,
|
||||
` <text x="${layout.x}" y="${measured.titleY}" class="t-primary" font-size="12" font-weight="650">${esc(translateMessage(locale, 'legend.title'))}</text>`,
|
||||
];
|
||||
|
||||
for (const entry of measured.entries) {
|
||||
const interactive = entry.interactive
|
||||
? ` data-legend-kind="${esc(entry.kind)}" data-legend-label="${esc(entry.label)}"`
|
||||
: '';
|
||||
parts.push(` <g data-legend-semantic-kind="${esc(entry.kind)}"${interactive} data-legend-x="${entry.x}" data-legend-baseline="${entry.baseline}" data-legend-width="${entry.width}">`);
|
||||
parts.push(` ${renderSwatch(entry)}`);
|
||||
parts.push(` <text x="${entry.x + (entry.swatchWidth ?? 14) + (entry.swatchGap ?? DEFAULT_SWATCH_GAP)}" y="${entry.baseline}" class="t-muted" font-size="${renderedFontSize}" font-weight="500">${esc(entry.label)}</text>`);
|
||||
parts.push(' </g>');
|
||||
}
|
||||
parts.push(' </g>');
|
||||
return parts.join('\n');
|
||||
}
|
||||
+440
@@ -0,0 +1,440 @@
|
||||
import path from 'node:path';
|
||||
import {
|
||||
containedBy,
|
||||
isValidWindowsSmbShareName,
|
||||
isWindowsIpcShare,
|
||||
resolvePhysicalLocation,
|
||||
sameLocation,
|
||||
} from './path-semantics.mjs';
|
||||
import { PortablePathError, validatePortablePath } from './portable-path.mjs';
|
||||
|
||||
export function canonicalFuturePath(targetPath) {
|
||||
const resolution = resolvePhysicalLocation(targetPath);
|
||||
if (resolution.status === 'resolved') {
|
||||
if (resolution.location.kind === 'existing') return resolution.location.path;
|
||||
return path.join(
|
||||
resolution.location.ancestorPath,
|
||||
...resolution.location.unresolved,
|
||||
);
|
||||
}
|
||||
const output = path.resolve(targetPath);
|
||||
if (resolution.reason.code === 'symlink-cycle') {
|
||||
throw new OutputPathError(`Output path contains a symbolic-link cycle: "${output}".`, {
|
||||
code: 'output/symlink-cycle',
|
||||
message: 'Output path could not be resolved because it contains a symbolic-link cycle.',
|
||||
subject: { output },
|
||||
evidence: { relation: resolution.reason },
|
||||
supportedFixes: ['remove the symbolic-link cycle or choose an output path outside it'],
|
||||
});
|
||||
}
|
||||
throw new OutputPathError('Output path could not be resolved safely.', {
|
||||
code: 'output/path-resolution-indeterminate',
|
||||
message: 'Output path could not be resolved safely for the requested filesystem location.',
|
||||
subject: { output },
|
||||
evidence: { relation: resolution.reason },
|
||||
supportedFixes: ['use an ordinary local filesystem path that can be resolved safely, then retry'],
|
||||
});
|
||||
}
|
||||
|
||||
export function pathsAlias(leftPath, rightPath) {
|
||||
const relation = sameLocation(leftPath, rightPath);
|
||||
if (relation.status === 'match') return true;
|
||||
if (relation.status === 'different') return false;
|
||||
if (relation.reason.code === 'ancestor-not-directory') return false;
|
||||
if (relation.reason.code === 'symlink-cycle') {
|
||||
const output = path.resolve(relation.reason.side === 'right' ? rightPath : leftPath);
|
||||
throw new OutputPathError(`Output path contains a symbolic-link cycle: "${output}".`, {
|
||||
code: 'output/symlink-cycle',
|
||||
message: 'Output path could not be resolved because it contains a symbolic-link cycle.',
|
||||
subject: { output },
|
||||
evidence: { relation: relation.reason },
|
||||
supportedFixes: ['remove the symbolic-link cycle or choose an output path outside it'],
|
||||
});
|
||||
}
|
||||
throw new OutputPathError('Path identity could not be determined safely.', {
|
||||
code: 'output/path-identity-indeterminate',
|
||||
message: 'Path identity could not be determined safely for the requested filesystem location.',
|
||||
subject: { left: path.resolve(leftPath), right: path.resolve(rightPath) },
|
||||
evidence: { relation: relation.reason },
|
||||
supportedFixes: ['use ordinary local filesystem paths whose identity can be verified, then retry'],
|
||||
});
|
||||
}
|
||||
|
||||
function pathIsInside(directoryPath, targetPath) {
|
||||
const relation = containedBy(directoryPath, targetPath);
|
||||
if (relation.status === 'match') return true;
|
||||
if (relation.status === 'different') return false;
|
||||
throw new OutputPathError('Output containment could not be determined safely.', {
|
||||
code: 'output/containment-indeterminate',
|
||||
message: 'Output containment could not be determined safely for the requested filesystem location.',
|
||||
subject: { output: path.resolve(targetPath), cwd: path.resolve(directoryPath) },
|
||||
evidence: { relation: relation.reason },
|
||||
supportedFixes: ['use an output beneath an ordinary local directory whose identity can be verified'],
|
||||
});
|
||||
}
|
||||
|
||||
function authoredOutputDiagnostic(error, rawOutput) {
|
||||
const absolute = error?.reason === 'absolute';
|
||||
const code = absolute ? 'output/meta-absolute' : 'output/meta-path-syntax';
|
||||
const message = absolute
|
||||
? 'meta.output must be a relative path resolved from the current working directory.'
|
||||
: 'meta.output must be a portable POSIX-relative path.';
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
subject: { output: rawOutput, path: '/meta/output' },
|
||||
evidence: {
|
||||
reason: error?.reason || 'invalid',
|
||||
...(error?.segmentIndex !== undefined ? { segmentIndex: error.segmentIndex } : {}),
|
||||
...(error?.segment !== undefined ? { segment: error.segment } : {}),
|
||||
...(error?.utf8Bytes !== undefined ? { utf8Bytes: error.utf8Bytes } : {}),
|
||||
...(error?.utf16CodeUnits !== undefined ? { utf16CodeUnits: error.utf16CodeUnits } : {}),
|
||||
...(error?.limit !== undefined ? { limit: error.limit } : {}),
|
||||
},
|
||||
supportedFixes: ['set meta.output to a portable relative .html path such as reports/diagram.html'],
|
||||
};
|
||||
}
|
||||
|
||||
function nativeOutputDiagnostic(rawOutput, reason, details = {}) {
|
||||
return {
|
||||
code: 'output/native-path-syntax',
|
||||
message: 'The output path is not a valid native filesystem path on this host.',
|
||||
subject: { output: rawOutput },
|
||||
evidence: { reason, ...details },
|
||||
supportedFixes: ['choose an ordinary filesystem path without device names, alternate data streams, trailing dots or spaces, or overlong components'],
|
||||
};
|
||||
}
|
||||
|
||||
function throwNativeOutputDiagnostic(rawOutput, reason, details = {}) {
|
||||
const diagnostic = nativeOutputDiagnostic(rawOutput, reason, details);
|
||||
throw new OutputPathError(diagnostic.message, diagnostic);
|
||||
}
|
||||
|
||||
function windowsExtendedTailComponents(rawOutput, tail) {
|
||||
const authoredComponents = tail.split('\\');
|
||||
// Preserve one trailing separator for directory arguments, but never repair
|
||||
// an empty component inside the raw extended namespace.
|
||||
if (authoredComponents.slice(0, -1).some((component) => component.length === 0)) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
|
||||
}
|
||||
return authoredComponents.filter(Boolean);
|
||||
}
|
||||
|
||||
function rejectWindowsIpcShare(rawOutput, share) {
|
||||
if (isWindowsIpcShare(share)) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-ipc-namespace', { share });
|
||||
}
|
||||
}
|
||||
|
||||
function validateWindowsNativeComponent(
|
||||
rawOutput,
|
||||
component,
|
||||
componentIndex,
|
||||
{ allowReservedName = false } = {},
|
||||
) {
|
||||
try {
|
||||
// Prefix the component so a colon is classified as an ADS separator,
|
||||
// rather than allowing the generic URI detector to claim it first.
|
||||
validatePortablePath(`native/${component}`, { profile: 'output' });
|
||||
} catch (error) {
|
||||
if (!(error instanceof PortablePathError)) throw error;
|
||||
if (allowReservedName && error.reason === 'windows-reserved-name') return;
|
||||
// Native Windows arguments may intentionally name an existing 8.3 alias.
|
||||
// Portable authored/archive paths reject that ambiguous spelling, while
|
||||
// native resolution lets the filesystem prove the existing target.
|
||||
if (error.reason === 'windows-short-name') {
|
||||
if (component.length <= 255) return;
|
||||
throwNativeOutputDiagnostic(rawOutput, 'component-too-long', {
|
||||
component,
|
||||
componentIndex,
|
||||
utf16CodeUnits: component.length,
|
||||
limit: 255,
|
||||
});
|
||||
}
|
||||
// Native Windows filesystems bound components in UTF-16 code units. The
|
||||
// stricter UTF-8 bound belongs to portable authored/archive names only.
|
||||
if (error.reason === 'component-too-long' && error.utf16CodeUnits <= 255) return;
|
||||
throwNativeOutputDiagnostic(rawOutput, error.reason, {
|
||||
component,
|
||||
componentIndex,
|
||||
...(error.character !== undefined ? { character: error.character } : {}),
|
||||
...(error.utf8Bytes !== undefined ? { utf8Bytes: error.utf8Bytes } : {}),
|
||||
...(error.utf16CodeUnits !== undefined ? { utf16CodeUnits: error.utf16CodeUnits } : {}),
|
||||
...(error.limit !== undefined ? { limit: error.limit } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateWindowsUncRootComponents(rawOutput, server, share) {
|
||||
rejectWindowsIpcShare(rawOutput, share);
|
||||
if (!isValidWindowsSmbShareName(share)) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-unc-share-name', {
|
||||
share,
|
||||
utf16CodeUnits: share.length,
|
||||
limit: 80,
|
||||
});
|
||||
}
|
||||
// UNC servers and shares are root components, not DOS file names. Keep the
|
||||
// server's ordinary native syntax checks while allowing names such as CON.
|
||||
validateWindowsNativeComponent(rawOutput, server, 0, { allowReservedName: true });
|
||||
}
|
||||
|
||||
function windowsExtendedPathComponents(rawOutput) {
|
||||
// The extended-length namespace deliberately bypasses Win32 normalization.
|
||||
// Inspect its original spelling so a dot segment cannot retarget a UNC share.
|
||||
if (rawOutput.includes('/')) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-separator', { character: '/' });
|
||||
}
|
||||
if (/^\\\\\?\\(?:GLOBALROOT|Device)(?:\\|$)/iu.test(rawOutput)) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-device-namespace');
|
||||
}
|
||||
|
||||
const drive = rawOutput.match(/^\\\\\?\\[A-Za-z]:\\/u);
|
||||
if (drive) {
|
||||
return windowsExtendedTailComponents(rawOutput, rawOutput.slice(drive[0].length));
|
||||
}
|
||||
|
||||
const uncPrefix = rawOutput.match(/^\\\\\?\\UNC\\/iu);
|
||||
if (uncPrefix) {
|
||||
const authoredTail = rawOutput.slice(uncPrefix[0].length);
|
||||
const components = authoredTail.split('\\');
|
||||
if (components.length < 2 || components[0].length === 0 || components[1].length === 0) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
|
||||
}
|
||||
validateWindowsUncRootComponents(rawOutput, components[0], components[1]);
|
||||
return windowsExtendedTailComponents(rawOutput, components.slice(2).join('\\'));
|
||||
}
|
||||
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
|
||||
}
|
||||
|
||||
function validateWindowsRawUncRoot(rawOutput) {
|
||||
if (!/^[\\/]{2}/u.test(rawOutput) || /^[\\/]{2}[.?][\\/]/u.test(rawOutput)) return;
|
||||
// Validate the raw server/share boundary before win32.normalize can collapse
|
||||
// an empty share or mix the two UNC separator spellings.
|
||||
const unc = rawOutput.match(/^([\\/])\1([^\\/]+)\1([^\\/]+)(?:[\\/]|$)/u);
|
||||
if (!unc) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-unc-root');
|
||||
}
|
||||
validateWindowsUncRootComponents(rawOutput, unc[2], unc[3]);
|
||||
}
|
||||
|
||||
function windowsPathComponents(rawOutput, normalized) {
|
||||
const authoredUncPrefix = /^[\\/]{2}/u.test(rawOutput);
|
||||
if (/^\\\\\.\\/u.test(normalized)) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-device-namespace');
|
||||
}
|
||||
if (/^\\\\\?\\/u.test(normalized)) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
|
||||
}
|
||||
if (normalized.startsWith('\\\\')) {
|
||||
const unc = normalized.match(/^\\\\([^\\]+)\\([^\\]+)(?:\\|$)/u);
|
||||
if (!unc) throwNativeOutputDiagnostic(rawOutput, 'windows-unc-root');
|
||||
return normalized.slice(unc[0].length).split('\\').filter(Boolean);
|
||||
}
|
||||
if (authoredUncPrefix) throwNativeOutputDiagnostic(rawOutput, 'windows-unc-root');
|
||||
if (normalized.startsWith('\\')) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'current-drive-rooted');
|
||||
}
|
||||
const root = path.win32.parse(normalized).root;
|
||||
return normalized
|
||||
.slice(root.length)
|
||||
.split(/[\\/]+/u)
|
||||
// normalize() retains leading navigation for a relative path. Those dot
|
||||
// segments are path syntax, not filename components subject to name rules.
|
||||
.filter((component) => component && component !== '.' && component !== '..');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate command-line/default output paths using the active host's native
|
||||
* syntax. Unlike authored portable paths, absolute paths and native separators
|
||||
* remain supported. File outputs reject a trailing separator before native
|
||||
* resolution can erase it; directory callers must opt in explicitly. The
|
||||
* component bound also protects derived sidecars from failing after an
|
||||
* operation has already started mutating the destination.
|
||||
*/
|
||||
export function validateNativeOutputPath(
|
||||
rawOutput,
|
||||
{ platform = process.platform, kind = 'file' } = {},
|
||||
) {
|
||||
if (kind !== 'file' && kind !== 'directory') {
|
||||
throw new TypeError(`Unsupported native output path kind: ${JSON.stringify(kind)}`);
|
||||
}
|
||||
if (typeof rawOutput !== 'string' || rawOutput.length === 0) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'empty');
|
||||
}
|
||||
if (rawOutput.includes('\0')) throwNativeOutputDiagnostic(rawOutput, 'nul-character');
|
||||
if (/[\uD800-\uDFFF]/u.test(rawOutput)) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'unpaired-surrogate');
|
||||
}
|
||||
const hasTrailingSeparator = platform === 'win32'
|
||||
? /[\\/]$/u.test(rawOutput)
|
||||
: rawOutput.endsWith(path.posix.sep);
|
||||
if (kind === 'file' && hasTrailingSeparator) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'trailing-separator', { kind });
|
||||
}
|
||||
|
||||
let normalized;
|
||||
let components;
|
||||
if (platform === 'win32') {
|
||||
if (/^[A-Za-z]:(?:$|[^\\/])/u.test(rawOutput)) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'drive-relative');
|
||||
}
|
||||
const extendedPrefix = rawOutput.startsWith('\\\\?\\');
|
||||
if (extendedPrefix) {
|
||||
components = windowsExtendedPathComponents(rawOutput);
|
||||
} else {
|
||||
validateWindowsRawUncRoot(rawOutput);
|
||||
normalized = path.win32.normalize(rawOutput);
|
||||
components = windowsPathComponents(rawOutput, normalized);
|
||||
}
|
||||
for (const [componentIndex, component] of components.entries()) {
|
||||
validateWindowsNativeComponent(rawOutput, component, componentIndex);
|
||||
}
|
||||
} else {
|
||||
normalized = path.resolve(rawOutput);
|
||||
const root = path.parse(normalized).root;
|
||||
components = normalized.slice(root.length).split(path.sep).filter(Boolean);
|
||||
for (const [componentIndex, component] of components.entries()) {
|
||||
const utf8Bytes = Buffer.byteLength(component, 'utf8');
|
||||
if (utf8Bytes > 255) {
|
||||
throwNativeOutputDiagnostic(rawOutput, 'component-too-long', {
|
||||
component,
|
||||
componentIndex,
|
||||
utf8Bytes,
|
||||
limit: 255,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return rawOutput;
|
||||
}
|
||||
|
||||
/** Validate a CLI directory argument before native resolution can normalize away its raw syntax. */
|
||||
export function resolveNativeOutputDirectory(
|
||||
rawDirectory,
|
||||
{ platform = process.platform, cwd = process.cwd() } = {},
|
||||
) {
|
||||
validateNativeOutputPath(rawDirectory, { platform, kind: 'directory' });
|
||||
const pathApi = platform === 'win32' ? path.win32 : path.posix;
|
||||
return pathApi.resolve(cwd, rawDirectory);
|
||||
}
|
||||
|
||||
export function validateAuthoredOutputPath(rawOutput, { cwd = process.cwd() } = {}) {
|
||||
try {
|
||||
validatePortablePath(rawOutput, { profile: 'output' });
|
||||
} catch (error) {
|
||||
if (!(error instanceof PortablePathError)) throw error;
|
||||
const diagnostic = authoredOutputDiagnostic(error, rawOutput);
|
||||
throw new OutputPathError(diagnostic.message, diagnostic);
|
||||
}
|
||||
if (path.posix.extname(rawOutput).toLowerCase() !== '.html') {
|
||||
throw new OutputPathError('meta.output must target an .html file.', {
|
||||
code: 'output/meta-extension',
|
||||
message: 'meta.output must target an .html file.',
|
||||
subject: { output: rawOutput, path: '/meta/output' },
|
||||
supportedFixes: ['change meta.output to a portable path ending in .html'],
|
||||
});
|
||||
}
|
||||
const outputPath = path.resolve(cwd, rawOutput);
|
||||
if (path.extname(canonicalFuturePath(outputPath)).toLowerCase() !== '.html') {
|
||||
throw new OutputPathError('meta.output must resolve to an .html file.', {
|
||||
code: 'output/meta-resolved-extension',
|
||||
message: 'meta.output must resolve to an .html file after symbolic links are followed.',
|
||||
subject: { output: rawOutput },
|
||||
supportedFixes: ['remove the symbolic-link alias or point it to an .html target inside the current working directory'],
|
||||
});
|
||||
}
|
||||
if (!pathIsInside(cwd, outputPath)) {
|
||||
throw new OutputPathError('meta.output must stay inside the current working directory.', {
|
||||
code: 'output/meta-outside-cwd',
|
||||
message: 'meta.output must stay inside the current working directory after symbolic links are resolved.',
|
||||
subject: { output: rawOutput, cwd: path.resolve(cwd) },
|
||||
supportedFixes: ['set meta.output to a relative .html path inside the current working directory'],
|
||||
});
|
||||
}
|
||||
return rawOutput;
|
||||
}
|
||||
|
||||
export class OutputPathError extends Error {
|
||||
constructor(message, diagnostic) {
|
||||
super(message);
|
||||
this.name = 'OutputPathError';
|
||||
this.archifyDiagnostics = [{
|
||||
severity: 'error',
|
||||
subject: {},
|
||||
evidence: {},
|
||||
supportedFixes: [],
|
||||
...diagnostic,
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOutputPath({
|
||||
requestedOutput,
|
||||
authoredOutput,
|
||||
defaultOutput,
|
||||
inputPaths = [],
|
||||
inputDescription = 'an input',
|
||||
otherOutputPaths = [],
|
||||
cwd = process.cwd(),
|
||||
requiredExtension = '.html',
|
||||
platform = process.platform,
|
||||
}) {
|
||||
if (authoredOutput !== undefined) validateAuthoredOutputPath(authoredOutput, { cwd });
|
||||
const source = requestedOutput !== undefined
|
||||
? 'cli'
|
||||
: (authoredOutput !== undefined ? 'meta' : 'default');
|
||||
const rawOutput = source === 'cli'
|
||||
? requestedOutput
|
||||
: (source === 'meta' ? authoredOutput : defaultOutput);
|
||||
if (source !== 'meta') validateNativeOutputPath(rawOutput, { platform, kind: 'file' });
|
||||
const outputPath = path.resolve(cwd, rawOutput);
|
||||
for (const inputPath of inputPaths) {
|
||||
if (!pathsAlias(outputPath, inputPath)) continue;
|
||||
throw new OutputPathError(`Output must not replace ${inputDescription}.`, {
|
||||
code: 'output/input-alias',
|
||||
message: `Output must not replace ${inputDescription}, including through a symbolic-link or future-path alias.`,
|
||||
subject: { output: outputPath, input: path.resolve(inputPath) },
|
||||
supportedFixes: ['choose an output path that is distinct from every input path'],
|
||||
});
|
||||
}
|
||||
for (const otherOutputPath of otherOutputPaths) {
|
||||
if (!pathsAlias(outputPath, otherOutputPath)) continue;
|
||||
throw new OutputPathError('Output targets must use distinct paths.', {
|
||||
code: 'output/target-alias',
|
||||
message: 'Output targets must use distinct paths, including symbolic-link and future-path aliases.',
|
||||
subject: { output: outputPath, conflictingOutput: path.resolve(otherOutputPath) },
|
||||
supportedFixes: ['choose distinct paths for every generated output'],
|
||||
});
|
||||
}
|
||||
|
||||
// Keep explicit CLI directories unrestricted, but reject mistaken file types.
|
||||
// Alias checks above retain priority when a target would overwrite an input.
|
||||
if (source === 'cli') {
|
||||
const resolvedOutput = canonicalFuturePath(outputPath);
|
||||
const authoredExtension = path.extname(rawOutput).toLowerCase();
|
||||
const existingWindowsHtmlAlias = platform === 'win32'
|
||||
&& requiredExtension === '.html'
|
||||
&& authoredExtension === '.htm'
|
||||
&& path.extname(resolvedOutput).toLowerCase() === requiredExtension
|
||||
&& pathsAlias(outputPath, resolvedOutput);
|
||||
const authoredMatches = authoredExtension === requiredExtension || existingWindowsHtmlAlias;
|
||||
const resolvedMatches = path.extname(resolvedOutput).toLowerCase() === requiredExtension;
|
||||
if (!authoredMatches || !resolvedMatches) {
|
||||
const message = `CLI output must ${authoredMatches ? 'resolve to' : 'target'} a ${requiredExtension} file.`;
|
||||
throw new OutputPathError(message, {
|
||||
code: authoredMatches ? 'output/cli-resolved-extension' : 'output/cli-extension',
|
||||
message,
|
||||
subject: { output: rawOutput },
|
||||
evidence: { resolvedOutput, requiredExtension },
|
||||
supportedFixes: [`choose a path ending in ${requiredExtension} whose symbolic-link target also ends in ${requiredExtension}`],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
outputPath,
|
||||
source,
|
||||
};
|
||||
}
|
||||
+1017
File diff suppressed because it is too large
Load Diff
+408
@@ -0,0 +1,408 @@
|
||||
const PORTABLE_PATH_PROFILES = new Set(['output', 'repo', 'archive']);
|
||||
const WINDOWS_SAFE_PROFILES = new Set(['output', 'archive']);
|
||||
const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/iu;
|
||||
const WINDOWS_SHORT_NAME = /~[1-9][0-9]*(?:\.|$)/iu;
|
||||
const URI_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/u;
|
||||
const CONTROL_CHARACTER = /\p{Cc}/u;
|
||||
const WINDOWS_INVALID_CHARACTER = /[<>"|?*]/u;
|
||||
const MAX_SEMANTIC_VARIANTS = 32;
|
||||
|
||||
export class PortablePathError extends Error {
|
||||
constructor(message, {
|
||||
code,
|
||||
reason,
|
||||
value,
|
||||
profile,
|
||||
segment,
|
||||
segmentIndex,
|
||||
character,
|
||||
index,
|
||||
conflictIndex,
|
||||
conflictValue,
|
||||
collisionKind,
|
||||
semantics,
|
||||
pathPart,
|
||||
conflictPathPart,
|
||||
utf8Bytes,
|
||||
utf16CodeUnits,
|
||||
limit,
|
||||
} = {}) {
|
||||
super(message);
|
||||
this.name = 'PortablePathError';
|
||||
this.code = code;
|
||||
this.reason = reason;
|
||||
this.value = value;
|
||||
this.profile = profile;
|
||||
if (segment !== undefined) this.segment = segment;
|
||||
if (segmentIndex !== undefined) this.segmentIndex = segmentIndex;
|
||||
if (character !== undefined) this.character = character;
|
||||
if (index !== undefined) this.index = index;
|
||||
if (conflictIndex !== undefined) this.conflictIndex = conflictIndex;
|
||||
if (conflictValue !== undefined) this.conflictValue = conflictValue;
|
||||
if (collisionKind !== undefined) this.collisionKind = collisionKind;
|
||||
if (semantics !== undefined) this.semantics = semantics;
|
||||
if (pathPart !== undefined) this.pathPart = pathPart;
|
||||
if (conflictPathPart !== undefined) this.conflictPathPart = conflictPathPart;
|
||||
if (utf8Bytes !== undefined) this.utf8Bytes = utf8Bytes;
|
||||
if (utf16CodeUnits !== undefined) this.utf16CodeUnits = utf16CodeUnits;
|
||||
if (limit !== undefined) this.limit = limit;
|
||||
}
|
||||
}
|
||||
|
||||
function pathError(value, profile, reason, message, details = {}) {
|
||||
return new PortablePathError(message, {
|
||||
code: `portable-path/${reason}`,
|
||||
reason,
|
||||
value,
|
||||
profile,
|
||||
...details,
|
||||
});
|
||||
}
|
||||
|
||||
function assertProfile(profile) {
|
||||
if (PORTABLE_PATH_PROFILES.has(profile)) return;
|
||||
throw pathError(
|
||||
undefined,
|
||||
profile,
|
||||
'profile',
|
||||
`Portable path profile must be one of: ${[...PORTABLE_PATH_PROFILES].join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function validatePortablePath(value, options = {}) {
|
||||
const profile = options?.profile;
|
||||
assertProfile(profile);
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
throw pathError(value, profile, 'type', 'Portable path must be a string.');
|
||||
}
|
||||
if (value.length === 0) {
|
||||
throw pathError(value, profile, 'empty', 'Portable path must not be empty.');
|
||||
}
|
||||
if (/^[A-Za-z]:[\\/]/u.test(value) || value.startsWith('/') || value.startsWith('\\')) {
|
||||
throw pathError(value, profile, 'absolute', 'Portable path must be relative.');
|
||||
}
|
||||
if (/^[A-Za-z]:/u.test(value)) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'drive-relative',
|
||||
'Portable path must not use a drive-relative Windows path.',
|
||||
);
|
||||
}
|
||||
if (URI_SCHEME.test(value)) {
|
||||
throw pathError(value, profile, 'uri', 'Portable path must not be a URI.');
|
||||
}
|
||||
if (value.includes('\\')) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'backslash',
|
||||
'Portable path must use forward slashes as separators.',
|
||||
);
|
||||
}
|
||||
|
||||
const segments = value.split('/');
|
||||
for (const [segmentIndex, segment] of segments.entries()) {
|
||||
const segmentDetails = { segment, segmentIndex };
|
||||
if (segment.length === 0) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'empty-segment',
|
||||
'Portable path must not contain empty segments.',
|
||||
segmentDetails,
|
||||
);
|
||||
}
|
||||
if (segment === '.' || segment === '..') {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'dot-segment',
|
||||
'Portable path must not contain dot segments.',
|
||||
segmentDetails,
|
||||
);
|
||||
}
|
||||
const controlMatch = segment.match(CONTROL_CHARACTER);
|
||||
if (controlMatch) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'control',
|
||||
'Portable path must not contain control characters.',
|
||||
{ ...segmentDetails, character: controlMatch[0] },
|
||||
);
|
||||
}
|
||||
const surrogateMatch = segment.match(/[\uD800-\uDFFF]/u);
|
||||
if (surrogateMatch) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'unpaired-surrogate',
|
||||
'Portable path must not contain unpaired UTF-16 surrogates.',
|
||||
{ ...segmentDetails, character: surrogateMatch[0] },
|
||||
);
|
||||
}
|
||||
|
||||
if (!WINDOWS_SAFE_PROFILES.has(profile)) continue;
|
||||
if (segment.includes(':')) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'windows-ads',
|
||||
'Portable path must not select a Windows alternate data stream.',
|
||||
{ ...segmentDetails, character: ':' },
|
||||
);
|
||||
}
|
||||
const invalidMatch = segment.match(WINDOWS_INVALID_CHARACTER);
|
||||
if (invalidMatch) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'windows-invalid-character',
|
||||
'Portable path contains a character that is invalid in Windows file names.',
|
||||
{ ...segmentDetails, character: invalidMatch[0] },
|
||||
);
|
||||
}
|
||||
if (/[. ]$/u.test(segment)) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'windows-trailing-dot-space',
|
||||
'Portable path segments must not end with a dot or space.',
|
||||
segmentDetails,
|
||||
);
|
||||
}
|
||||
if (WINDOWS_RESERVED_NAME.test(segment)) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'windows-reserved-name',
|
||||
'Portable path must not use a reserved Windows device name.',
|
||||
segmentDetails,
|
||||
);
|
||||
}
|
||||
if (WINDOWS_SHORT_NAME.test(segment)) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'windows-short-name',
|
||||
'Portable path must not use a Windows 8.3 short-name shape.',
|
||||
segmentDetails,
|
||||
);
|
||||
}
|
||||
const utf8Bytes = Buffer.byteLength(segment, 'utf8');
|
||||
const utf16CodeUnits = segment.length;
|
||||
if (utf8Bytes > 255 || utf16CodeUnits > 255) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'component-too-long',
|
||||
'Portable path segments must fit both UTF-8 and UTF-16 filesystem component limits.',
|
||||
{
|
||||
...segmentDetails,
|
||||
utf8Bytes,
|
||||
utf16CodeUnits,
|
||||
limit: 255,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function collisionError(values, profile, index, conflictIndex, reason, details = {}) {
|
||||
return new PortablePathError(
|
||||
`Portable paths at indexes ${conflictIndex} and ${index} collide under portable filesystem semantics.`,
|
||||
{
|
||||
code: 'portable-path/collision',
|
||||
reason,
|
||||
value: values[index],
|
||||
profile,
|
||||
index,
|
||||
conflictIndex,
|
||||
conflictValue: values[conflictIndex],
|
||||
...details,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createSemanticIndex() {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function semanticEnvelope(value, profile, index) {
|
||||
const variants = [value];
|
||||
const seen = new Set(variants);
|
||||
const transforms = [
|
||||
(candidate) => candidate.normalize('NFC'),
|
||||
(candidate) => candidate.normalize('NFD'),
|
||||
// path-contract-allow: portable-logical-path -- Archive names require conservative case-collision closure.
|
||||
(candidate) => candidate.toLocaleLowerCase('en-US'),
|
||||
// path-contract-allow: portable-logical-path -- Archive names require conservative case-collision closure.
|
||||
(candidate) => candidate.toLocaleUpperCase('en-US'),
|
||||
];
|
||||
|
||||
for (let cursor = 0; cursor < variants.length; cursor += 1) {
|
||||
for (const transform of transforms) {
|
||||
const transformed = transform(variants[cursor]);
|
||||
if (seen.has(transformed)) continue;
|
||||
if (variants.length >= MAX_SEMANTIC_VARIANTS) {
|
||||
throw pathError(
|
||||
value,
|
||||
profile,
|
||||
'semantic-expansion',
|
||||
'Portable path semantic comparison exceeded its bounded Unicode expansion.',
|
||||
{ index, limit: MAX_SEMANTIC_VARIANTS },
|
||||
);
|
||||
}
|
||||
seen.add(transformed);
|
||||
variants.push(transformed);
|
||||
}
|
||||
}
|
||||
|
||||
return variants;
|
||||
}
|
||||
|
||||
function classifySemanticCollision(value, conflictValue) {
|
||||
if (value === conflictValue) return 'exact';
|
||||
if (value.normalize('NFC') === conflictValue.normalize('NFC')) return 'normalization';
|
||||
|
||||
const directLowerMatch = value.toLocaleLowerCase('en-US')
|
||||
=== conflictValue.toLocaleLowerCase('en-US');
|
||||
const directUpperMatch = value.toLocaleUpperCase('en-US')
|
||||
=== conflictValue.toLocaleUpperCase('en-US');
|
||||
return directLowerMatch || directUpperMatch ? 'case' : 'case-and-normalization';
|
||||
}
|
||||
|
||||
function findSemanticCollision(semanticIndex, value, profile, sourceIndex) {
|
||||
const variants = semanticEnvelope(value, profile, sourceIndex);
|
||||
for (const variant of variants) {
|
||||
const entry = semanticIndex.get(variant);
|
||||
if (entry) {
|
||||
return {
|
||||
entry,
|
||||
variants,
|
||||
semantics: classifySemanticCollision(value, entry.value),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { entry: null, variants, semantics: null };
|
||||
}
|
||||
|
||||
function rememberSemanticEntry(semanticIndex, entry, variants) {
|
||||
for (const variant of variants) {
|
||||
if (!semanticIndex.has(variant)) semanticIndex.set(variant, entry);
|
||||
}
|
||||
}
|
||||
|
||||
export function validatePortablePathSet(values, options = {}) {
|
||||
const profile = options?.profile;
|
||||
assertProfile(profile);
|
||||
if (!Array.isArray(values)) {
|
||||
throw pathError(values, profile, 'set-type', 'Portable path set must be an array.');
|
||||
}
|
||||
|
||||
const leafEntries = createSemanticIndex();
|
||||
const directoryEntries = createSemanticIndex();
|
||||
|
||||
for (const [index, value] of values.entries()) {
|
||||
try {
|
||||
validatePortablePath(value, { profile });
|
||||
} catch (error) {
|
||||
if (error instanceof PortablePathError && error.index === undefined) error.index = index;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const leafCollision = findSemanticCollision(leafEntries, value, profile, index);
|
||||
if (leafCollision.entry) {
|
||||
const conflictIndex = leafCollision.entry.index;
|
||||
throw collisionError(
|
||||
values,
|
||||
profile,
|
||||
index,
|
||||
conflictIndex,
|
||||
leafCollision.semantics === 'exact' ? 'duplicate' : leafCollision.semantics,
|
||||
{
|
||||
collisionKind: 'entry',
|
||||
semantics: leafCollision.semantics,
|
||||
pathPart: value,
|
||||
conflictPathPart: leafCollision.entry.value,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const segments = value.split('/');
|
||||
const prefixes = segments.slice(0, -1).map((_, prefixIndex) => (
|
||||
segments.slice(0, prefixIndex + 1).join('/')
|
||||
));
|
||||
for (const prefix of prefixes) {
|
||||
const directoryCollision = findSemanticCollision(directoryEntries, prefix, profile, index);
|
||||
if (directoryCollision.entry && directoryCollision.semantics !== 'exact') {
|
||||
throw collisionError(
|
||||
values,
|
||||
profile,
|
||||
index,
|
||||
directoryCollision.entry.index,
|
||||
`directory-${directoryCollision.semantics}`,
|
||||
{
|
||||
collisionKind: 'directory-spelling',
|
||||
semantics: directoryCollision.semantics,
|
||||
pathPart: prefix,
|
||||
conflictPathPart: directoryCollision.entry.value,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const fileCollision = findSemanticCollision(leafEntries, prefix, profile, index);
|
||||
if (fileCollision.entry) {
|
||||
throw collisionError(
|
||||
values,
|
||||
profile,
|
||||
index,
|
||||
fileCollision.entry.index,
|
||||
fileCollision.semantics === 'exact'
|
||||
? 'tree-file'
|
||||
: `tree-file-${fileCollision.semantics}`,
|
||||
{
|
||||
collisionKind: 'tree-file',
|
||||
semantics: fileCollision.semantics,
|
||||
pathPart: prefix,
|
||||
conflictPathPart: fileCollision.entry.value,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const directoryCollision = findSemanticCollision(directoryEntries, value, profile, index);
|
||||
if (directoryCollision.entry) {
|
||||
throw collisionError(
|
||||
values,
|
||||
profile,
|
||||
index,
|
||||
directoryCollision.entry.index,
|
||||
directoryCollision.semantics === 'exact'
|
||||
? 'tree-file'
|
||||
: `tree-file-${directoryCollision.semantics}`,
|
||||
{
|
||||
collisionKind: 'tree-file',
|
||||
semantics: directoryCollision.semantics,
|
||||
pathPart: value,
|
||||
conflictPathPart: directoryCollision.entry.value,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
rememberSemanticEntry(leafEntries, { index, value }, leafCollision.variants);
|
||||
for (const prefix of prefixes) {
|
||||
rememberSemanticEntry(
|
||||
directoryEntries,
|
||||
{ index, value: prefix },
|
||||
semanticEnvelope(prefix, profile, index),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { throwDiagnosticError, withDiagnosticRecordingSuppressed } from './diagnostics.mjs';
|
||||
import { sameEntry } from './path-semantics.mjs';
|
||||
import { parseRepositoryRemote, redactRepositoryRemote, repositorySourceHref } from './repository-location.mjs';
|
||||
|
||||
const FULL_SHA_RE = /^[a-f0-9]{40}$/i;
|
||||
const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/;
|
||||
const MAX_SOURCE_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
function evidenceFailure(code, message, { subject = {}, evidence = {}, supportedFixes = [] } = {}) {
|
||||
throwDiagnosticError(message, [{
|
||||
code,
|
||||
severity: 'error',
|
||||
message,
|
||||
subject: { surface: 'repository-evidence', ...subject },
|
||||
evidence,
|
||||
supportedFixes,
|
||||
}]);
|
||||
}
|
||||
|
||||
function runGit(repoRoot, args) {
|
||||
// 固定 SHA 的来源必须读取原始对象,不能使用本地 replacement refs 的替换内容。
|
||||
const result = spawnSync('git', ['--no-replace-objects', '-C', repoRoot, ...args], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: MAX_SOURCE_BYTES,
|
||||
});
|
||||
if (result.error) evidenceFailure('repository-evidence/git-unavailable', `Could not run Git: ${result.error.message}`, {
|
||||
evidence: { reason: result.error.message },
|
||||
supportedFixes: ['install Git and ensure it is available on PATH'],
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check types in one session, then read only blobs whose cited lines need
|
||||
// verification. Path-only references never require loading the file contents.
|
||||
function prefetchBlobs(repoRoot, objectNeedsContent) {
|
||||
const blobs = readBatchObjects(repoRoot, [...objectNeedsContent.keys()], false);
|
||||
if (!blobs) return null;
|
||||
const readable = [...objectNeedsContent].filter(([object, needsContent]) => {
|
||||
const blob = blobs.get(object);
|
||||
return needsContent && blob?.type === 'blob' && blob.size <= MAX_SOURCE_BYTES;
|
||||
}).map(([object]) => object);
|
||||
const contents = readBatchObjects(repoRoot, readable, true);
|
||||
if (contents) for (const [object, blob] of contents) blobs.set(object, blob);
|
||||
// Failed or oversized reads fall back in source order to the original
|
||||
// per-file path, preserving its size limit and diagnostic behavior.
|
||||
return blobs;
|
||||
}
|
||||
|
||||
function readBatchObjects(repoRoot, objects, includeContent) {
|
||||
if (!objects.length) return new Map();
|
||||
const mode = includeContent ? '--batch' : '--batch-check';
|
||||
const result = spawnSync('git', ['--no-replace-objects', '-C', repoRoot, 'cat-file', mode], {
|
||||
input: objects.join('\n') + '\n',
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (result.error || result.status !== 0 || !Buffer.isBuffer(result.stdout)) return null;
|
||||
const buffer = result.stdout;
|
||||
const blobs = new Map();
|
||||
let cursor = 0;
|
||||
for (const object of objects) {
|
||||
const newline = buffer.indexOf(0x0a, cursor);
|
||||
if (newline < 0) return null;
|
||||
const header = buffer.toString('utf8', cursor, newline);
|
||||
cursor = newline + 1;
|
||||
if (header.endsWith(' missing')) {
|
||||
blobs.set(object, { missing: true });
|
||||
continue;
|
||||
}
|
||||
const parts = header.split(' ');
|
||||
const size = Number(parts[2]);
|
||||
if (parts.length !== 3 || !Number.isSafeInteger(size) || size < 0) return null;
|
||||
const blob = { type: parts[1], size };
|
||||
if (includeContent) {
|
||||
if (cursor + size >= buffer.length || buffer[cursor + size] !== 0x0a) return null;
|
||||
blob.content = buffer.toString('utf8', cursor, cursor + size);
|
||||
cursor += size + 1;
|
||||
}
|
||||
blobs.set(object, blob);
|
||||
}
|
||||
return blobs;
|
||||
}
|
||||
|
||||
function gitValue(repoRoot, args, failure) {
|
||||
const result = runGit(repoRoot, args);
|
||||
if (result.status !== 0) evidenceFailure('repository-evidence/git-command', failure, {
|
||||
evidence: { gitArgs: args, exitCode: result.status },
|
||||
supportedFixes: ['use the intended local Git repository and verify its origin and revision'],
|
||||
});
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function verifiedSourcePath(value, where) {
|
||||
const sourcePath = String(value || '');
|
||||
// path-contract-allow: git-path -- Git tree entries use repository-relative POSIX syntax.
|
||||
if (!sourcePath || sourcePath.startsWith('/') || sourcePath.includes('\\') || CONTROL_CHARACTER_RE.test(sourcePath)) {
|
||||
evidenceFailure('repository-evidence/path-invalid', `${where} must be a repo-relative POSIX path.`, {
|
||||
subject: { path: where },
|
||||
evidence: { authoredPath: sourcePath },
|
||||
supportedFixes: ['use a repository-relative path with forward slashes'],
|
||||
});
|
||||
}
|
||||
const segments = sourcePath.split('/');
|
||||
if (segments.some((segment) => !segment || segment === '.' || segment === '..') || segments[0] === '.git') {
|
||||
evidenceFailure('repository-evidence/path-escape', `${where} must stay inside the repository and may not address .git.`, {
|
||||
subject: { path: where },
|
||||
evidence: { authoredPath: sourcePath },
|
||||
supportedFixes: ['remove empty, dot, parent, or .git path segments'],
|
||||
});
|
||||
}
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
function sourceLineCount(content) {
|
||||
if (!content.length) return 0;
|
||||
const lines = content.split(/\r\n|\n|\r/);
|
||||
return lines.length - (/(?:\r\n|\n|\r)$/.test(content) ? 1 : 0);
|
||||
}
|
||||
|
||||
// Every diagram type carries its nodes under a different property name, and
|
||||
// source evidence is authored on those nodes. One table keeps the verification
|
||||
// below identical for all five types instead of branching per type: the only
|
||||
// per-type fact is which array to read and which JSON pointer to quote back.
|
||||
const EVIDENCE_NODE_COLLECTIONS = {
|
||||
architecture: 'components',
|
||||
workflow: 'nodes',
|
||||
sequence: 'participants',
|
||||
dataflow: 'nodes',
|
||||
lifecycle: 'states',
|
||||
};
|
||||
|
||||
function evidenceNodes(diagramType, diagram) {
|
||||
const collection = EVIDENCE_NODE_COLLECTIONS[diagramType];
|
||||
if (!collection) return null;
|
||||
return { collection, nodes: Array.isArray(diagram?.[collection]) ? diagram[collection] : [] };
|
||||
}
|
||||
|
||||
export function hasRepositoryEvidence(diagramType, diagram) {
|
||||
const authored = evidenceNodes(diagramType, diagram);
|
||||
if (!authored) return false;
|
||||
return Boolean(diagram?.meta?.repository) || authored.nodes.some((node) => Array.isArray(node?.sources) && node.sources.length);
|
||||
}
|
||||
|
||||
export function verifyRepositoryEvidence(diagramType, diagram, repoRootInput) {
|
||||
if (!hasRepositoryEvidence(diagramType, diagram)) return null;
|
||||
const { collection, nodes: authoredNodes } = evidenceNodes(diagramType, diagram);
|
||||
|
||||
const repository = diagram.meta?.repository;
|
||||
if (!repository) evidenceFailure('repository-evidence/repository-required', 'Repository evidence requires /meta/repository.', {
|
||||
subject: { path: '/meta/repository', diagramType, collection },
|
||||
supportedFixes: [`add the pinned repository metadata or remove /${collection} sources`],
|
||||
});
|
||||
if (!FULL_SHA_RE.test(repository.revision || '')) {
|
||||
evidenceFailure('repository-evidence/revision-invalid', '/meta/repository/revision must be a full 40-character commit SHA.', {
|
||||
subject: { path: '/meta/repository/revision' },
|
||||
evidence: { revision: repository.revision },
|
||||
supportedFixes: ['pin one full 40-character commit SHA'],
|
||||
});
|
||||
}
|
||||
const location = parseRepositoryRemote(repository.url, { authored: true });
|
||||
if (!location) {
|
||||
// A filesystem path is the common authoring mistake: the field carries the
|
||||
// remote origin identity, which `git remote get-url origin` reports.
|
||||
const filesystemPath = /^(?:[\\/]|~|\.{1,2}(?:[\\/]|$)|[A-Za-z]:[\\/])/.test(String(repository.url ?? ''));
|
||||
evidenceFailure('repository-evidence/url-invalid', '/meta/repository/url must be a credential-free HTTP(S) or Git SSH repository address without query, fragment, or dot segments.', {
|
||||
subject: { path: '/meta/repository/url' },
|
||||
evidence: filesystemPath ? { authoredValueLooksLike: 'local filesystem path; the expected value is the remote origin address' } : {},
|
||||
supportedFixes: ['run `git remote get-url origin` inside --repo-root and declare that credential-free address', 'use link_mode: local-only for internal repositories'],
|
||||
});
|
||||
}
|
||||
const linkMode = repository.link_mode ?? 'web';
|
||||
if (!['web', 'local-only'].includes(linkMode)) evidenceFailure('repository-evidence/link-mode-invalid', 'Repository link_mode must be web or local-only.');
|
||||
if (repository.provider !== undefined && (!['github', 'gitee'].includes(repository.provider) || repository.provider !== location.provider)) {
|
||||
evidenceFailure('repository-evidence/provider-invalid', 'Repository provider must match its supported public host (github.com or gitee.com).', {
|
||||
subject: { path: '/meta/repository/provider' },
|
||||
supportedFixes: ['use the matching provider or omit provider and select link_mode: local-only'],
|
||||
});
|
||||
}
|
||||
if (linkMode === 'web' && (!location.provider || location.protocol !== 'https:' || location.endpoint !== 'standard' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(location.path))) {
|
||||
evidenceFailure('repository-evidence/links-unsupported', 'Web source links require a canonical GitHub or Gitee HTTPS owner/repository URL.', {
|
||||
subject: { path: '/meta/repository/url' },
|
||||
supportedFixes: ['use a canonical GitHub or Gitee URL, or select link_mode: local-only to retain local verification without web links'],
|
||||
});
|
||||
}
|
||||
if (!repoRootInput) {
|
||||
evidenceFailure('repository-evidence/root-required', 'This diagram declares source evidence. Pass --repo-root <repository> so Archify can verify it before rendering.', {
|
||||
subject: { path: '/meta/repository' },
|
||||
supportedFixes: ['pass --repo-root with the matching local Git checkout'],
|
||||
});
|
||||
}
|
||||
|
||||
const requestedRoot = path.resolve(repoRootInput);
|
||||
let realRoot;
|
||||
try {
|
||||
realRoot = fs.realpathSync(requestedRoot);
|
||||
} catch (error) {
|
||||
evidenceFailure('repository-evidence/root-unreadable', `Could not resolve evidence repository root "${requestedRoot}": ${error.message}`, {
|
||||
subject: { repoRoot: requestedRoot },
|
||||
evidence: { reason: error.message },
|
||||
supportedFixes: ['pass one readable local repository directory'],
|
||||
});
|
||||
}
|
||||
const gitRoot = gitValue(realRoot, ['rev-parse', '--show-toplevel'], `Evidence root "${realRoot}" is not a Git repository.`);
|
||||
const rootIdentity = sameEntry(realRoot, gitRoot);
|
||||
if (rootIdentity.status === 'unknown') {
|
||||
evidenceFailure('repository-evidence/root-identity-indeterminate', 'Could not determine whether the evidence root is the Git top-level directory.', {
|
||||
subject: { repoRoot: realRoot },
|
||||
evidence: { gitTopLevel: gitRoot, relation: rootIdentity.reason },
|
||||
supportedFixes: ['pass the readable Git top-level directory using its canonical filesystem path'],
|
||||
});
|
||||
}
|
||||
if (rootIdentity.status === 'different') {
|
||||
evidenceFailure('repository-evidence/root-not-top-level', `Evidence root must be the Git top-level directory: ${gitRoot}`, {
|
||||
subject: { repoRoot: realRoot },
|
||||
evidence: { gitTopLevel: gitRoot },
|
||||
supportedFixes: [`pass --repo-root ${gitRoot}`],
|
||||
});
|
||||
}
|
||||
const origin = gitValue(realRoot, ['remote', 'get-url', 'origin'], 'Evidence repository must have an origin remote.');
|
||||
if (parseRepositoryRemote(origin)?.identity !== location.identity) {
|
||||
const safeOrigin = redactRepositoryRemote(origin);
|
||||
evidenceFailure('repository-evidence/origin-mismatch', `Evidence repository origin ${JSON.stringify(safeOrigin)} does not match ${JSON.stringify(repository.url)}.`, {
|
||||
subject: { repoRoot: realRoot },
|
||||
evidence: { localOrigin: safeOrigin, authoredRepository: repository.url },
|
||||
supportedFixes: ['use the matching local checkout or correct the authored repository URL'],
|
||||
});
|
||||
}
|
||||
|
||||
const revision = repository.revision.toLowerCase();
|
||||
const commit = runGit(realRoot, ['cat-file', '-e', `${revision}^{commit}`]);
|
||||
if (commit.status !== 0) {
|
||||
evidenceFailure('repository-evidence/revision-unavailable', `Evidence revision ${revision} is not available in the local repository.`, {
|
||||
subject: { repoRoot: realRoot },
|
||||
evidence: { revision },
|
||||
supportedFixes: ['fetch the pinned commit or pin an available full commit SHA'],
|
||||
});
|
||||
}
|
||||
|
||||
// The batch is an optimization only: every path, line-range, file and line
|
||||
// check still runs in source order in the verification loop below, so a
|
||||
// citation the batch cannot answer for never reorders the first diagnostic.
|
||||
const citedObjects = new Map();
|
||||
for (const [nodeIndex, node] of authoredNodes.entries()) {
|
||||
if (!Array.isArray(node.sources) || node.sources.length === 0) continue;
|
||||
for (const [sourceIndex, authored] of node.sources.entries()) {
|
||||
const at = `/${collection}/${nodeIndex}/sources/${sourceIndex}`;
|
||||
let sourcePath;
|
||||
try {
|
||||
sourcePath = withDiagnosticRecordingSuppressed(() => verifiedSourcePath(authored.path, `${at}/path`));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const object = `${revision}:${sourcePath}`;
|
||||
citedObjects.set(object, citedObjects.get(object) || Boolean(authored.line));
|
||||
}
|
||||
}
|
||||
const prefetchedBlobs = prefetchBlobs(realRoot, citedObjects);
|
||||
|
||||
const nodes = Object.create(null);
|
||||
let referenceCount = 0;
|
||||
for (const [nodeIndex, node] of authoredNodes.entries()) {
|
||||
if (!Array.isArray(node.sources) || node.sources.length === 0) continue;
|
||||
// `componentId` shipped with the architecture-only path; keep it beside the
|
||||
// type-neutral `nodeId` so existing agent handling stays valid.
|
||||
const nodeSubject = collection === 'components'
|
||||
? { diagramType, collection, nodeId: node.id, componentId: node.id }
|
||||
: { diagramType, collection, nodeId: node.id };
|
||||
const verified = [];
|
||||
for (const [sourceIndex, authored] of node.sources.entries()) {
|
||||
const at = `/${collection}/${nodeIndex}/sources/${sourceIndex}`;
|
||||
const where = `${at}/path`;
|
||||
const source = {
|
||||
path: verifiedSourcePath(authored.path, where),
|
||||
...(authored.line ? { line: authored.line } : {}),
|
||||
...(authored.end_line ? { endLine: authored.end_line } : {}),
|
||||
...(authored.label ? { label: authored.label } : {}),
|
||||
};
|
||||
if (source.endLine && !source.line) {
|
||||
evidenceFailure('repository-evidence/line-required', `${at}/end_line requires line.`, {
|
||||
subject: { path: `${at}/end_line`, ...nodeSubject },
|
||||
supportedFixes: ['add line or remove end_line'],
|
||||
});
|
||||
}
|
||||
if (source.endLine && source.endLine < source.line) {
|
||||
evidenceFailure('repository-evidence/line-range-invalid', `${at}/end_line must be greater than or equal to line.`, {
|
||||
subject: { path: at, ...nodeSubject },
|
||||
evidence: { line: source.line, endLine: source.endLine },
|
||||
supportedFixes: ['use an end_line greater than or equal to line'],
|
||||
});
|
||||
}
|
||||
const object = `${revision}:${source.path}`;
|
||||
const prefetched = prefetchedBlobs ? prefetchedBlobs.get(object) : undefined;
|
||||
const objectIsBlob = prefetched
|
||||
? !prefetched.missing && prefetched.type === 'blob'
|
||||
: (() => {
|
||||
const type = runGit(realRoot, ['cat-file', '-t', object]);
|
||||
return type.status === 0 && type.stdout.trim() === 'blob';
|
||||
})();
|
||||
if (!objectIsBlob) {
|
||||
evidenceFailure('repository-evidence/file-missing', `${where} does not identify a file at revision ${revision}.`, {
|
||||
subject: { path: where, ...nodeSubject },
|
||||
evidence: { sourcePath: source.path, revision },
|
||||
supportedFixes: ['use a file path that exists at the pinned revision'],
|
||||
});
|
||||
}
|
||||
if (source.line) {
|
||||
const content = prefetched && Object.hasOwn(prefetched, 'content')
|
||||
? { status: 0, stdout: prefetched.content }
|
||||
: runGit(realRoot, ['show', object]);
|
||||
if (content.status !== 0) evidenceFailure('repository-evidence/file-unreadable', `${where} could not be read at revision ${revision}.`, {
|
||||
subject: { path: where, ...nodeSubject },
|
||||
evidence: { sourcePath: source.path, revision },
|
||||
supportedFixes: ['verify the pinned blob is readable in the local checkout'],
|
||||
});
|
||||
const lineCount = sourceLineCount(content.stdout);
|
||||
const requestedLine = source.endLine || source.line;
|
||||
if (requestedLine > lineCount) {
|
||||
evidenceFailure('repository-evidence/line-out-of-range', `${at} requests line ${requestedLine}, but ${source.path} has ${lineCount} lines at revision ${revision}.`, {
|
||||
subject: { path: at, ...nodeSubject },
|
||||
evidence: { sourcePath: source.path, requestedLine, lineCount, revision },
|
||||
supportedFixes: ['use a line range that exists at the pinned revision'],
|
||||
});
|
||||
}
|
||||
}
|
||||
verified.push({ ...source, ...(linkMode === 'web' ? { href: repositorySourceHref(location.provider, location.url, revision, source) } : {}) });
|
||||
referenceCount += 1;
|
||||
}
|
||||
nodes[node.id] = verified;
|
||||
}
|
||||
if (referenceCount === 0) {
|
||||
evidenceFailure('repository-evidence/source-required', `/meta/repository requires at least one /${collection} source reference.`, {
|
||||
subject: { path: '/meta/repository', diagramType, collection },
|
||||
supportedFixes: [`add at least one verified /${collection} source or remove repository metadata`],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
verified: true,
|
||||
repository: {
|
||||
url: location.url,
|
||||
revision,
|
||||
shortRevision: revision.slice(0, 7),
|
||||
label: location.provider === 'github' ? location.path : location.url.replace(/^(?:https?:\/\/|ssh:\/\/git@|git@)/, ''),
|
||||
...(linkMode === 'web' ? { href: `${location.url}/tree/${revision}` } : { linkMode }),
|
||||
},
|
||||
referenceCount,
|
||||
nodes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Repository identity and forge links are independent of local Git object checks.
|
||||
// This module never contacts a remote server or reads the user's SSH config.
|
||||
export function parseRepositoryRemote(value, { authored = false } = {}) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const raw = authored ? value : value.trim();
|
||||
if (!raw || /[\s\\\u0000-\u001f\u007f?#]/.test(raw)) return null;
|
||||
const scp = raw.match(/^git@([^/:]+):(.+)$/);
|
||||
const scpAbsolute = Boolean(scp && scp[2].startsWith('/'));
|
||||
const expanded = scp ? `ssh://git@${scp[1]}/${scp[2].replace(/^\//, '')}` : raw;
|
||||
const match = expanded.match(/^(https?|ssh):\/\/([^/]+)\/(.+)$/i);
|
||||
if (!match) return null;
|
||||
// Validate the original path before URL parsing can collapse dot segments.
|
||||
let segments;
|
||||
try {
|
||||
segments = match[3].replace(/\/$/, '').split('/');
|
||||
// Git passes SCP paths literally; percent escapes are decoded only in URIs.
|
||||
if (!scp) segments = segments.map(decodeURIComponent);
|
||||
}
|
||||
catch { return null; }
|
||||
if (segments.some((part) => !part || part === '.' || part === '..' || /[/\\\s\u0000-\u001f\u007f?#]/.test(part))) return null;
|
||||
let url;
|
||||
try { url = new URL(expanded); } catch { return null; }
|
||||
const protocol = url.protocol;
|
||||
if (protocol === 'ssh:' && (url.username !== 'git' || url.password)) return null;
|
||||
if (authored && protocol !== 'ssh:' && (url.username || url.password)) return null;
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
if (!hostname) return null;
|
||||
const provider = hostname === 'github.com' ? 'github' : hostname === 'gitee.com' ? 'gitee' : null;
|
||||
const last = segments.length - 1;
|
||||
if (provider) segments[last] = segments[last].replace(provider === 'github' ? /\.git$/i : /\.git$/, '');
|
||||
if (!segments[last] || segments[last] === '.' || segments[last] === '..') return null;
|
||||
const repositoryPath = segments.join('/');
|
||||
// Only known forges map HTTPS and SSH to one repository namespace. Other
|
||||
// hosts retain transport, port and remote-relative/absolute path semantics.
|
||||
const port = url.port || (protocol === 'ssh:' ? '22' : protocol === 'https:' ? '443' : '80');
|
||||
const endpoint = provider && ((protocol === 'https:' && port === '443') || (protocol === 'ssh:' && port === '22'))
|
||||
? 'standard' : `${protocol}${port}`;
|
||||
const pathKind = provider ? 'repository' : scp && !scpAbsolute ? 'relative' : 'absolute';
|
||||
// path-contract-allow: url-path -- GitHub repository names are case-insensitive URL identities.
|
||||
const identityPath = provider === 'github' ? repositoryPath.toLowerCase() : repositoryPath;
|
||||
const encodedPath = segments.map(encodeURIComponent).join('/');
|
||||
const canonicalUrl = scp ? `git@${hostname}:${scpAbsolute ? '/' : ''}${repositoryPath}`
|
||||
: `${protocol}//${protocol === 'ssh:' ? 'git@' : ''}${url.host}/${encodedPath}`;
|
||||
return { identity: JSON.stringify([hostname, endpoint, pathKind, identityPath]), url: canonicalUrl, provider, protocol, path: repositoryPath, endpoint };
|
||||
}
|
||||
|
||||
export function redactRepositoryRemote(value) {
|
||||
return String(value || '')
|
||||
.replace(/^((?:https?|ssh):\/\/)[^/]*@/i, '$1REDACTED@')
|
||||
.replace(/[?#].*$/s, '?REDACTED');
|
||||
}
|
||||
|
||||
export function repositorySourceHref(provider, url, revision, source) {
|
||||
const encodedPath = source.path.split('/').map(encodeURIComponent).join('/');
|
||||
const end = source.endLine && source.endLine !== source.line
|
||||
? `-${provider === 'github' ? 'L' : ''}${source.endLine}` : '';
|
||||
const fragment = source.line ? `#L${source.line}${end}` : '';
|
||||
return `${url}/blob/${revision}/${encodedPath}${fragment}`;
|
||||
}
|
||||
+614
@@ -0,0 +1,614 @@
|
||||
import { recordDiagnostic } from './diagnostics.mjs';
|
||||
import {
|
||||
asArray,
|
||||
isFinitePoint,
|
||||
normalizeRoutePoints,
|
||||
properSegmentIntersection,
|
||||
segmentIntersectsRect,
|
||||
} from './geometry.mjs';
|
||||
|
||||
const DEFAULTS = Object.freeze({
|
||||
clearance: 2,
|
||||
minimumDetourRatio: 2.5,
|
||||
minimumExcessLengthPx: 200,
|
||||
minimumEmptyExcursionPx: 96,
|
||||
maximumObstacleCount: 80,
|
||||
sharedCorridorMinimumPx: 32,
|
||||
});
|
||||
|
||||
const OUTWARD = Object.freeze({
|
||||
left: [-1, 0],
|
||||
right: [1, 0],
|
||||
top: [0, -1],
|
||||
bottom: [0, 1],
|
||||
});
|
||||
|
||||
function rounded(value) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function pointKey(point) {
|
||||
return `${point[0]}\u0000${point[1]}`;
|
||||
}
|
||||
|
||||
class MinHeap {
|
||||
constructor() {
|
||||
this.entries = [];
|
||||
}
|
||||
|
||||
push(key, distance) {
|
||||
const entry = { key, distance };
|
||||
this.entries.push(entry);
|
||||
let index = this.entries.length - 1;
|
||||
while (index > 0) {
|
||||
const parent = Math.floor((index - 1) / 2);
|
||||
if (this.entries[parent].distance <= distance) break;
|
||||
this.entries[index] = this.entries[parent];
|
||||
index = parent;
|
||||
}
|
||||
this.entries[index] = entry;
|
||||
}
|
||||
|
||||
pop() {
|
||||
if (!this.entries.length) return null;
|
||||
const first = this.entries[0];
|
||||
const last = this.entries.pop();
|
||||
if (!this.entries.length) return first;
|
||||
let index = 0;
|
||||
while (true) {
|
||||
const left = index * 2 + 1;
|
||||
const right = left + 1;
|
||||
if (left >= this.entries.length) break;
|
||||
const child = right < this.entries.length
|
||||
&& this.entries[right].distance < this.entries[left].distance ? right : left;
|
||||
if (this.entries[child].distance >= last.distance) break;
|
||||
this.entries[index] = this.entries[child];
|
||||
index = child;
|
||||
}
|
||||
this.entries[index] = last;
|
||||
return first;
|
||||
}
|
||||
}
|
||||
|
||||
function orthogonalLength(points) {
|
||||
let total = 0;
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
const [x1, y1] = points[index];
|
||||
const [x2, y2] = points[index + 1];
|
||||
if (x1 !== x2 && y1 !== y2) return null;
|
||||
total += Math.abs(x2 - x1) + Math.abs(y2 - y1);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function inferredSide(points, endpoint) {
|
||||
if (points.length < 2) return null;
|
||||
const start = endpoint === 'source' ? points[0] : points.at(-2);
|
||||
const end = endpoint === 'source' ? points[1] : points.at(-1);
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
if (endpoint === 'source') {
|
||||
if (dx > 0 && dy === 0) return 'right';
|
||||
if (dx < 0 && dy === 0) return 'left';
|
||||
if (dy > 0 && dx === 0) return 'bottom';
|
||||
if (dy < 0 && dx === 0) return 'top';
|
||||
} else {
|
||||
if (dx > 0 && dy === 0) return 'left';
|
||||
if (dx < 0 && dy === 0) return 'right';
|
||||
if (dy > 0 && dx === 0) return 'top';
|
||||
if (dy < 0 && dx === 0) return 'bottom';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function moveOutward(point, side, distance) {
|
||||
const [dx, dy] = OUTWARD[side] || [0, 0];
|
||||
return [point[0] + dx * distance, point[1] + dy * distance];
|
||||
}
|
||||
|
||||
function expandedRect(rect, clearance) {
|
||||
return {
|
||||
id: rect.id,
|
||||
x: rect.x - clearance,
|
||||
y: rect.y - clearance,
|
||||
width: rect.width + clearance * 2,
|
||||
height: rect.height + clearance * 2,
|
||||
};
|
||||
}
|
||||
|
||||
function boundsForRects(rects) {
|
||||
const usable = [...rects].filter((rect) => (
|
||||
rect && isFinitePoint(rect.x, rect.y, rect.width, rect.height)
|
||||
&& rect.width >= 0 && rect.height >= 0
|
||||
));
|
||||
if (!usable.length) return null;
|
||||
const left = Math.min(...usable.map((rect) => rect.x));
|
||||
const top = Math.min(...usable.map((rect) => rect.y));
|
||||
const right = Math.max(...usable.map((rect) => rect.x + rect.width));
|
||||
const bottom = Math.max(...usable.map((rect) => rect.y + rect.height));
|
||||
return { left, top, right, bottom, width: right - left, height: bottom - top };
|
||||
}
|
||||
|
||||
function boundsForPoints(points) {
|
||||
if (!points.length) return null;
|
||||
const xs = points.map(([x]) => x);
|
||||
const ys = points.map(([, y]) => y);
|
||||
const left = Math.min(...xs);
|
||||
const top = Math.min(...ys);
|
||||
const right = Math.max(...xs);
|
||||
const bottom = Math.max(...ys);
|
||||
return { left, top, right, bottom, width: right - left, height: bottom - top };
|
||||
}
|
||||
|
||||
function outsideExcursion(routeBounds, contentBounds) {
|
||||
if (!routeBounds || !contentBounds) return null;
|
||||
const sides = {
|
||||
left: Math.max(0, contentBounds.left - routeBounds.left),
|
||||
top: Math.max(0, contentBounds.top - routeBounds.top),
|
||||
right: Math.max(0, routeBounds.right - contentBounds.right),
|
||||
bottom: Math.max(0, routeBounds.bottom - contentBounds.bottom),
|
||||
};
|
||||
return { ...sides, maximum: Math.max(...Object.values(sides)) };
|
||||
}
|
||||
|
||||
function pointDistanceFromRect(point, rect) {
|
||||
const dx = Math.max(rect.x - point[0], 0, point[0] - (rect.x + rect.width));
|
||||
const dy = Math.max(rect.y - point[1], 0, point[1] - (rect.y + rect.height));
|
||||
return dx + dy;
|
||||
}
|
||||
|
||||
function emptyControlPointClearance(points, contentRects) {
|
||||
const controls = points.slice(1, -1);
|
||||
const rects = [...contentRects].filter((rect) => (
|
||||
rect && isFinitePoint(rect.x, rect.y, rect.width, rect.height)
|
||||
&& rect.width >= 0 && rect.height >= 0
|
||||
));
|
||||
if (!controls.length || !rects.length) return null;
|
||||
const distances = controls.map((point) => Math.min(
|
||||
...rects.map((rect) => pointDistanceFromRect(point, rect)),
|
||||
));
|
||||
const maximum = Math.max(...distances);
|
||||
return { maximum, point: controls[distances.indexOf(maximum)] };
|
||||
}
|
||||
|
||||
function pointBlocked(point, obstacles) {
|
||||
return obstacles.some((rect) => (
|
||||
point[0] >= rect.x && point[0] <= rect.x + rect.width
|
||||
&& point[1] >= rect.y && point[1] <= rect.y + rect.height
|
||||
));
|
||||
}
|
||||
|
||||
function segmentBlocked(start, end, obstacles) {
|
||||
return obstacles.some((rect) => segmentIntersectsRect({ start, end }, rect));
|
||||
}
|
||||
|
||||
function segmentConflictsWithAvoided(start, end, avoidedSegments, minimumOverlapPx, allowCrossings) {
|
||||
return avoidedSegments.some((segment) => (
|
||||
(!allowCrossings && (properSegmentIntersection(start, end, segment.start, segment.end)
|
||||
|| orthogonalTouchOnAvoidedInterior(start, end, segment.start, segment.end)))
|
||||
|| collinearOverlap(start, end, segment.start, segment.end) >= minimumOverlapPx
|
||||
));
|
||||
}
|
||||
|
||||
function orthogonalTouchOnAvoidedInterior(start, end, avoidedStart, avoidedEnd) {
|
||||
const epsilon = 0.0001;
|
||||
const candidateHorizontal = Math.abs(start[1] - end[1]) <= epsilon;
|
||||
const candidateVertical = Math.abs(start[0] - end[0]) <= epsilon;
|
||||
const avoidedHorizontal = Math.abs(avoidedStart[1] - avoidedEnd[1]) <= epsilon;
|
||||
const avoidedVertical = Math.abs(avoidedStart[0] - avoidedEnd[0]) <= epsilon;
|
||||
if (candidateHorizontal && avoidedVertical) {
|
||||
const x = avoidedStart[0];
|
||||
const y = start[1];
|
||||
return x >= Math.min(start[0], end[0]) - epsilon
|
||||
&& x <= Math.max(start[0], end[0]) + epsilon
|
||||
&& y > Math.min(avoidedStart[1], avoidedEnd[1]) + epsilon
|
||||
&& y < Math.max(avoidedStart[1], avoidedEnd[1]) - epsilon;
|
||||
}
|
||||
if (candidateVertical && avoidedHorizontal) {
|
||||
const x = start[0];
|
||||
const y = avoidedStart[1];
|
||||
return y >= Math.min(start[1], end[1]) - epsilon
|
||||
&& y <= Math.max(start[1], end[1]) + epsilon
|
||||
&& x > Math.min(avoidedStart[0], avoidedEnd[0]) + epsilon
|
||||
&& x < Math.max(avoidedStart[0], avoidedEnd[0]) - epsilon;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function pointOnSegmentInterior(point, start, end) {
|
||||
const epsilon = 0.0001;
|
||||
const cross = (end[0] - start[0]) * (point[1] - start[1])
|
||||
- (end[1] - start[1]) * (point[0] - start[0]);
|
||||
if (Math.abs(cross) > epsilon) return false;
|
||||
const dot = (point[0] - start[0]) * (point[0] - end[0])
|
||||
+ (point[1] - start[1]) * (point[1] - end[1]);
|
||||
return dot < -epsilon;
|
||||
}
|
||||
|
||||
function pointOnAvoidedInterior(point, avoidedSegments) {
|
||||
return avoidedSegments.some((segment) => (
|
||||
pointOnSegmentInterior(point, segment.start, segment.end)
|
||||
));
|
||||
}
|
||||
|
||||
function writeGridMetrics(metrics, patch) {
|
||||
if (!metrics || typeof metrics !== 'object') return;
|
||||
Object.assign(metrics, patch);
|
||||
}
|
||||
|
||||
export function shortestOrthogonalGridRoute({
|
||||
start,
|
||||
end,
|
||||
points,
|
||||
obstacles,
|
||||
fromSide,
|
||||
toSide,
|
||||
clearance,
|
||||
maximumObstacleCount,
|
||||
endpointStubPx = clearance + 2,
|
||||
maximumGridNodes = Infinity,
|
||||
avoidedSegments = [],
|
||||
allowAvoidedCrossings = false,
|
||||
minimumAvoidedOverlapPx = 8,
|
||||
routeSeparationPx = 8,
|
||||
minimumSegmentPx = 8,
|
||||
borderSegments = [],
|
||||
bendPenaltyPx = 0,
|
||||
metrics,
|
||||
}) {
|
||||
writeGridMetrics(metrics, {
|
||||
status: 'initializing',
|
||||
maximumGridNodes,
|
||||
obstacleCount: 0,
|
||||
avoidedSegmentCount: 0,
|
||||
coordinateCount: 0,
|
||||
candidateNodeCount: 0,
|
||||
usableNodeCount: 0,
|
||||
graphEdgeCount: 0,
|
||||
visitedNodeCount: 0,
|
||||
});
|
||||
if (!OUTWARD[fromSide] || !OUTWARD[toSide]) {
|
||||
writeGridMetrics(metrics, { status: 'unsupported-endpoint-side' });
|
||||
return null;
|
||||
}
|
||||
const startStub = moveOutward(start, fromSide, endpointStubPx);
|
||||
const endStub = moveOutward(end, toSide, endpointStubPx);
|
||||
// The graph may legally leave the initial endpoint bounds to find a clear
|
||||
// corridor. Keep every bounded obstacle and occupied relationship visible
|
||||
// to that search; filtering them against the initial box lets a detour walk
|
||||
// straight through geometry that only becomes relevant after it leaves the
|
||||
// box. The explicit obstacle/node budgets below keep this deterministic.
|
||||
const expanded = [...obstacles]
|
||||
.filter((rect) => rect && isFinitePoint(rect.x, rect.y, rect.width, rect.height))
|
||||
.map((rect) => expandedRect(rect, clearance));
|
||||
const relevantAvoidedSegments = [...avoidedSegments]
|
||||
.filter((segment) => segment?.start && segment?.end);
|
||||
// Frame borders may be crossed perpendicularly but never borrowed as a
|
||||
// corridor: the composition gate rejects any collinear run along them.
|
||||
const relevantBorderSegments = [...borderSegments]
|
||||
.filter((segment) => segment?.start && segment?.end);
|
||||
writeGridMetrics(metrics, {
|
||||
obstacleCount: expanded.length,
|
||||
avoidedSegmentCount: relevantAvoidedSegments.length,
|
||||
});
|
||||
if (expanded.length > maximumObstacleCount) {
|
||||
writeGridMetrics(metrics, { status: 'obstacle-budget-exceeded' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const xs = new Set([startStub[0], endStub[0], ...points.map(([x]) => x)]);
|
||||
const ys = new Set([startStub[1], endStub[1], ...points.map(([, y]) => y)]);
|
||||
for (const rect of expanded) {
|
||||
xs.add(rect.x - 1);
|
||||
xs.add(rect.x + rect.width + 1);
|
||||
ys.add(rect.y - 1);
|
||||
ys.add(rect.y + rect.height + 1);
|
||||
}
|
||||
for (const segment of relevantAvoidedSegments) {
|
||||
const [segmentStart, segmentEnd] = [segment.start, segment.end];
|
||||
xs.add(segmentStart[0]);
|
||||
xs.add(segmentEnd[0]);
|
||||
ys.add(segmentStart[1]);
|
||||
ys.add(segmentEnd[1]);
|
||||
if (Math.abs(segmentStart[0] - segmentEnd[0]) <= 0.0001) {
|
||||
xs.add(segmentStart[0] - routeSeparationPx);
|
||||
xs.add(segmentStart[0] + routeSeparationPx);
|
||||
}
|
||||
if (Math.abs(segmentStart[1] - segmentEnd[1]) <= 0.0001) {
|
||||
ys.add(segmentStart[1] - routeSeparationPx);
|
||||
ys.add(segmentStart[1] + routeSeparationPx);
|
||||
}
|
||||
}
|
||||
for (const segment of relevantBorderSegments) {
|
||||
if (Math.abs(segment.start[0] - segment.end[0]) <= 0.0001) {
|
||||
xs.add(segment.start[0] - routeSeparationPx);
|
||||
xs.add(segment.start[0] + routeSeparationPx);
|
||||
}
|
||||
if (Math.abs(segment.start[1] - segment.end[1]) <= 0.0001) {
|
||||
ys.add(segment.start[1] - routeSeparationPx);
|
||||
ys.add(segment.start[1] + routeSeparationPx);
|
||||
}
|
||||
}
|
||||
// Grid lines closer than a readable segment would let the search emit a
|
||||
// micro jog between two obstacle edges; keep the endpoint stubs and coalesce
|
||||
// the rest so every turn the route can take is at least one segment long.
|
||||
const coalesce = (values, keep) => values.sort((a, b) => a - b).filter((value, index, sorted) => (
|
||||
index === 0 || keep.has(value) || value - sorted[index - 1] >= minimumSegmentPx
|
||||
));
|
||||
const orderedX = coalesce([...xs], new Set([startStub[0], endStub[0]]));
|
||||
const orderedY = coalesce([...ys], new Set([startStub[1], endStub[1]]));
|
||||
const candidateNodeCount = orderedX.length * orderedY.length;
|
||||
writeGridMetrics(metrics, {
|
||||
coordinateCount: orderedX.length + orderedY.length,
|
||||
candidateNodeCount,
|
||||
});
|
||||
if (candidateNodeCount > maximumGridNodes) {
|
||||
writeGridMetrics(metrics, { status: 'node-budget-exceeded' });
|
||||
return null;
|
||||
}
|
||||
const nodes = new Map();
|
||||
for (const x of orderedX) {
|
||||
for (const y of orderedY) {
|
||||
const point = [x, y];
|
||||
if (!pointBlocked(point, expanded)
|
||||
&& (allowAvoidedCrossings || !pointOnAvoidedInterior(point, relevantAvoidedSegments))) {
|
||||
nodes.set(pointKey(point), point);
|
||||
}
|
||||
}
|
||||
}
|
||||
writeGridMetrics(metrics, { usableNodeCount: nodes.size });
|
||||
if (!nodes.has(pointKey(startStub)) || !nodes.has(pointKey(endStub))) {
|
||||
writeGridMetrics(metrics, { status: 'endpoint-blocked' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const adjacency = new Map([...nodes.keys()].map((key) => [key, []]));
|
||||
let graphEdgeCount = 0;
|
||||
const connectLine = (line, axis) => {
|
||||
for (let index = 0; index < line.length - 1; index += 1) {
|
||||
const left = line[index];
|
||||
const right = line[index + 1];
|
||||
if (segmentBlocked(left, right, expanded)) continue;
|
||||
if (segmentConflictsWithAvoided(
|
||||
left,
|
||||
right,
|
||||
relevantAvoidedSegments,
|
||||
minimumAvoidedOverlapPx,
|
||||
allowAvoidedCrossings,
|
||||
)) continue;
|
||||
if (relevantBorderSegments.some((segment) => (
|
||||
collinearOverlap(left, right, segment.start, segment.end) > 0.0001
|
||||
))) continue;
|
||||
const distance = Math.abs(right[0] - left[0]) + Math.abs(right[1] - left[1]);
|
||||
const leftKey = pointKey(left);
|
||||
const rightKey = pointKey(right);
|
||||
adjacency.get(leftKey).push([rightKey, distance, axis === 'h' ? 'R' : 'D']);
|
||||
adjacency.get(rightKey).push([leftKey, distance, axis === 'h' ? 'L' : 'U']);
|
||||
graphEdgeCount += 1;
|
||||
}
|
||||
};
|
||||
for (const y of orderedY) {
|
||||
connectLine(orderedX.map((x) => nodes.get(pointKey([x, y]))).filter(Boolean), 'h');
|
||||
}
|
||||
for (const x of orderedX) {
|
||||
connectLine(orderedY.map((y) => nodes.get(pointKey([x, y]))).filter(Boolean), 'v');
|
||||
}
|
||||
writeGridMetrics(metrics, { graphEdgeCount });
|
||||
|
||||
// The search state carries the incoming direction so a turn can cost extra
|
||||
// and a reversal is never taken: the pure shortest path hugs every obstacle
|
||||
// corner with a staircase of short jogs, while a bend-penalised one takes
|
||||
// the same corridor in a few long strokes. The first stub already leaves
|
||||
// the endpoint along its side and the last one arrives along the end side.
|
||||
const directionOf = ([dx, dy]) => (dx > 0 ? 'R' : dx < 0 ? 'L' : dy > 0 ? 'D' : 'U');
|
||||
const opposite = { R: 'L', L: 'R', D: 'U', U: 'D' };
|
||||
const stateKey = (key, direction) => `${key}|${direction}`;
|
||||
const sourceAxis = directionOf(OUTWARD[fromSide]);
|
||||
const targetAxis = opposite[directionOf(OUTWARD[toSide])];
|
||||
const source = pointKey(startStub);
|
||||
const target = pointKey(endStub);
|
||||
const sourceState = stateKey(source, sourceAxis);
|
||||
const distances = new Map([[sourceState, 0]]);
|
||||
const previous = new Map();
|
||||
const queue = new MinHeap();
|
||||
queue.push(sourceState, 0);
|
||||
let visitedNodeCount = 0;
|
||||
let targetState = null;
|
||||
while (queue.entries.length) {
|
||||
const next = queue.pop();
|
||||
const current = next.key;
|
||||
const currentDistance = next.distance;
|
||||
if (currentDistance !== distances.get(current)) continue;
|
||||
visitedNodeCount += 1;
|
||||
const [currentNode, currentAxis] = current.split('|');
|
||||
if (currentNode === target) {
|
||||
// Arriving on the wrong axis costs one final turn onto the end stub.
|
||||
const arrival = currentDistance + (currentAxis === targetAxis ? 0 : bendPenaltyPx);
|
||||
if (targetState == null || arrival < targetState.distance) {
|
||||
targetState = { key: current, distance: arrival };
|
||||
}
|
||||
if (currentAxis === targetAxis || bendPenaltyPx === 0) break;
|
||||
continue;
|
||||
}
|
||||
if (targetState && currentDistance >= targetState.distance) break;
|
||||
for (const [neighbor, weight, axis] of adjacency.get(currentNode) || []) {
|
||||
if (axis === opposite[currentAxis]) continue;
|
||||
const candidate = currentDistance + weight + (axis === currentAxis ? 0 : bendPenaltyPx);
|
||||
const neighborState = stateKey(neighbor, axis);
|
||||
if (candidate >= (distances.get(neighborState) ?? Infinity)) continue;
|
||||
distances.set(neighborState, candidate);
|
||||
previous.set(neighborState, current);
|
||||
queue.push(neighborState, candidate);
|
||||
}
|
||||
}
|
||||
writeGridMetrics(metrics, { visitedNodeCount });
|
||||
if (!targetState) {
|
||||
writeGridMetrics(metrics, { status: 'no-route' });
|
||||
return null;
|
||||
}
|
||||
const reversed = [];
|
||||
for (let key = targetState.key; key; key = previous.get(key)) {
|
||||
reversed.push(nodes.get(key.split('|')[0]));
|
||||
if (key === sourceState) break;
|
||||
}
|
||||
if (pointKey(reversed.at(-1)) !== source) {
|
||||
writeGridMetrics(metrics, { status: 'broken-predecessor-chain' });
|
||||
return null;
|
||||
}
|
||||
const shortestPoints = normalizeRoutePoints([start, ...reversed.reverse(), end]);
|
||||
writeGridMetrics(metrics, { status: 'routed' });
|
||||
return {
|
||||
points: shortestPoints,
|
||||
length: orthogonalLength(shortestPoints),
|
||||
obstacleCount: expanded.length,
|
||||
};
|
||||
}
|
||||
|
||||
function collinearOverlap(leftStart, leftEnd, rightStart, rightEnd) {
|
||||
if (leftStart[0] === leftEnd[0] && rightStart[0] === rightEnd[0]
|
||||
&& leftStart[0] === rightStart[0]) {
|
||||
return Math.max(0, Math.min(Math.max(leftStart[1], leftEnd[1]), Math.max(rightStart[1], rightEnd[1]))
|
||||
- Math.max(Math.min(leftStart[1], leftEnd[1]), Math.min(rightStart[1], rightEnd[1])));
|
||||
}
|
||||
if (leftStart[1] === leftEnd[1] && rightStart[1] === rightEnd[1]
|
||||
&& leftStart[1] === rightStart[1]) {
|
||||
return Math.max(0, Math.min(Math.max(leftStart[0], leftEnd[0]), Math.max(rightStart[0], rightEnd[0]))
|
||||
- Math.max(Math.min(leftStart[0], leftEnd[0]), Math.min(rightStart[0], rightEnd[0])));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function segmentOutsideContent(start, end, contentBounds) {
|
||||
if (!contentBounds) return false;
|
||||
const midpoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2];
|
||||
return midpoint[0] < contentBounds.left || midpoint[0] > contentBounds.right
|
||||
|| midpoint[1] < contentBounds.top || midpoint[1] > contentBounds.bottom;
|
||||
}
|
||||
|
||||
function sharesOuterCorridor({ relation, relations, pathFor, points, contentBounds, minimumOverlap }) {
|
||||
for (const other of asArray(relations)) {
|
||||
if (!other || other === relation) continue;
|
||||
const related = relation.from === other.from || relation.from === other.to
|
||||
|| relation.to === other.from || relation.to === other.to;
|
||||
if (!related) continue;
|
||||
const otherPoints = normalizeRoutePoints(pathFor(other)?.points || []);
|
||||
for (let left = 0; left < points.length - 1; left += 1) {
|
||||
if (!segmentOutsideContent(points[left], points[left + 1], contentBounds)) continue;
|
||||
for (let right = 0; right < otherPoints.length - 1; right += 1) {
|
||||
if (collinearOverlap(points[left], points[left + 1], otherPoints[right], otherPoints[right + 1]) >= minimumOverlap) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function relationshipSubject(diagramType, relationCollection, relationIndex, relation) {
|
||||
return {
|
||||
diagramType,
|
||||
collection: relationCollection,
|
||||
index: relationIndex,
|
||||
...(relation.id ? { id: relation.id } : {}),
|
||||
from: relation.from,
|
||||
to: relation.to,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject conspicuous authored detours without penalizing routes whose length is
|
||||
* explained by opaque-node avoidance or a related shared outer corridor.
|
||||
*/
|
||||
export function cleanRouteDetourProblems({
|
||||
relations,
|
||||
obstacles,
|
||||
contentRects = obstacles,
|
||||
endpointIds,
|
||||
pathFor,
|
||||
fromSideFor,
|
||||
toSideFor,
|
||||
diagramType,
|
||||
relationCollection,
|
||||
profile,
|
||||
thresholds = {},
|
||||
}) {
|
||||
if (profile !== 'showcase') return [];
|
||||
const policy = { ...DEFAULTS, ...thresholds };
|
||||
const obstacleList = [...obstacles];
|
||||
const contentBounds = boundsForRects(contentRects);
|
||||
const problems = [];
|
||||
for (const [relationIndex, relation] of asArray(relations).entries()) {
|
||||
if (!relation || !endpointIds?.has(relation.from) || !endpointIds?.has(relation.to)) continue;
|
||||
if (!Array.isArray(relation.via) || relation.via.length === 0) continue;
|
||||
const points = normalizeRoutePoints(pathFor(relation)?.points || []);
|
||||
if (points.length < 3 || !points.every((point) => Array.isArray(point) && isFinitePoint(...point))) continue;
|
||||
const actualLength = orthogonalLength(points);
|
||||
if (!Number.isFinite(actualLength)) continue;
|
||||
const start = points[0];
|
||||
const end = points.at(-1);
|
||||
const manhattan = Math.abs(end[0] - start[0]) + Math.abs(end[1] - start[1]);
|
||||
if (actualLength < manhattan * policy.minimumDetourRatio
|
||||
|| actualLength - manhattan < policy.minimumExcessLengthPx) continue;
|
||||
const routeBounds = boundsForPoints(points);
|
||||
const excursion = outsideExcursion(routeBounds, contentBounds);
|
||||
const emptyClearance = emptyControlPointClearance(points, obstacleList);
|
||||
if (Math.max(excursion?.maximum || 0, emptyClearance?.maximum || 0)
|
||||
< policy.minimumEmptyExcursionPx) continue;
|
||||
if (sharesOuterCorridor({
|
||||
relation,
|
||||
relations,
|
||||
pathFor,
|
||||
points,
|
||||
contentBounds,
|
||||
minimumOverlap: policy.sharedCorridorMinimumPx,
|
||||
})) continue;
|
||||
|
||||
const fromSide = fromSideFor?.(relation) || inferredSide(points, 'source');
|
||||
const toSide = toSideFor?.(relation) || inferredSide(points, 'target');
|
||||
const shortest = shortestOrthogonalGridRoute({
|
||||
start,
|
||||
end,
|
||||
points,
|
||||
obstacles: obstacleList,
|
||||
fromSide,
|
||||
toSide,
|
||||
clearance: policy.clearance,
|
||||
maximumObstacleCount: policy.maximumObstacleCount,
|
||||
});
|
||||
if (!shortest || !Number.isFinite(shortest.length) || shortest.length <= 0) continue;
|
||||
const detourRatio = actualLength / shortest.length;
|
||||
const excessLength = actualLength - shortest.length;
|
||||
if (detourRatio < policy.minimumDetourRatio || excessLength < policy.minimumExcessLengthPx) continue;
|
||||
|
||||
const relationId = relation.id ? ` id "${relation.id}"` : '';
|
||||
const message = `[composition/excessive-route-detour] ${diagramType} ${relationCollection}[${relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" travels ${Math.round(actualLength)}px, ${rounded(detourRatio)}x the ${Math.round(shortest.length)}px shortest obstacle-clearing orthogonal route, and reaches ${Math.round(excursion.maximum)}px beyond the content bounds — remove the distant via corridor or move it close to the connected content.`;
|
||||
const supportedFix = 'remove the distant via points and retry automatic routing, or keep the endpoint sides and move the via corridor near the connected nodes while preserving labels and direction';
|
||||
recordDiagnostic({
|
||||
code: 'composition/excessive-route-detour',
|
||||
severity: 'error',
|
||||
message,
|
||||
subject: relationshipSubject(diagramType, relationCollection, relationIndex, relation),
|
||||
evidence: {
|
||||
points,
|
||||
actualLengthPx: rounded(actualLength),
|
||||
shortestLegalPoints: shortest.points,
|
||||
shortestLegalLengthPx: rounded(shortest.length),
|
||||
detourRatio: rounded(detourRatio),
|
||||
excessLengthPx: rounded(excessLength),
|
||||
routeBounds,
|
||||
contentBounds,
|
||||
emptyExcursionPx: excursion,
|
||||
emptyControlPointClearancePx: emptyClearance,
|
||||
obstacleCount: shortest.obstacleCount,
|
||||
thresholds: {
|
||||
minimumDetourRatio: policy.minimumDetourRatio,
|
||||
minimumExcessLengthPx: policy.minimumExcessLengthPx,
|
||||
minimumEmptyExcursionPx: policy.minimumEmptyExcursionPx,
|
||||
},
|
||||
},
|
||||
supportedFixes: [supportedFix],
|
||||
});
|
||||
problems.push(message);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const SIDECAR_STEM_NAMESPACE = /\.~archify-[0-9a-f]{64}$/iu;
|
||||
|
||||
export function sidecarStemNeedsBounding(stem, suffixes) {
|
||||
const fits = (value) => value.length <= 255 && Buffer.byteLength(value, 'utf8') <= 255;
|
||||
return !suffixes.every((suffix) => fits(`${stem}${suffix}`));
|
||||
}
|
||||
|
||||
export function boundedSidecarStem(stem, suffixes, { force = false, hashDomain } = {}) {
|
||||
if (!force && !sidecarStemNeedsBounding(stem, suffixes)
|
||||
&& !SIDECAR_STEM_NAMESPACE.test(stem)) return stem;
|
||||
const hashInput = hashDomain ? `${hashDomain}\0${stem}` : stem;
|
||||
const marker = `.~archify-${createHash('sha256').update(hashInput).digest('hex')}`;
|
||||
const codePoints = [...stem];
|
||||
while (codePoints.length
|
||||
&& sidecarStemNeedsBounding(`${codePoints.join('')}${marker}`, suffixes)) {
|
||||
codePoints.pop();
|
||||
}
|
||||
return `${codePoints.join('')}${marker}`;
|
||||
}
|
||||
|
||||
export function sidecarStemFromComponent(component) {
|
||||
if (component.endsWith('.html')) {
|
||||
return {
|
||||
stem: component.slice(0, -'.html'.length),
|
||||
options: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
stem: component,
|
||||
options: { force: true, hashDomain: 'full-component' },
|
||||
};
|
||||
}
|
||||
|
||||
export function isBoundedSidecarStem(stem) {
|
||||
return SIDECAR_STEM_NAMESPACE.test(stem);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/** Uniform spatial grid for "which items can this box reach" queries (#8). */
|
||||
|
||||
// A cell box wider than this is not worth walking: the query returns every
|
||||
// inserted item instead, which stays a superset of what the box reaches.
|
||||
const DEFAULT_MAX_CELLS = 4096;
|
||||
|
||||
export function createSpatialGrid(cellSize, { maxCells = DEFAULT_MAX_CELLS } = {}) {
|
||||
const buckets = new Map();
|
||||
const items = [];
|
||||
// Items whose own box cannot be walked are candidates for every query.
|
||||
const overflow = [];
|
||||
const keyOf = (x, y) => x + ':' + y;
|
||||
const rangeOf = (box) => ({
|
||||
x0: Math.floor(box.minX / cellSize), x1: Math.floor(box.maxX / cellSize),
|
||||
y0: Math.floor(box.minY / cellSize), y1: Math.floor(box.maxY / cellSize),
|
||||
});
|
||||
// Finite coordinates can still land outside the safe-integer range, where
|
||||
// incrementing an index no longer advances it; and a legal coordinate can
|
||||
// name more cells than the grid is worth. Both cases stay out of the buckets
|
||||
// and are answered by the item list, so they never hang or allocate.
|
||||
const walkable = (range) => Number.isSafeInteger(range.x0) && Number.isSafeInteger(range.x1)
|
||||
&& Number.isSafeInteger(range.y0) && Number.isSafeInteger(range.y1)
|
||||
&& (range.x1 - range.x0 + 1) * (range.y1 - range.y0 + 1) <= maxCells;
|
||||
|
||||
return {
|
||||
insert(box, item) {
|
||||
items.push(item);
|
||||
const range = rangeOf(box);
|
||||
if (!walkable(range)) {
|
||||
overflow.push(item);
|
||||
return;
|
||||
}
|
||||
for (let x = range.x0; x <= range.x1; x += 1) {
|
||||
for (let y = range.y0; y <= range.y1; y += 1) {
|
||||
const key = keyOf(x, y);
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) { bucket = []; buckets.set(key, bucket); }
|
||||
bucket.push(item);
|
||||
}
|
||||
}
|
||||
},
|
||||
query(box) {
|
||||
const range = rangeOf(box);
|
||||
if (!walkable(range)) return items.slice();
|
||||
const seen = new Set();
|
||||
const found = [];
|
||||
for (let x = range.x0; x <= range.x1; x += 1) {
|
||||
for (let y = range.y0; y <= range.y1; y += 1) {
|
||||
const bucket = buckets.get(keyOf(x, y));
|
||||
if (!bucket) continue;
|
||||
for (const item of bucket) {
|
||||
if (seen.has(item)) continue;
|
||||
seen.add(item);
|
||||
found.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const item of overflow) {
|
||||
if (seen.has(item)) continue;
|
||||
seen.add(item);
|
||||
found.push(item);
|
||||
}
|
||||
return found;
|
||||
},
|
||||
};
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
// Single-line node text fitting, shared by every renderer.
|
||||
//
|
||||
// Node text (`label`, `sublabel`, `tag`) renders as one <text> element with
|
||||
// text-anchor="middle" and is never wrapped. Left unmeasured, an over-long
|
||||
// value silently spills across its neighbours while validation still reports
|
||||
// a clean receipt — the failure mode this module exists to close.
|
||||
//
|
||||
// Two halves, always used together:
|
||||
// - fittedNodeFontSize shrinks the text toward a legible minimum at render
|
||||
// time, so ordinary overruns simply get smaller instead of overlapping.
|
||||
// - minimumNodeTextWidth reports the width the text still needs once it has
|
||||
// shrunk as far as it may, so validation can reject what shrinking cannot
|
||||
// save.
|
||||
//
|
||||
// The geometry constants below are shared; the per-field `preferred` and
|
||||
// `minimum` font sizes are not, because renderers set node text at different
|
||||
// sizes (architecture sublabels are 9px, the rest are 7px).
|
||||
|
||||
import { textUnits, SEMANTIC_SIGIL_INSET, SEMANTIC_SIGIL_SIZE, SEMANTIC_SIGIL_FOOTPRINT, SOURCE_BADGE_FOOTPRINT } from './utils.mjs';
|
||||
|
||||
// widthFactor: px of advance width per text unit, per px of font size.
|
||||
// horizontalPadding: total px reserved inside the box so text never touches
|
||||
// the border.
|
||||
export const nodeTextFit = {
|
||||
widthFactor: 0.6,
|
||||
horizontalPadding: 8,
|
||||
};
|
||||
|
||||
// Largest font size at or below `preferred` that fits `text` inside `width`,
|
||||
// floored at `minimum` — below that the text is no longer legible and the
|
||||
// caller should be reporting a problem instead.
|
||||
export function fittedNodeFontSize(text, width, preferred, minimum) {
|
||||
const units = Math.max(1, textUnits(text));
|
||||
const available = Math.max(1, width - nodeTextFit.horizontalPadding);
|
||||
const fitted = Math.min(preferred, available / (units * nodeTextFit.widthFactor));
|
||||
return Math.max(minimum, Math.floor(fitted * 10) / 10);
|
||||
}
|
||||
|
||||
// Width `text` occupies at its legible minimum. Compare against
|
||||
// `width - nodeTextFit.horizontalPadding` to decide whether shrink-to-fit can
|
||||
// rescue it.
|
||||
export function minimumNodeTextWidth(text, minimum) {
|
||||
return textUnits(text) * minimum * nodeTextFit.widthFactor;
|
||||
}
|
||||
|
||||
// Available text width inside a box of `width`.
|
||||
export function availableNodeTextWidth(width) {
|
||||
return width - nodeTextFit.horizontalPadding;
|
||||
}
|
||||
|
||||
// Adapted from Souptik Chakraborty's #220: shift only labels which reach a
|
||||
// corner icon. Unlike the original hard gate, a narrow valid node uses a
|
||||
// separate text row; its authored bounds and acceptance remain unchanged.
|
||||
export function nodeLabelLayout({ width, height, rows, side = 'left', brand = false, source = false, step = '' }) {
|
||||
const result = { x: width / 2, ys: rows.map(row => row.y), sigilY: SEMANTIC_SIGIL_INSET, sigilSize: SEMANTIC_SIGIL_SIZE };
|
||||
const labelWidth = minimumNodeTextWidth(rows[0].text, rows[0].font);
|
||||
const stepEnd = step ? (side === 'left' ? 23 : 10) + minimumNodeTextWidth(step, 8) + 3 : 0;
|
||||
const left = Math.max(side === 'left' ? SEMANTIC_SIGIL_FOOTPRINT + 2 : 4, stepEnd);
|
||||
const right = width - (brand ? 26 : side === 'right' ? SEMANTIC_SIGIL_FOOTPRINT + 2 : 4)
|
||||
- (source ? SOURCE_BADGE_FOOTPRINT : 0);
|
||||
if (result.x - labelWidth / 2 >= left && result.x + labelWidth / 2 <= right) return result;
|
||||
if (labelWidth <= right - left) {
|
||||
// Round away from the icon, retaining the node centre whenever possible.
|
||||
result.x = Math.min(Math.floor((right - labelWidth / 2) * 10) / 10,
|
||||
Math.max(result.x, Math.ceil((left + labelWidth / 2) * 10) / 10));
|
||||
return result;
|
||||
}
|
||||
// Keep the existing font sizes and put the text below the decoration rail.
|
||||
// Conservative ascent/descent bounds also protect CJK and fallback fonts.
|
||||
let bottom = Math.max(brand ? 22 : SEMANTIC_SIGIL_FOOTPRINT, source ? 19 : 0);
|
||||
const ys = rows.map(row => {
|
||||
const y = Math.max(row.y, Math.ceil((bottom + 2 + row.font * 1.2) * 10) / 10);
|
||||
bottom = y + row.font * 0.3;
|
||||
return y;
|
||||
});
|
||||
if (bottom <= height - 2) {
|
||||
result.ys = ys;
|
||||
return result;
|
||||
}
|
||||
if (source) {
|
||||
// A source badge adds a second decoration on the right. On short boxes,
|
||||
// restoring the original rows would put the title back under that badge.
|
||||
// Try compact leading before giving up the dedicated text rail. Retain
|
||||
// every font size and the authored box; only this crowded fallback packs
|
||||
// the rows, with a full em above each baseline and 0.3 em below it.
|
||||
let compactBottom = Math.max(brand ? 22 : SEMANTIC_SIGIL_FOOTPRINT, 19) + 1;
|
||||
const compactYs = rows.map(row => {
|
||||
const y = Math.ceil((compactBottom + 1 + row.font) * 10) / 10;
|
||||
compactBottom = y + row.font * 0.3;
|
||||
return y;
|
||||
});
|
||||
// The compact fallback may also use the otherwise reserved bottom
|
||||
// padding; the entire descent still stays inside the fixed box.
|
||||
if (compactBottom <= height - 0.5) {
|
||||
result.ys = compactYs;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
// A deliberately short fixed box may have no spare row. Preserve its text
|
||||
// and geometry, and fit only the decorative sigil in the space above it.
|
||||
result.sigilY = 1;
|
||||
result.sigilSize = Math.max(1, Math.min(SEMANTIC_SIGIL_SIZE,
|
||||
Math.floor(rows[0].y - rows[0].font * 1.2 - 3)));
|
||||
return result;
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
escapeHtml as esc,
|
||||
localizeTemplate,
|
||||
resolveLocale,
|
||||
translateMessage,
|
||||
viewerCatalog,
|
||||
} from './i18n.mjs';
|
||||
|
||||
export { esc };
|
||||
|
||||
export function renderDefinitions() {
|
||||
return ` <!-- Definitions -->
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" class="m-default" />
|
||||
</marker>
|
||||
<marker id="arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
|
||||
</marker>
|
||||
<marker id="arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" class="m-security" />
|
||||
</marker>
|
||||
<marker id="arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
|
||||
</marker>
|
||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>`;
|
||||
}
|
||||
|
||||
const SIGIL_TONE = {
|
||||
frontend: 'frontend',
|
||||
start: 'frontend',
|
||||
backend: 'backend',
|
||||
active: 'frontend',
|
||||
database: 'database',
|
||||
success: 'backend',
|
||||
cloud: 'cloud',
|
||||
waiting: 'cloud',
|
||||
security: 'security',
|
||||
decision: 'database',
|
||||
failure: 'security',
|
||||
messagebus: 'messagebus',
|
||||
external: 'external',
|
||||
neutral: 'external',
|
||||
};
|
||||
|
||||
const SIGIL_SHAPE = {
|
||||
calendar: `<rect x="2" y="3.5" width="12" height="10.5" rx="2"/><path d="M5 2v3M11 2v3M2 7h12M5 10h2M9 10h2"/>`,
|
||||
clock: `<circle cx="8" cy="8" r="6"/><path d="M8 4v4l3 2"/>`,
|
||||
person: `<circle cx="8" cy="4.5" r="2.5"/><path d="M3 14v-2a5 5 0 0 1 10 0v2"/>`,
|
||||
briefcase: `<rect x="2" y="5" width="12" height="9" rx="2"/><path d="M5 5V2h6v3M2 9h12M7 9v2h2V9"/>`,
|
||||
flag: `<path d="M3 14V2h10l-2 3 2 3H3"/>`,
|
||||
moon: `<path d="M13.5 10A6 6 0 0 1 6 2.5 6 6 0 1 0 13.5 10Z"/>`,
|
||||
frontend: `<rect x="2" y="3" width="12" height="10" rx="2"/>
|
||||
<path d="M2 6.5h12"/>
|
||||
<circle cx="4.1" cy="4.8" r=".7" class="sigil-fill"/>
|
||||
<circle cx="6.3" cy="4.8" r=".7" class="sigil-fill"/>`,
|
||||
backend: `<path d="M6 3 3 8l3 5M10 3l3 5-3 5"/>`,
|
||||
database: `<ellipse cx="8" cy="4" rx="5" ry="2"/>
|
||||
<path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2"/>`,
|
||||
cloud: `<path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z"/>`,
|
||||
security: `<path d="M8 2.2 13 4v3.5c0 3.1-1.8 5.4-5 6.5-3.2-1.1-5-3.4-5-6.5V4Z"/>
|
||||
<path d="m5.8 8 1.5 1.5 3-3"/>`,
|
||||
messagebus: `<path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/>
|
||||
<circle cx="5" cy="4.5" r="1" class="sigil-fill"/>
|
||||
<circle cx="10.5" cy="8" r="1" class="sigil-fill"/>
|
||||
<circle cx="7" cy="11.5" r="1" class="sigil-fill"/>`,
|
||||
external: `<rect x="2.5" y="5" width="8.5" height="8" rx="1.5"/>
|
||||
<path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5"/>`,
|
||||
start: `<circle cx="8" cy="8" r="5"/>
|
||||
<path d="m7 5.4 3.6 2.6L7 10.6Z" class="sigil-fill"/>`,
|
||||
active: `<path d="M2 8h3l1.5-3.5L9 12l1.6-4H14"/>`,
|
||||
waiting: `<path d="M4 2.5h8M4 13.5h8M5 3c0 2.8 2 3.2 3 5-1 1.8-3 2.2-3 5M11 3c0 2.8-2 3.2-3 5 1 1.8 3 2.2 3 5"/>`,
|
||||
success: `<circle cx="8" cy="8" r="5.3"/>
|
||||
<path d="m5.2 8 1.8 1.8 3.8-4"/>`,
|
||||
failure: `<circle cx="8" cy="8" r="5.3"/>
|
||||
<path d="m5.7 5.7 4.6 4.6m0-4.6-4.6 4.6"/>`,
|
||||
neutral: `<rect x="3" y="3" width="10" height="10" rx="2"/>
|
||||
<circle cx="8" cy="8" r="1.2" class="sigil-fill"/>`,
|
||||
};
|
||||
|
||||
// A quiet, renderer-owned corner symbol (type default or authored icon). It is
|
||||
// SVG content rather than a
|
||||
// viewer overlay, so it survives canonical export while adding no focus target,
|
||||
// accessible name, layout box, or interaction state of its own.
|
||||
// Shared with label clearance so the reserved rail matches the actual icon.
|
||||
export const SEMANTIC_SIGIL_INSET = 6;
|
||||
export const SEMANTIC_SIGIL_SIZE = 11;
|
||||
export const SEMANTIC_SIGIL_FOOTPRINT = SEMANTIC_SIGIL_INSET + SEMANTIC_SIGIL_SIZE;
|
||||
// The viewer installs a runtime "sources" beacon on the node's top-right
|
||||
// rail, just left of the brand mark. Layout must reserve the same footprint
|
||||
// so labels never sit under the badge.
|
||||
export const SOURCE_BADGE_FOOTPRINT = 38;
|
||||
|
||||
export function renderSemanticSigil(kind, { x, y, size = SEMANTIC_SIGIL_SIZE, icon } = {}) {
|
||||
if (icon === 'none') return '';
|
||||
const selected = icon ?? kind;
|
||||
const normalized = Object.hasOwn(SIGIL_SHAPE, selected) ? selected : 'neutral';
|
||||
const tone = SIGIL_TONE[kind] || 'external';
|
||||
const scale = size / 16;
|
||||
return `<g aria-hidden="true" data-semantic-sigil="${esc(normalized)}" class="semantic-sigil s-${tone}" transform="translate(${x} ${y}) scale(${scale})">
|
||||
${SIGIL_SHAPE[normalized]}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
export function renderCards(cards) {
|
||||
const list = Array.isArray(cards) ? cards : [];
|
||||
return ` <!-- Info Cards -->
|
||||
<div class="cards">
|
||||
${list.map((card) => ` <div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-dot ${esc(card.dot)}"></div>
|
||||
<h3>${esc(card.title)}</h3>
|
||||
</div>
|
||||
<ul>
|
||||
${card.items.map((item) => ` <li>${esc(item)}</li>`).join('\n')}
|
||||
</ul>
|
||||
</div>`).join('\n\n')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const SVG_SLOT_RE = / <!-- ARCHIFY:SVG_SLOT_START -->[\s\S]*? <!-- ARCHIFY:SVG_SLOT_END -->/;
|
||||
const CARDS_SLOT_RE = / <!-- ARCHIFY:CARDS_SLOT_START -->[\s\S]*? <!-- ARCHIFY:CARDS_SLOT_END -->/;
|
||||
const SUBTITLE_SLOT_RE = /^([ \t]*)<p class="subtitle">\[Subtitle description\]<\/p>[ \t]*(\r?\n)?/m;
|
||||
const SOURCE_EVIDENCE_PLACEHOLDER = ' <!-- ARCHIFY:SOURCE_EVIDENCE_DATA -->';
|
||||
const I18N_PLACEHOLDER = ' <!-- ARCHIFY:I18N_DATA -->';
|
||||
|
||||
function serializeScriptJson(value) {
|
||||
return JSON.stringify(value)
|
||||
.replaceAll('<', '\\u003c')
|
||||
.replaceAll('>', '\\u003e')
|
||||
.replaceAll('&', '\\u0026');
|
||||
}
|
||||
|
||||
const TEMPLATE_PLACEHOLDERS = [
|
||||
'<html lang="en" data-theme="dark" data-preset="[VISUAL PRESET]">',
|
||||
'<title>[PROJECT NAME] Architecture Diagram</title>',
|
||||
'<h1>[PROJECT NAME] Architecture</h1>',
|
||||
I18N_PLACEHOLDER,
|
||||
];
|
||||
|
||||
export function applyTemplate(template, {
|
||||
title,
|
||||
subtitle,
|
||||
svg,
|
||||
cards,
|
||||
locale,
|
||||
visualPreset = 'classic',
|
||||
sourceEvidence = null,
|
||||
}) {
|
||||
if (!SVG_SLOT_RE.test(template)) {
|
||||
throw new Error('applyTemplate: template missing ARCHIFY:SVG_SLOT sentinel');
|
||||
}
|
||||
if (!CARDS_SLOT_RE.test(template)) {
|
||||
throw new Error('applyTemplate: template missing ARCHIFY:CARDS_SLOT sentinel');
|
||||
}
|
||||
if (!SUBTITLE_SLOT_RE.test(template)) {
|
||||
throw new Error('applyTemplate: template missing subtitle placeholder');
|
||||
}
|
||||
for (const ph of TEMPLATE_PLACEHOLDERS) {
|
||||
if (!template.includes(ph)) {
|
||||
throw new Error(`applyTemplate: template missing placeholder ${JSON.stringify(ph)}`);
|
||||
}
|
||||
}
|
||||
// Keep existing custom templates compatible when evidence is not requested.
|
||||
// Silently dropping verified evidence would be misleading, so the new slot
|
||||
// becomes mandatory only for the opt-in evidence path.
|
||||
if (sourceEvidence && !template.includes(SOURCE_EVIDENCE_PLACEHOLDER)) {
|
||||
throw new Error(`applyTemplate: repository evidence requires placeholder ${JSON.stringify(SOURCE_EVIDENCE_PLACEHOLDER)}`);
|
||||
}
|
||||
// Function replacers: a literal `$&`, `$'`, `$\`` or `$$` in titles, labels,
|
||||
// or rendered SVG must not be interpreted as a replacement pattern.
|
||||
const sourceEvidenceJson = serializeScriptJson(sourceEvidence);
|
||||
const resolvedLocale = resolveLocale(locale);
|
||||
const i18nJson = serializeScriptJson({ locale: resolvedLocale, messages: viewerCatalog(resolvedLocale) });
|
||||
const renderedSubtitle = typeof subtitle === 'string' && subtitle.trim()
|
||||
? `<p class="subtitle">${esc(subtitle)}</p>`
|
||||
: '';
|
||||
const i18nData = ` <script id="archify-i18n-data" type="application/json">${i18nJson}</script>`;
|
||||
return localizeTemplate(template, resolvedLocale)
|
||||
.replace(I18N_PLACEHOLDER, () => i18nData)
|
||||
.replace(TEMPLATE_PLACEHOLDERS[0], () => `<html lang="${esc(resolvedLocale)}" data-theme="dark" data-preset="${esc(visualPreset)}">`)
|
||||
.replace(TEMPLATE_PLACEHOLDERS[1], () => `<title>${esc(translateMessage(resolvedLocale, 'page.title', { title }))}</title>`)
|
||||
.replace(TEMPLATE_PLACEHOLDERS[2], () => `<h1>${esc(title)}</h1>`)
|
||||
.replace(SUBTITLE_SLOT_RE, (_match, indent, newline = '') => renderedSubtitle
|
||||
? `${indent}${renderedSubtitle}${newline}`
|
||||
: '')
|
||||
.replace(SVG_SLOT_RE, () => svg)
|
||||
.replace(CARDS_SLOT_RE, () => cards)
|
||||
.replace(SOURCE_EVIDENCE_PLACEHOLDER, () => sourceEvidence
|
||||
? ` <script id="archify-source-evidence-data" type="application/json">${sourceEvidenceJson}</script>`
|
||||
: '');
|
||||
}
|
||||
|
||||
// CJK and other wide/fullwidth glyphs render at roughly twice the advance
|
||||
// width of ASCII in the monospace stacks the template uses. Keep halfwidth
|
||||
// forms (notably U+FF61–U+FF9F Katakana) out of this set. The explicit ranges
|
||||
// also cover vertical punctuation and supplementary East Asian scripts that
|
||||
// literal glyph ranges made difficult to audit.
|
||||
// Code points that take two columns of advance width: East Asian Wide and
|
||||
// Fullwidth per UAX #11, tracking Unicode 17.0. That takes in the BMP symbols
|
||||
// carrying emoji presentation (U+2705, U+2B50, U+26A1, U+231B, ...), which
|
||||
// render at the same square advance as the supplementary-plane emoji already
|
||||
// listed here, and Hangul Jamo Extended-A. Two boundary calls worth naming:
|
||||
// Unicode 16.0 reclassified the trigrams (U+2630-U+2637) and the monogram /
|
||||
// digram symbols (U+268A-U+268F) from Neutral to Wide, so both are in; and
|
||||
// Hangul Jamo Extended-A stops at U+A97C, its last assigned jamo, because
|
||||
// U+A97D-U+A97F are unassigned, and unassigned code points outside the CJK
|
||||
// ranges UAX #11 names default to Neutral rather than Wide. Spelled out as
|
||||
// ranges because V8 has no \p{East_Asian_Width=W} property escape.
|
||||
const FULLWIDTH_RE = /[\u1100-\u115F\u231A-\u231B\u2329-\u232A\u23E9-\u23EC\u23F0\u23F3\u25FD-\u25FE\u2614-\u2615\u2630-\u2637\u2648-\u2653\u267F\u268A-\u268F\u2693\u26A1\u26AA-\u26AB\u26BD-\u26BE\u26C4-\u26C5\u26CE\u26D4\u26EA\u26F2-\u26F3\u26F5\u26FA\u26FD\u2705\u270A-\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B-\u2B1C\u2B50\u2B55\u2E80-\uA4CF\uA960-\uA97C\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF01-\uFF60\uFFE0-\uFFE6\u{16FE0}-\u{18DFF}\u{1AFF0}-\u{1AFFF}\u{1B000}-\u{1B2FF}\u{1F000}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u;
|
||||
|
||||
// Variation selectors contribute no separate unit. This is a conservative
|
||||
// width estimate, not a measurement of the selected glyph: its actual advance
|
||||
// depends on the font and presentation (Unicode UAX #11).
|
||||
// VS16 requests emoji presentation, so reserve two units for the sequence.
|
||||
// VS15 retains the base's width estimate; forcing every text-presentation
|
||||
// sequence to one unit undercounts wide bases, including CJK characters whose
|
||||
// font ignores that selector. Neutral bases remain one unit. Some selected
|
||||
// text glyphs can be narrower than this estimate; prefer extra space to overflow.
|
||||
const VARIATION_SELECTOR_FIRST = 0xfe00;
|
||||
const VARIATION_SELECTOR_LAST = 0xfe0f;
|
||||
const VARIATION_SELECTOR_EMOJI = 0xfe0f;
|
||||
|
||||
// Width measurement is pure and the same labels are measured many times per
|
||||
// compile, so the unit count is memoized by its input string.
|
||||
const TEXT_UNITS_CACHE = new Map();
|
||||
const MAX_TEXT_UNITS_CACHE_ENTRIES = 4096;
|
||||
const MAX_CACHED_TEXT_LENGTH = 1024;
|
||||
|
||||
export function textUnits(text) {
|
||||
const cacheKey = String(text ?? '');
|
||||
const cachedUnits = TEXT_UNITS_CACHE.get(cacheKey);
|
||||
if (cachedUnits !== undefined) return cachedUnits;
|
||||
const chars = Array.from(cacheKey);
|
||||
let units = 0;
|
||||
for (let i = 0; i < chars.length; i += 1) {
|
||||
const codePoint = chars[i].codePointAt(0);
|
||||
if (codePoint >= VARIATION_SELECTOR_FIRST && codePoint <= VARIATION_SELECTOR_LAST) continue;
|
||||
const next = i + 1 < chars.length ? chars[i + 1].codePointAt(0) : -1;
|
||||
if (next === VARIATION_SELECTOR_EMOJI) units += 2;
|
||||
else units += FULLWIDTH_RE.test(chars[i]) ? 2 : 1;
|
||||
}
|
||||
// Keep repeated in-process compiles bounded, including unusually long labels.
|
||||
if (cacheKey.length <= MAX_CACHED_TEXT_LENGTH) {
|
||||
if (TEXT_UNITS_CACHE.size >= MAX_TEXT_UNITS_CACHE_ENTRIES) {
|
||||
TEXT_UNITS_CACHE.delete(TEXT_UNITS_CACHE.keys().next().value);
|
||||
}
|
||||
TEXT_UNITS_CACHE.set(cacheKey, units);
|
||||
}
|
||||
return units;
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import * as validators from './generated-validators.mjs';
|
||||
import { throwDiagnosticError } from './diagnostics.mjs';
|
||||
|
||||
// "/nodes/3/label" reads much better as "/nodes/3 (id: "router") /label" for the
|
||||
// LLM fixing the JSON; resolve the nearest enclosing element's id or label.
|
||||
function annotatedPath(instancePath, data) {
|
||||
if (!instancePath) return { path: '/', identity: null };
|
||||
let node = data;
|
||||
let hint = null;
|
||||
for (const seg of instancePath.split('/').slice(1)) {
|
||||
if (node == null || typeof node !== 'object') break;
|
||||
node = node[/^\d+$/.test(seg) ? Number(seg) : seg];
|
||||
if (node && typeof node === 'object' && !Array.isArray(node)) {
|
||||
const tag = node.id ?? node.label;
|
||||
if (tag != null) hint = String(tag);
|
||||
}
|
||||
}
|
||||
return { path: instancePath, identity: hint };
|
||||
}
|
||||
|
||||
function annotatePath(instancePath, data) {
|
||||
const annotated = annotatedPath(instancePath, data);
|
||||
return annotated.identity != null
|
||||
? `${annotated.path} (id/label: ${JSON.stringify(annotated.identity)})`
|
||||
: annotated.path;
|
||||
}
|
||||
|
||||
function formatErrors(errors, data) {
|
||||
return errors.map((e) => {
|
||||
const where = annotatePath(e.instancePath, data);
|
||||
const detail = e.params && Object.keys(e.params).length
|
||||
? ' ' + JSON.stringify(e.params)
|
||||
: '';
|
||||
return ` ${where} ${e.message}${detail}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
export function validateSchema(diagramType, data) {
|
||||
const validate = validators[diagramType];
|
||||
if (!validate) {
|
||||
throw new Error(`validateSchema: unknown diagram type "${diagramType}"`);
|
||||
}
|
||||
if (!validate(data)) {
|
||||
const diagnostics = validate.errors.map((error) => {
|
||||
const annotated = annotatedPath(error.instancePath, data);
|
||||
const subject = {
|
||||
diagramType,
|
||||
path: annotated.path,
|
||||
...(annotated.identity != null ? { identity: String(annotated.identity) } : {}),
|
||||
};
|
||||
const evidence = {
|
||||
keyword: error.keyword,
|
||||
expected: error.schema,
|
||||
...error.params,
|
||||
};
|
||||
const supportedFixes = {
|
||||
additionalProperties: [`remove unsupported property ${JSON.stringify(error.params?.additionalProperty)}`],
|
||||
required: [`add required property ${JSON.stringify(error.params?.missingProperty)}`],
|
||||
type: [`use ${JSON.stringify(error.params?.type)} at ${annotated.path}`],
|
||||
enum: [`choose one of ${JSON.stringify(error.params?.allowedValues || [])}`],
|
||||
pattern: [`match the required pattern ${JSON.stringify(error.params?.pattern)}`],
|
||||
minimum: [`use a value ${error.params?.comparison || '>='} ${error.params?.limit}`],
|
||||
maximum: [`use a value ${error.params?.comparison || '<='} ${error.params?.limit}`],
|
||||
minItems: [`provide at least ${error.params?.limit} item(s)`],
|
||||
maxItems: [`provide at most ${error.params?.limit} item(s)`],
|
||||
minLength: [`provide at least ${error.params?.limit} character(s)`],
|
||||
maxLength: [`provide at most ${error.params?.limit} character(s)`],
|
||||
}[error.keyword] || [];
|
||||
const detail = error.params && Object.keys(error.params).length
|
||||
? ` ${JSON.stringify(error.params)}`
|
||||
: '';
|
||||
return {
|
||||
code: `schema/${error.keyword}`,
|
||||
severity: 'error',
|
||||
message: `${annotatePath(error.instancePath, data)} ${error.message}${detail}`,
|
||||
subject,
|
||||
evidence,
|
||||
supportedFixes,
|
||||
};
|
||||
});
|
||||
throwDiagnosticError(
|
||||
`${diagramType} schema validation failed:\n${formatErrors(validate.errors, data)}`,
|
||||
diagnostics,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user