@1agh/maude 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +30 -11
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +58 -12
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// annotate.mjs — the AI annotation WRITE verb (FigJam v3).
|
|
3
|
+
//
|
|
4
|
+
// `read-annotations` made the annotation layer machine-READABLE; this verb
|
|
5
|
+
// closes the loop: an AI agent (or any tool) creates stickies, labelled
|
|
6
|
+
// shapes, BOUND connectors, groups — or a whole auto-laid-out flow diagram —
|
|
7
|
+
// through a typed ops vocabulary (never raw SVG). Everything renders through
|
|
8
|
+
// the CANONICAL serializer (`annotations-model.ts` — the same code the canvas
|
|
9
|
+
// uses) and passes the same allowlist sanitizer before a byte is written, so
|
|
10
|
+
// the verb can never emit a shape the canvas wouldn't.
|
|
11
|
+
//
|
|
12
|
+
// Runs under Bun (the .sh wrapper enforces it) because the model is TS.
|
|
13
|
+
//
|
|
14
|
+
// Write path: when `<designRoot>/_server.json` points at a live dev-server the
|
|
15
|
+
// merged SVG goes through `PUT /_api/annotations` — the server sanitizes,
|
|
16
|
+
// persists, and broadcasts through the collab bridge so every open canvas
|
|
17
|
+
// updates in real time. With no server, the file is written directly (already
|
|
18
|
+
// sanitized). Either way the result is LWW over the whole SVG — the same
|
|
19
|
+
// trade-off the canvas itself has (documented; agents should read-then-write).
|
|
20
|
+
//
|
|
21
|
+
// Every created stroke is stamped `data-author="ai"` (provenance) and the
|
|
22
|
+
// verb prints a ref → id map so a follow-up call can target what it made.
|
|
23
|
+
// AI writes never enter any user's local undo stack (they arrive over the
|
|
24
|
+
// sync channel, not through the canvas's commitStrokes).
|
|
25
|
+
//
|
|
26
|
+
// Reached via `maude design annotate` (DDR-062), never a raw bin path.
|
|
27
|
+
//
|
|
28
|
+
// Usage:
|
|
29
|
+
// maude design annotate <rel-path> [--ops <file|->] [--flow <file|->]
|
|
30
|
+
// [--near <artboardId>] [--canvas-state <path>]
|
|
31
|
+
// [--root <repo>] [--dry-run]
|
|
32
|
+
//
|
|
33
|
+
// Ops JSON (--ops / stdin):
|
|
34
|
+
// { "ops": [
|
|
35
|
+
// { "op": "create", "type": "sticky", "ref": "@a", "text": "…",
|
|
36
|
+
// "x"?, "y"?, "w"?, "h"?, "color"? },
|
|
37
|
+
// { "op": "create", "type": "text", "text": "…", "x"?, "y"?, "fontSize"? },
|
|
38
|
+
// { "op": "create", "type": "shape", "shape": "rect|rounded|ellipse|diamond|triangle|triangle-down",
|
|
39
|
+
// "ref"?, "label"?, "x"?, "y"?, "w"?, "h"?, "color"?, "fill"? },
|
|
40
|
+
// { "op": "create", "type": "arrow", "x1", "y1", "x2", "y2" },
|
|
41
|
+
// { "op": "connect", "from": "<id|@ref>", "to": "<id|@ref>", "label"? },
|
|
42
|
+
// { "op": "group", "ids": ["<id|@ref>", …] },
|
|
43
|
+
// { "op": "delete", "id": "<id>" }
|
|
44
|
+
// ] }
|
|
45
|
+
//
|
|
46
|
+
// Flow JSON (--flow): { "nodes": [{ "id", "label", "shape"? }],
|
|
47
|
+
// "edges": [{ "from", "to", "label"? }] }
|
|
48
|
+
// Nodes are auto-laid-out left→right by dependency layer and connected with
|
|
49
|
+
// BOUND arrows, so `read-annotations --graph` reads the diagram back as the
|
|
50
|
+
// same graph.
|
|
51
|
+
|
|
52
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
53
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
54
|
+
|
|
55
|
+
import { anchorPoint, facingAnchor } from '../annotations-bindings.ts';
|
|
56
|
+
import {
|
|
57
|
+
DEFAULT_COLOR,
|
|
58
|
+
DEFAULT_FONT_SIZE,
|
|
59
|
+
DEFAULT_SECTION_COLOR,
|
|
60
|
+
DEFAULT_STICKY_COLOR,
|
|
61
|
+
gid,
|
|
62
|
+
rid,
|
|
63
|
+
SECTION_DEFAULT_H,
|
|
64
|
+
SECTION_DEFAULT_W,
|
|
65
|
+
STICKY_CORNER_RADIUS,
|
|
66
|
+
STICKY_DEFAULT_H,
|
|
67
|
+
STICKY_DEFAULT_W,
|
|
68
|
+
sanitizeAnnotationSvg,
|
|
69
|
+
strokeToSvgEl,
|
|
70
|
+
} from '../annotations-model.ts';
|
|
71
|
+
import {
|
|
72
|
+
fileSlug,
|
|
73
|
+
loadArtboards,
|
|
74
|
+
parseAnnotations,
|
|
75
|
+
resolveDesignRoot,
|
|
76
|
+
} from './read-annotations.mjs';
|
|
77
|
+
|
|
78
|
+
// Mirrors MAX_ANNOTATIONS_BYTES (sync/codec.ts) — kept literal here so the bin
|
|
79
|
+
// stays import-light; the server enforces the same cap on the PUT path anyway.
|
|
80
|
+
const MAX_ANNOTATIONS_BYTES = 1024 * 1024;
|
|
81
|
+
|
|
82
|
+
const SVG_HEADER = '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">';
|
|
83
|
+
|
|
84
|
+
// Flow-node visual defaults — the blue ink + paired tint read on light AND
|
|
85
|
+
// dark canvases (the verb cannot know the viewer's theme).
|
|
86
|
+
const NODE_INK = '#3b82f6';
|
|
87
|
+
const NODE_FILL = '#e0ebfd';
|
|
88
|
+
const TEXT_INK = '#1a1a1a';
|
|
89
|
+
const NODE_W = 180;
|
|
90
|
+
const NODE_H = 80;
|
|
91
|
+
const FLOW_GAP_X = 100;
|
|
92
|
+
const FLOW_GAP_Y = 60;
|
|
93
|
+
|
|
94
|
+
const HELP = `annotate.mjs — AI annotation WRITE verb (DDR-062 via \`maude design annotate\`)
|
|
95
|
+
|
|
96
|
+
Usage:
|
|
97
|
+
maude design annotate <rel-path> [--ops <file|->] [--flow <file|->]
|
|
98
|
+
[--near <artboardId>] [--canvas-state <path>]
|
|
99
|
+
[--root <repo>] [--dry-run]
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
<rel-path> Canvas path relative to the design root (e.g. "ui/Foo.tsx").
|
|
103
|
+
--ops <file|-> Ops JSON ({ ops: [...] }); "-" or omitted = stdin.
|
|
104
|
+
--flow <file|-> Flow JSON ({ nodes, edges }) — auto-laid-out diagram of
|
|
105
|
+
bound connectors. Mutually exclusive with --ops.
|
|
106
|
+
--near <artboard> With --canvas-state: place the flow beside this artboard.
|
|
107
|
+
--canvas-state <p> Artboard rects JSON (same shape read-annotations takes).
|
|
108
|
+
--root <repo> Repo root. Default: $CLAUDE_PROJECT_DIR, then cwd.
|
|
109
|
+
--dry-run Print the merged SVG to stdout instead of writing.
|
|
110
|
+
|
|
111
|
+
Ops vocabulary: create (sticky | text | shape | arrow) · connect (bound arrow
|
|
112
|
+
between hosts, by id or @ref) · group · delete. Created strokes carry
|
|
113
|
+
data-author="ai" and fresh ids; the verb prints { ok, via, file, refs }.
|
|
114
|
+
|
|
115
|
+
The write is last-write-wins over the whole SVG — read before you write.`;
|
|
116
|
+
|
|
117
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
118
|
+
// Argv
|
|
119
|
+
|
|
120
|
+
function parseArgv(argv) {
|
|
121
|
+
const out = {
|
|
122
|
+
positional: [],
|
|
123
|
+
ops: null,
|
|
124
|
+
flow: null,
|
|
125
|
+
near: null,
|
|
126
|
+
canvasState: null,
|
|
127
|
+
root: null,
|
|
128
|
+
dryRun: false,
|
|
129
|
+
help: false,
|
|
130
|
+
};
|
|
131
|
+
const VALUE_FLAGS = {
|
|
132
|
+
'--ops': 'ops',
|
|
133
|
+
'--flow': 'flow',
|
|
134
|
+
'--near': 'near',
|
|
135
|
+
'--canvas-state': 'canvasState',
|
|
136
|
+
'--root': 'root',
|
|
137
|
+
};
|
|
138
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
139
|
+
const a = argv[i];
|
|
140
|
+
const eq = a.indexOf('=');
|
|
141
|
+
const flagKey = eq > 0 ? a.slice(0, eq) : a;
|
|
142
|
+
if (a === '--help' || a === '-h') {
|
|
143
|
+
out.help = true;
|
|
144
|
+
} else if (a === '--dry-run') {
|
|
145
|
+
out.dryRun = true;
|
|
146
|
+
} else if (flagKey in VALUE_FLAGS) {
|
|
147
|
+
if (eq > 0) {
|
|
148
|
+
out[VALUE_FLAGS[flagKey]] = a.slice(eq + 1);
|
|
149
|
+
} else {
|
|
150
|
+
i += 1;
|
|
151
|
+
out[VALUE_FLAGS[flagKey]] = argv[i];
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
out.positional.push(a);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function fail(msg, code = 1) {
|
|
161
|
+
process.stderr.write(`annotate: ${msg}\n`);
|
|
162
|
+
process.exit(code);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readInput(spec) {
|
|
166
|
+
if (!spec || spec === '-') {
|
|
167
|
+
try {
|
|
168
|
+
return readFileSync(0, 'utf8'); // stdin
|
|
169
|
+
} catch {
|
|
170
|
+
return '';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const p = isAbsolute(spec) ? spec : resolve(process.cwd(), spec);
|
|
174
|
+
if (!existsSync(p)) fail(`input not found: ${p}`, 2);
|
|
175
|
+
return readFileSync(p, 'utf8');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function escapeRe(s) {
|
|
179
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Wave H F2 — the annotate egress allowlist. The dev-server is loopback-only
|
|
184
|
+
* (DDR-054); a PUT target that is not a loopback http(s) origin is refused so
|
|
185
|
+
* a poisoned `_server.json.url` can't exfiltrate the canvas SVG off-box.
|
|
186
|
+
*/
|
|
187
|
+
function isLoopbackHttpUrl(base) {
|
|
188
|
+
let u;
|
|
189
|
+
try {
|
|
190
|
+
u = new URL(base);
|
|
191
|
+
} catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
|
195
|
+
const h = u.hostname.toLowerCase();
|
|
196
|
+
return h === '127.0.0.1' || h === 'localhost' || h === '::1' || h === '[::1]';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
200
|
+
// Existing-canvas context — host geometry for binds, placement origin.
|
|
201
|
+
|
|
202
|
+
/** Fabricate a bbox-shaped rect Stroke for an EXISTING annotation so the
|
|
203
|
+
* binding helpers (which only read strokeBBox) work against parsed data. */
|
|
204
|
+
function fakeHost(ann) {
|
|
205
|
+
return {
|
|
206
|
+
id: ann.id,
|
|
207
|
+
tool: 'rect',
|
|
208
|
+
color: '#000',
|
|
209
|
+
width: 1,
|
|
210
|
+
x: ann.x ?? 0,
|
|
211
|
+
y: ann.y ?? 0,
|
|
212
|
+
w: ann.w ?? 0,
|
|
213
|
+
h: ann.h ?? 0,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const BINDABLE_PARSED = new Set(['rect', 'ellipse', 'polygon', 'sticky', 'image']);
|
|
218
|
+
|
|
219
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
220
|
+
// Op application
|
|
221
|
+
|
|
222
|
+
function buildContext(existing, artboards, near) {
|
|
223
|
+
// Placement origin: beside --near's artboard when given, else right of the
|
|
224
|
+
// existing annotation extent, else a sane top-left.
|
|
225
|
+
let origin = { x: 100, y: 100 };
|
|
226
|
+
const nearBoard = near ? artboards.find((r) => r.id === near) : null;
|
|
227
|
+
if (nearBoard) {
|
|
228
|
+
origin = { x: nearBoard.x + nearBoard.w + 80, y: nearBoard.y };
|
|
229
|
+
} else if (existing.length) {
|
|
230
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
231
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
232
|
+
for (const a of existing) {
|
|
233
|
+
if (a.x == null) continue;
|
|
234
|
+
maxX = Math.max(maxX, a.x + (a.w || 0));
|
|
235
|
+
minY = Math.min(minY, a.y ?? 0);
|
|
236
|
+
}
|
|
237
|
+
if (Number.isFinite(maxX)) origin = { x: maxX + 80, y: Math.max(0, minY) };
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
origin,
|
|
241
|
+
cursor: { ...origin },
|
|
242
|
+
refs: new Map(), // '@ref' → minted id
|
|
243
|
+
newById: new Map(), // id → new Stroke
|
|
244
|
+
created: [], // Stroke[] in creation order
|
|
245
|
+
groupExisting: [], // { id, groupId } injections into existing elements
|
|
246
|
+
deletes: [], // existing ids to remove
|
|
247
|
+
existingById: new Map(existing.map((a) => [a.id, a])),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function resolveTarget(ctx, idOrRef) {
|
|
252
|
+
if (typeof idOrRef !== 'string' || !idOrRef) return null;
|
|
253
|
+
const id = idOrRef.startsWith('@') ? ctx.refs.get(idOrRef) : idOrRef;
|
|
254
|
+
if (!id) return null;
|
|
255
|
+
const created = ctx.newById.get(id);
|
|
256
|
+
if (created) return { id, stroke: created, isNew: true };
|
|
257
|
+
const existing = ctx.existingById.get(id);
|
|
258
|
+
if (existing && BINDABLE_PARSED.has(existing.tool)) {
|
|
259
|
+
return { id, stroke: fakeHost(existing), isNew: false };
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function mint(ctx, ref) {
|
|
265
|
+
const id = rid();
|
|
266
|
+
if (typeof ref === 'string' && ref.startsWith('@')) ctx.refs.set(ref, id);
|
|
267
|
+
return id;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function autoPlace(ctx, w, op) {
|
|
271
|
+
const x = Number.isFinite(op.x) ? op.x : ctx.cursor.x;
|
|
272
|
+
const y = Number.isFinite(op.y) ? op.y : ctx.cursor.y;
|
|
273
|
+
if (!Number.isFinite(op.x)) ctx.cursor.x = x + w + 40;
|
|
274
|
+
return { x, y };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function pushCreated(ctx, stroke) {
|
|
278
|
+
ctx.created.push(stroke);
|
|
279
|
+
ctx.newById.set(stroke.id, stroke);
|
|
280
|
+
return stroke;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function createSticky(ctx, op) {
|
|
284
|
+
const w = Number.isFinite(op.w) ? op.w : STICKY_DEFAULT_W;
|
|
285
|
+
const h = Number.isFinite(op.h) ? op.h : STICKY_DEFAULT_H;
|
|
286
|
+
const { x, y } = autoPlace(ctx, w, op);
|
|
287
|
+
return pushCreated(ctx, {
|
|
288
|
+
id: mint(ctx, op.ref),
|
|
289
|
+
tool: 'sticky',
|
|
290
|
+
color: typeof op.color === 'string' ? op.color : DEFAULT_STICKY_COLOR,
|
|
291
|
+
x,
|
|
292
|
+
y,
|
|
293
|
+
w,
|
|
294
|
+
h,
|
|
295
|
+
text: typeof op.text === 'string' ? op.text : '',
|
|
296
|
+
fontSize: Number.isFinite(op.fontSize) ? op.fontSize : DEFAULT_FONT_SIZE,
|
|
297
|
+
cornerRadius: STICKY_CORNER_RADIUS,
|
|
298
|
+
author: 'ai',
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function createSection(ctx, op) {
|
|
303
|
+
const w = Number.isFinite(op.w) ? op.w : SECTION_DEFAULT_W;
|
|
304
|
+
const h = Number.isFinite(op.h) ? op.h : SECTION_DEFAULT_H;
|
|
305
|
+
const { x, y } = autoPlace(ctx, w, op);
|
|
306
|
+
return pushCreated(ctx, {
|
|
307
|
+
id: mint(ctx, op.ref),
|
|
308
|
+
tool: 'section',
|
|
309
|
+
x,
|
|
310
|
+
y,
|
|
311
|
+
w,
|
|
312
|
+
h,
|
|
313
|
+
label: typeof op.label === 'string' && op.label ? op.label : 'Section',
|
|
314
|
+
color: typeof op.color === 'string' ? op.color : DEFAULT_SECTION_COLOR,
|
|
315
|
+
author: 'ai',
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function createText(ctx, op) {
|
|
320
|
+
const { x, y } = autoPlace(ctx, 160, op);
|
|
321
|
+
return pushCreated(ctx, {
|
|
322
|
+
id: mint(ctx, op.ref),
|
|
323
|
+
tool: 'text',
|
|
324
|
+
color: typeof op.color === 'string' ? op.color : TEXT_INK,
|
|
325
|
+
fontSize: Number.isFinite(op.fontSize) ? op.fontSize : DEFAULT_FONT_SIZE,
|
|
326
|
+
text: typeof op.text === 'string' ? op.text : '',
|
|
327
|
+
x,
|
|
328
|
+
y,
|
|
329
|
+
author: 'ai',
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function createShape(ctx, op) {
|
|
334
|
+
const w = Number.isFinite(op.w) ? op.w : NODE_W;
|
|
335
|
+
const h = Number.isFinite(op.h) ? op.h : NODE_H;
|
|
336
|
+
const { x, y } = autoPlace(ctx, w, op);
|
|
337
|
+
const ink = typeof op.color === 'string' ? op.color : NODE_INK;
|
|
338
|
+
const fill = op.fill === null ? null : typeof op.fill === 'string' ? op.fill : NODE_FILL;
|
|
339
|
+
const kind = typeof op.shape === 'string' ? op.shape : 'rounded';
|
|
340
|
+
const id = mint(ctx, op.ref);
|
|
341
|
+
let shape;
|
|
342
|
+
if (kind === 'ellipse' || kind === 'circle') {
|
|
343
|
+
shape = {
|
|
344
|
+
id,
|
|
345
|
+
tool: 'ellipse',
|
|
346
|
+
color: ink,
|
|
347
|
+
width: 3,
|
|
348
|
+
cx: x + w / 2,
|
|
349
|
+
cy: y + h / 2,
|
|
350
|
+
rx: w / 2,
|
|
351
|
+
ry: h / 2,
|
|
352
|
+
fill,
|
|
353
|
+
author: 'ai',
|
|
354
|
+
};
|
|
355
|
+
} else if (kind === 'diamond' || kind === 'triangle' || kind === 'triangle-down') {
|
|
356
|
+
shape = {
|
|
357
|
+
id,
|
|
358
|
+
tool: 'polygon',
|
|
359
|
+
shape: kind,
|
|
360
|
+
color: ink,
|
|
361
|
+
width: 3,
|
|
362
|
+
x,
|
|
363
|
+
y,
|
|
364
|
+
w,
|
|
365
|
+
h,
|
|
366
|
+
fill,
|
|
367
|
+
author: 'ai',
|
|
368
|
+
};
|
|
369
|
+
} else {
|
|
370
|
+
shape = {
|
|
371
|
+
id,
|
|
372
|
+
tool: 'rect',
|
|
373
|
+
color: ink,
|
|
374
|
+
width: 3,
|
|
375
|
+
x,
|
|
376
|
+
y,
|
|
377
|
+
w,
|
|
378
|
+
h,
|
|
379
|
+
fill,
|
|
380
|
+
cornerRadius: kind === 'rect' || kind === 'square' ? 0 : 8,
|
|
381
|
+
author: 'ai',
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
pushCreated(ctx, shape);
|
|
385
|
+
if (typeof op.label === 'string' && op.label) {
|
|
386
|
+
// Anchored label — renders centered in the host (the canvas convention).
|
|
387
|
+
// Wave G widened anchored text to every closed shape, polygons included.
|
|
388
|
+
pushCreated(ctx, {
|
|
389
|
+
id: rid(),
|
|
390
|
+
tool: 'text',
|
|
391
|
+
color: TEXT_INK,
|
|
392
|
+
fontSize: DEFAULT_FONT_SIZE,
|
|
393
|
+
text: op.label,
|
|
394
|
+
anchorId: id,
|
|
395
|
+
author: 'ai',
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
return shape;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function createConnect(ctx, op) {
|
|
402
|
+
const from = resolveTarget(ctx, op.from);
|
|
403
|
+
const to = resolveTarget(ctx, op.to);
|
|
404
|
+
if (!from || !to) {
|
|
405
|
+
fail(
|
|
406
|
+
`connect: unknown ${!from ? `"from" (${op.from})` : `"to" (${op.to})`} — targets must be existing bindable ids or @refs created earlier in the batch`,
|
|
407
|
+
2
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
const fromCenter = centerOf(from.stroke);
|
|
411
|
+
const toCenter = centerOf(to.stroke);
|
|
412
|
+
const sb = facingAnchor(from.stroke, toCenter[0], toCenter[1]);
|
|
413
|
+
const eb = facingAnchor(to.stroke, fromCenter[0], fromCenter[1]);
|
|
414
|
+
if (!sb || !eb) fail('connect: could not derive anchors (zero-extent host?)', 2);
|
|
415
|
+
const p1 = anchorPoint(from.stroke, sb.nx, sb.ny);
|
|
416
|
+
const p2 = anchorPoint(to.stroke, eb.nx, eb.ny);
|
|
417
|
+
if (!p1 || !p2) fail('connect: could not derive endpoints', 2);
|
|
418
|
+
const arrow = pushCreated(ctx, {
|
|
419
|
+
id: mint(ctx, op.ref),
|
|
420
|
+
tool: 'arrow',
|
|
421
|
+
color: typeof op.color === 'string' ? op.color : DEFAULT_COLOR,
|
|
422
|
+
width: 3,
|
|
423
|
+
x1: p1[0],
|
|
424
|
+
y1: p1[1],
|
|
425
|
+
x2: p2[0],
|
|
426
|
+
y2: p2[1],
|
|
427
|
+
startBind: { hostId: from.id, nx: sb.nx, ny: sb.ny },
|
|
428
|
+
endBind: { hostId: to.id, nx: eb.nx, ny: eb.ny },
|
|
429
|
+
author: 'ai',
|
|
430
|
+
});
|
|
431
|
+
if (typeof op.label === 'string' && op.label) {
|
|
432
|
+
pushCreated(ctx, {
|
|
433
|
+
id: rid(),
|
|
434
|
+
tool: 'text',
|
|
435
|
+
color: TEXT_INK,
|
|
436
|
+
fontSize: 12,
|
|
437
|
+
text: op.label,
|
|
438
|
+
x: (p1[0] + p2[0]) / 2 + 6,
|
|
439
|
+
y: (p1[1] + p2[1]) / 2 - 18,
|
|
440
|
+
author: 'ai',
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
return arrow;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function centerOf(stroke) {
|
|
447
|
+
if (stroke.tool === 'ellipse') return [stroke.cx, stroke.cy];
|
|
448
|
+
return [stroke.x + (stroke.w ?? 0) / 2, stroke.y + (stroke.h ?? 0) / 2];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function applyGroup(ctx, op) {
|
|
452
|
+
const ids = Array.isArray(op.ids) ? op.ids : [];
|
|
453
|
+
const resolved = [];
|
|
454
|
+
for (const raw of ids) {
|
|
455
|
+
const id = typeof raw === 'string' && raw.startsWith('@') ? ctx.refs.get(raw) : raw;
|
|
456
|
+
if (!id) fail(`group: unknown ref ${raw}`, 2);
|
|
457
|
+
resolved.push(id);
|
|
458
|
+
}
|
|
459
|
+
if (resolved.length < 2) fail('group: needs at least two ids', 2);
|
|
460
|
+
const groupId = gid();
|
|
461
|
+
for (const id of resolved) {
|
|
462
|
+
const created = ctx.newById.get(id);
|
|
463
|
+
if (created) {
|
|
464
|
+
created.groupIds = [...(created.groupIds ?? []), groupId];
|
|
465
|
+
} else if (ctx.existingById.has(id)) {
|
|
466
|
+
ctx.groupExisting.push({ id, groupId });
|
|
467
|
+
} else {
|
|
468
|
+
fail(`group: id not found on canvas: ${id}`, 2);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function applyOps(ctx, ops) {
|
|
474
|
+
for (const op of ops) {
|
|
475
|
+
if (!op || typeof op !== 'object') fail('ops: every entry must be an object', 2);
|
|
476
|
+
if (op.op === 'create') {
|
|
477
|
+
if (op.type === 'sticky') createSticky(ctx, op);
|
|
478
|
+
else if (op.type === 'section') createSection(ctx, op);
|
|
479
|
+
else if (op.type === 'text') createText(ctx, op);
|
|
480
|
+
else if (op.type === 'shape') createShape(ctx, op);
|
|
481
|
+
else if (op.type === 'arrow') {
|
|
482
|
+
if ([op.x1, op.y1, op.x2, op.y2].every(Number.isFinite)) {
|
|
483
|
+
pushCreated(ctx, {
|
|
484
|
+
id: mint(ctx, op.ref),
|
|
485
|
+
tool: 'arrow',
|
|
486
|
+
color: typeof op.color === 'string' ? op.color : DEFAULT_COLOR,
|
|
487
|
+
width: 3,
|
|
488
|
+
x1: op.x1,
|
|
489
|
+
y1: op.y1,
|
|
490
|
+
x2: op.x2,
|
|
491
|
+
y2: op.y2,
|
|
492
|
+
author: 'ai',
|
|
493
|
+
});
|
|
494
|
+
} else {
|
|
495
|
+
createConnect(ctx, op); // from/to form
|
|
496
|
+
}
|
|
497
|
+
} else fail(`create: unknown type "${op.type}"`, 2);
|
|
498
|
+
} else if (op.op === 'connect') {
|
|
499
|
+
createConnect(ctx, op);
|
|
500
|
+
} else if (op.op === 'group') {
|
|
501
|
+
applyGroup(ctx, op);
|
|
502
|
+
} else if (op.op === 'delete') {
|
|
503
|
+
if (typeof op.id !== 'string' || !op.id) fail('delete: missing id', 2);
|
|
504
|
+
ctx.deletes.push(op.id);
|
|
505
|
+
} else {
|
|
506
|
+
fail(`ops: unknown op "${op.op}"`, 2);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
512
|
+
// Flow mode — layered left→right layout of nodes + bound connector edges.
|
|
513
|
+
|
|
514
|
+
function flowToOps(flow) {
|
|
515
|
+
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
|
|
516
|
+
const edges = Array.isArray(flow.edges) ? flow.edges : [];
|
|
517
|
+
if (!nodes.length) fail('flow: needs at least one node', 2);
|
|
518
|
+
const ids = nodes.map((n) => n.id);
|
|
519
|
+
const idSet = new Set(ids);
|
|
520
|
+
for (const e of edges) {
|
|
521
|
+
if (!idSet.has(e.from) || !idSet.has(e.to)) {
|
|
522
|
+
fail(`flow: edge references unknown node (${e.from} → ${e.to})`, 2);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// Longest-path layering via Kahn; cycle leftovers keep their seeded layer.
|
|
526
|
+
const indeg = new Map(ids.map((id) => [id, 0]));
|
|
527
|
+
const adj = new Map(ids.map((id) => [id, []]));
|
|
528
|
+
for (const e of edges) {
|
|
529
|
+
adj.get(e.from).push(e.to);
|
|
530
|
+
indeg.set(e.to, indeg.get(e.to) + 1);
|
|
531
|
+
}
|
|
532
|
+
const layer = new Map(ids.map((id) => [id, 0]));
|
|
533
|
+
const queue = ids.filter((id) => indeg.get(id) === 0);
|
|
534
|
+
while (queue.length) {
|
|
535
|
+
const id = queue.shift();
|
|
536
|
+
for (const t of adj.get(id)) {
|
|
537
|
+
layer.set(t, Math.max(layer.get(t), layer.get(id) + 1));
|
|
538
|
+
indeg.set(t, indeg.get(t) - 1);
|
|
539
|
+
if (indeg.get(t) === 0) queue.push(t);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
const byLayer = new Map();
|
|
543
|
+
for (const id of ids) {
|
|
544
|
+
const l = layer.get(id);
|
|
545
|
+
if (!byLayer.has(l)) byLayer.set(l, []);
|
|
546
|
+
byLayer.get(l).push(id);
|
|
547
|
+
}
|
|
548
|
+
const ops = [];
|
|
549
|
+
for (const [l, list] of [...byLayer.entries()].sort((a, b) => a[0] - b[0])) {
|
|
550
|
+
list.forEach((id, i) => {
|
|
551
|
+
const node = nodes.find((n) => n.id === id);
|
|
552
|
+
ops.push({
|
|
553
|
+
op: 'create',
|
|
554
|
+
type: 'shape',
|
|
555
|
+
shape: typeof node.shape === 'string' ? node.shape : 'rounded',
|
|
556
|
+
ref: `@${id}`,
|
|
557
|
+
label: typeof node.label === 'string' ? node.label : String(id),
|
|
558
|
+
// Relative to the placement origin — autoPlace sees explicit coords.
|
|
559
|
+
flowX: l * (NODE_W + FLOW_GAP_X),
|
|
560
|
+
flowY: i * (NODE_H + FLOW_GAP_Y),
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
for (const e of edges) {
|
|
565
|
+
ops.push({ op: 'connect', from: `@${e.from}`, to: `@${e.to}`, label: e.label });
|
|
566
|
+
}
|
|
567
|
+
return ops;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
571
|
+
// SVG assembly — deletes + group injections on the existing string, new
|
|
572
|
+
// strokes serialized through the canonical serializer, sanitize, cap.
|
|
573
|
+
|
|
574
|
+
function deleteElement(svg, id) {
|
|
575
|
+
const idEsc = escapeRe(id);
|
|
576
|
+
let out = svg.replace(
|
|
577
|
+
new RegExp(`<(g|text)\\b[^>]*data-id="${idEsc}"[^>]*>[\\s\\S]*?</\\1>`, 'g'),
|
|
578
|
+
''
|
|
579
|
+
);
|
|
580
|
+
out = out.replace(
|
|
581
|
+
new RegExp(`<(?:path|rect|ellipse|polygon|image)\\b[^>]*data-id="${idEsc}"[^>]*/>`, 'g'),
|
|
582
|
+
''
|
|
583
|
+
);
|
|
584
|
+
// Cascade — anchored text hosted by the deleted shape goes with it (the
|
|
585
|
+
// canvas deleteStrokes rule).
|
|
586
|
+
out = out.replace(
|
|
587
|
+
new RegExp(`<text\\b[^>]*data-anchor-id="${idEsc}"[^>]*>[\\s\\S]*?</text>`, 'g'),
|
|
588
|
+
''
|
|
589
|
+
);
|
|
590
|
+
return out;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function injectGroupAttr(svg, id, groupId) {
|
|
594
|
+
const idEsc = escapeRe(id);
|
|
595
|
+
const re = new RegExp(`(<[a-zA-Z]+\\b[^>]*data-id="${idEsc}"[^>]*?)(/?>)`);
|
|
596
|
+
return svg.replace(re, (_whole, head, close) => {
|
|
597
|
+
if (/data-group-ids="/.test(head)) {
|
|
598
|
+
return (
|
|
599
|
+
head.replace(
|
|
600
|
+
/data-group-ids="([^"]*)"/,
|
|
601
|
+
(_m, cur) => `data-group-ids="${cur} ${groupId}"`
|
|
602
|
+
) + close
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
return `${head} data-group-ids="${groupId}"${close}`;
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
610
|
+
// Main
|
|
611
|
+
|
|
612
|
+
async function main() {
|
|
613
|
+
const args = parseArgv(process.argv.slice(2));
|
|
614
|
+
if (args.help) {
|
|
615
|
+
process.stdout.write(`${HELP}\n`);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const relPath = args.positional[0];
|
|
619
|
+
if (!relPath) fail(`missing <rel-path>.\n\n${HELP}`, 2);
|
|
620
|
+
if (args.ops && args.flow) fail('--ops and --flow are mutually exclusive', 2);
|
|
621
|
+
|
|
622
|
+
const repoRoot = args.root
|
|
623
|
+
? resolve(args.root)
|
|
624
|
+
: process.env.CLAUDE_PROJECT_DIR
|
|
625
|
+
? resolve(process.env.CLAUDE_PROJECT_DIR)
|
|
626
|
+
: process.cwd();
|
|
627
|
+
const { designRel, designRoot } = resolveDesignRoot(repoRoot);
|
|
628
|
+
const slug = fileSlug(relPath, designRel);
|
|
629
|
+
const svgPath = join(designRoot, `${slug}.annotations.svg`);
|
|
630
|
+
|
|
631
|
+
let svg = '';
|
|
632
|
+
if (existsSync(svgPath)) {
|
|
633
|
+
try {
|
|
634
|
+
svg = readFileSync(svgPath, 'utf8');
|
|
635
|
+
} catch {
|
|
636
|
+
svg = '';
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const existing = parseAnnotations(svg);
|
|
640
|
+
|
|
641
|
+
const artboards = args.canvasState
|
|
642
|
+
? loadArtboards(
|
|
643
|
+
isAbsolute(args.canvasState) ? args.canvasState : resolve(process.cwd(), args.canvasState)
|
|
644
|
+
)
|
|
645
|
+
: [];
|
|
646
|
+
|
|
647
|
+
const ctx = buildContext(existing, artboards, args.near);
|
|
648
|
+
|
|
649
|
+
let ops;
|
|
650
|
+
if (args.flow != null) {
|
|
651
|
+
const flow = parseJsonInput(readInput(args.flow), '--flow');
|
|
652
|
+
ops = flowToOps(flow).map((op) =>
|
|
653
|
+
'flowX' in op ? { ...op, x: ctx.origin.x + op.flowX, y: ctx.origin.y + op.flowY } : op
|
|
654
|
+
);
|
|
655
|
+
} else {
|
|
656
|
+
const payload = parseJsonInput(readInput(args.ops), '--ops');
|
|
657
|
+
ops = Array.isArray(payload.ops) ? payload.ops : Array.isArray(payload) ? payload : null;
|
|
658
|
+
if (!ops) fail('ops: expected { ops: [...] } (or a bare array)', 2);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
applyOps(ctx, ops);
|
|
662
|
+
|
|
663
|
+
// Assemble. Deletes + group injections operate on the existing string; new
|
|
664
|
+
// strokes append before </svg> through the canonical serializer.
|
|
665
|
+
let merged = svg && /<svg[\s>]/i.test(svg) ? svg : `${SVG_HEADER}</svg>`;
|
|
666
|
+
for (const id of ctx.deletes) merged = deleteElement(merged, id);
|
|
667
|
+
for (const inj of ctx.groupExisting) merged = injectGroupAttr(merged, inj.id, inj.groupId);
|
|
668
|
+
if (ctx.created.length) {
|
|
669
|
+
const body = ctx.created.map((s) => strokeToSvgEl(s)).join('');
|
|
670
|
+
const close = merged.lastIndexOf('</svg>');
|
|
671
|
+
if (close < 0) fail('existing annotation file is not a valid SVG', 1);
|
|
672
|
+
merged = merged.slice(0, close) + body + merged.slice(close);
|
|
673
|
+
}
|
|
674
|
+
merged = sanitizeAnnotationSvg(merged);
|
|
675
|
+
const bytes = Buffer.byteLength(merged, 'utf8');
|
|
676
|
+
if (bytes > MAX_ANNOTATIONS_BYTES) {
|
|
677
|
+
fail(`result exceeds the 1 MB annotation cap (${bytes} bytes) — nothing written`, 1);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (args.dryRun) {
|
|
681
|
+
process.stdout.write(`${merged}\n`);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Prefer the live server (sanitize + persist + collab broadcast — open
|
|
686
|
+
// canvases update in real time); fall back to a direct file write.
|
|
687
|
+
let via = 'file';
|
|
688
|
+
const serverJsonPath = join(designRoot, '_server.json');
|
|
689
|
+
if (existsSync(serverJsonPath)) {
|
|
690
|
+
try {
|
|
691
|
+
const srv = JSON.parse(readFileSync(serverJsonPath, 'utf8'));
|
|
692
|
+
const base = typeof srv.url === 'string' ? srv.url.replace(/\/+$/, '') : null;
|
|
693
|
+
// Security (Wave H F2): `_server.json` is local dev-server state, but the
|
|
694
|
+
// verb runs in an AGENT loop — only ever PUT to a loopback http(s) origin
|
|
695
|
+
// so a poisoned/foreign `url` can't turn this into an SSRF primitive that
|
|
696
|
+
// exfiltrates the whole canvas SVG to an arbitrary host. On anything else
|
|
697
|
+
// (remote host, file:, javascript:) we silently fall back to the file
|
|
698
|
+
// write — the local intent still succeeds, the egress is denied.
|
|
699
|
+
if (base && isLoopbackHttpUrl(base)) {
|
|
700
|
+
const res = await fetch(`${base}/_api/annotations`, {
|
|
701
|
+
method: 'PUT',
|
|
702
|
+
headers: { 'Content-Type': 'application/json' },
|
|
703
|
+
body: JSON.stringify({ file: `${designRel}/${relPath}`, svg: merged }),
|
|
704
|
+
signal: AbortSignal.timeout(3000),
|
|
705
|
+
});
|
|
706
|
+
if (res.ok) via = 'server';
|
|
707
|
+
}
|
|
708
|
+
} catch {
|
|
709
|
+
/* stale _server.json / server down — fall through to the file write */
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (via === 'file') {
|
|
713
|
+
writeFileSync(svgPath, merged, 'utf8');
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const refs = {};
|
|
717
|
+
for (const [ref, id] of ctx.refs) refs[ref] = id;
|
|
718
|
+
process.stdout.write(
|
|
719
|
+
`${JSON.stringify({ ok: true, via, file: svgPath, bytes, created: ctx.created.length, deleted: ctx.deletes.length, refs })}\n`
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function parseJsonInput(raw, what) {
|
|
724
|
+
if (!raw?.trim()) fail(`${what}: empty input`, 2);
|
|
725
|
+
try {
|
|
726
|
+
return JSON.parse(raw);
|
|
727
|
+
} catch (err) {
|
|
728
|
+
fail(`${what}: invalid JSON — ${err?.message ?? err}`, 2);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
await main();
|