@1agh/maude 1.4.6 → 1.5.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.
Files changed (99) hide show
  1. package/apps/studio/annotations/ai-read.ts +288 -0
  2. package/apps/studio/annotations/ai-write.ts +533 -0
  3. package/apps/studio/annotations/board-io.ts +94 -0
  4. package/apps/studio/annotations/board-text.ts +45 -0
  5. package/apps/studio/annotations/constants.ts +58 -0
  6. package/apps/studio/annotations/elements/_shared.ts +125 -0
  7. package/apps/studio/annotations/elements/arrow.model.ts +196 -0
  8. package/apps/studio/annotations/elements/media.model.ts +79 -0
  9. package/apps/studio/annotations/elements/pen.model.ts +82 -0
  10. package/apps/studio/annotations/elements/section.model.ts +70 -0
  11. package/apps/studio/annotations/elements/shape.model.ts +141 -0
  12. package/apps/studio/annotations/elements/sticky.model.ts +48 -0
  13. package/apps/studio/annotations/elements/text.model.ts +52 -0
  14. package/apps/studio/annotations/fields.ts +347 -0
  15. package/apps/studio/annotations/fractional-index.ts +234 -0
  16. package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
  17. package/apps/studio/annotations/migrate-boot.ts +172 -0
  18. package/apps/studio/annotations/migrate-cli.ts +37 -0
  19. package/apps/studio/annotations/migrate-v1.ts +383 -0
  20. package/apps/studio/annotations/ops.ts +474 -0
  21. package/apps/studio/annotations/registry.ts +159 -0
  22. package/apps/studio/annotations/replica.ts +264 -0
  23. package/apps/studio/annotations/scene.ts +235 -0
  24. package/apps/studio/annotations/schema.ts +188 -0
  25. package/apps/studio/annotations/types.ts +77 -0
  26. package/apps/studio/annotations/ui/board.ts +126 -0
  27. package/apps/studio/annotations/ui/containment.ts +118 -0
  28. package/apps/studio/annotations/ui/edit-actions.ts +551 -0
  29. package/apps/studio/annotations/ui/editor-channel.ts +62 -0
  30. package/apps/studio/annotations/ui/element-node.tsx +993 -0
  31. package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
  32. package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
  33. package/apps/studio/annotations/ui/render-model.ts +264 -0
  34. package/apps/studio/annotations/ui/scene.tsx +63 -0
  35. package/apps/studio/annotations/ui/text-editor.tsx +420 -0
  36. package/apps/studio/annotations/ui/text-session.ts +140 -0
  37. package/apps/studio/annotations/ui/text-style.ts +111 -0
  38. package/apps/studio/annotations/ui/world.ts +47 -0
  39. package/apps/studio/annotations/v1-adapter.ts +428 -0
  40. package/apps/studio/annotations-align.ts +21 -6
  41. package/apps/studio/annotations-bindings.ts +2 -0
  42. package/apps/studio/annotations-context-toolbar.tsx +9 -13
  43. package/apps/studio/annotations-groups.ts +3 -0
  44. package/apps/studio/annotations-layer.tsx +923 -1925
  45. package/apps/studio/annotations-model.ts +65 -4
  46. package/apps/studio/annotations-sync.ts +4 -47
  47. package/apps/studio/api.ts +248 -69
  48. package/apps/studio/bin/_import-figma.mjs +22 -12
  49. package/apps/studio/bin/annotate.mjs +331 -838
  50. package/apps/studio/bin/annotate.sh +4 -4
  51. package/apps/studio/bin/perf.sh +21 -7
  52. package/apps/studio/bin/read-annotations.mjs +184 -666
  53. package/apps/studio/bin/read-annotations.sh +9 -5
  54. package/apps/studio/canvas-artifacts.ts +9 -0
  55. package/apps/studio/canvas-lib.tsx +13 -1
  56. package/apps/studio/canvas-shell.tsx +6 -0
  57. package/apps/studio/client/app.jsx +98 -35
  58. package/apps/studio/client/hmr.mjs +1 -1
  59. package/apps/studio/client/panels/git-grouping.js +2 -2
  60. package/apps/studio/client/tree-expansion.js +217 -0
  61. package/apps/studio/collab/index.ts +49 -5
  62. package/apps/studio/collab/persistence.ts +31 -10
  63. package/apps/studio/collab/registry.ts +63 -26
  64. package/apps/studio/commands/annotation-ops-command.ts +72 -0
  65. package/apps/studio/cursors-overlay.tsx +158 -4
  66. package/apps/studio/dist/client.bundle.js +850 -850
  67. package/apps/studio/dist/comment-mount.js +2 -2
  68. package/apps/studio/figma/to-strokes.ts +31 -10
  69. package/apps/studio/git/endpoints.ts +1 -1
  70. package/apps/studio/git/service.ts +1 -1
  71. package/apps/studio/git/watch.ts +1 -1
  72. package/apps/studio/http.ts +90 -15
  73. package/apps/studio/server.ts +16 -0
  74. package/apps/studio/sync/accepted-cold-start.ts +12 -4
  75. package/apps/studio/sync/agent.ts +8 -4
  76. package/apps/studio/sync/codec.ts +95 -40
  77. package/apps/studio/sync/file-membership.ts +12 -2
  78. package/apps/studio/sync/file-plane.ts +1 -1
  79. package/apps/studio/sync/index.ts +38 -16
  80. package/apps/studio/sync/journal-client.ts +5 -0
  81. package/apps/studio/sync/limits.ts +7 -2
  82. package/apps/studio/sync/migrate-seed.ts +8 -5
  83. package/apps/studio/sync/projection.ts +7 -2
  84. package/apps/studio/sync/remote-docs.ts +34 -0
  85. package/apps/studio/sync/writer-registry.ts +10 -0
  86. package/apps/studio/text-caret.ts +11 -2
  87. package/apps/studio/tree-state.ts +45 -0
  88. package/apps/studio/undo-stack.ts +2 -2
  89. package/apps/studio/use-annotation-resize.tsx +48 -23
  90. package/apps/studio/use-annotation-selection.tsx +9 -2
  91. package/apps/studio/use-collab.tsx +76 -0
  92. package/apps/studio/whats-new.json +27 -0
  93. package/cli/lib/design-link.mjs +5 -1
  94. package/cli/lib/gitignore-block.mjs +1 -1
  95. package/cli/lib/gitignore-drift.mjs +2 -1
  96. package/package.json +9 -8
  97. package/plugins/design/templates/brief-board.tsx.template +1 -1
  98. package/apps/studio/annotation-edit-base.ts +0 -36
  99. package/apps/studio/commands/annotation-strokes-command.ts +0 -137
@@ -0,0 +1,234 @@
1
+ /**
2
+ * @file annotations/fractional-index.ts — z-order keys (DDR-242 §1)
3
+ * @scope apps/studio/annotations/fractional-index.ts
4
+ * @purpose Fractional indexing: a key strictly between any two keys, so a
5
+ * reorder writes ONE element instead of renumbering its neighbours,
6
+ * and two peers reordering concurrently converge.
7
+ *
8
+ * Port of the algorithm in rocicorp/fractional-indexing
9
+ * (https://github.com/rocicorp/fractional-indexing, CC0-1.0 — public
10
+ * domain; kept in-tree so the hub, a DDR-054 "untrusted to peers"
11
+ * component with a frozen lockfile, takes no new dependency).
12
+ *
13
+ * Two peers inserting between the same two keys produce the SAME
14
+ * key — order is therefore always `(index, id)` (`compareOrder`),
15
+ * never index alone.
16
+ */
17
+
18
+ export const BASE_62_DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
19
+ const ZERO = '0';
20
+ const SMALLEST_INTEGER = `A${ZERO.repeat(26)}`;
21
+
22
+ function midpoint(a: string, b: string | null): string {
23
+ if (b != null && a >= b) throw new Error(`${a} >= ${b}`);
24
+ if (a.slice(-1) === ZERO || (b && b.slice(-1) === ZERO)) throw new Error('trailing zero');
25
+ if (b) {
26
+ let n = 0;
27
+ while ((a[n] || ZERO) === b[n]) n++;
28
+ if (n > 0) return b.slice(0, n) + midpoint(a.slice(n), b.slice(n));
29
+ }
30
+ const digitA = a ? BASE_62_DIGITS.indexOf(a[0] as string) : 0;
31
+ const digitB = b != null ? BASE_62_DIGITS.indexOf(b[0] as string) : BASE_62_DIGITS.length;
32
+ if (digitB - digitA > 1) {
33
+ return BASE_62_DIGITS[Math.round(0.5 * (digitA + digitB))] as string;
34
+ }
35
+ if (b && b.length > 1) return b.slice(0, 1);
36
+ return (BASE_62_DIGITS[digitA] as string) + midpoint(a.slice(1), null);
37
+ }
38
+
39
+ function integerLength(head: string): number {
40
+ if (head >= 'a' && head <= 'z') return head.charCodeAt(0) - 97 + 2;
41
+ if (head >= 'A' && head <= 'Z') return 90 - head.charCodeAt(0) + 2;
42
+ throw new Error(`invalid order key head: ${head}`);
43
+ }
44
+
45
+ function integerPart(key: string): string {
46
+ const len = integerLength(key[0] as string);
47
+ if (len > key.length) throw new Error(`invalid order key: ${key}`);
48
+ return key.slice(0, len);
49
+ }
50
+
51
+ /** True when `key` is a well-formed order key (used to validate untrusted input). */
52
+ export function isValidOrderKey(key: string): boolean {
53
+ try {
54
+ if (key === SMALLEST_INTEGER) return false;
55
+ if (!/^[0-9A-Za-z]+$/.test(key)) return false;
56
+ const i = integerPart(key);
57
+ return key.slice(i.length).slice(-1) !== ZERO;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ function assertKey(key: string): void {
64
+ if (!isValidOrderKey(key)) throw new Error(`invalid order key: ${key}`);
65
+ }
66
+
67
+ function incrementInteger(x: string): string | null {
68
+ const [head, ...digs] = x.split('') as [string, ...string[]];
69
+ let carry = true;
70
+ for (let i = digs.length - 1; carry && i >= 0; i--) {
71
+ const d = BASE_62_DIGITS.indexOf(digs[i] as string) + 1;
72
+ if (d === BASE_62_DIGITS.length) digs[i] = ZERO;
73
+ else {
74
+ digs[i] = BASE_62_DIGITS[d] as string;
75
+ carry = false;
76
+ }
77
+ }
78
+ if (!carry) return head + digs.join('');
79
+ if (head === 'Z') return `a${ZERO}`;
80
+ if (head === 'z') return null;
81
+ const h = String.fromCharCode(head.charCodeAt(0) + 1);
82
+ if (h > 'a') digs.push(ZERO);
83
+ else digs.pop();
84
+ return h + digs.join('');
85
+ }
86
+
87
+ function decrementInteger(x: string): string | null {
88
+ const [head, ...digs] = x.split('') as [string, ...string[]];
89
+ let borrow = true;
90
+ for (let i = digs.length - 1; borrow && i >= 0; i--) {
91
+ const d = BASE_62_DIGITS.indexOf(digs[i] as string) - 1;
92
+ if (d === -1) digs[i] = BASE_62_DIGITS.slice(-1);
93
+ else {
94
+ digs[i] = BASE_62_DIGITS[d] as string;
95
+ borrow = false;
96
+ }
97
+ }
98
+ if (!borrow) return head + digs.join('');
99
+ if (head === 'a') return `Z${BASE_62_DIGITS.slice(-1)}`;
100
+ if (head === 'A') return null;
101
+ const h = String.fromCharCode(head.charCodeAt(0) - 1);
102
+ if (h < 'Z') digs.push(BASE_62_DIGITS.slice(-1));
103
+ else digs.pop();
104
+ return h + digs.join('');
105
+ }
106
+
107
+ /** A key strictly between `a` and `b` (null = open end). Throws if `a >= b`. */
108
+ export function keyBetween(a: string | null, b: string | null): string {
109
+ if (a != null) assertKey(a);
110
+ if (b != null) assertKey(b);
111
+ if (a != null && b != null && a >= b) throw new Error(`${a} >= ${b}`);
112
+ if (a == null) {
113
+ if (b == null) return `a${ZERO}`;
114
+ const ib = integerPart(b);
115
+ const fb = b.slice(ib.length);
116
+ if (ib === SMALLEST_INTEGER) return ib + midpoint('', fb);
117
+ if (ib < b) return ib;
118
+ const res = decrementInteger(ib);
119
+ if (res == null) throw new Error('cannot decrement any more');
120
+ return res;
121
+ }
122
+ if (b == null) {
123
+ const ia = integerPart(a);
124
+ const fa = a.slice(ia.length);
125
+ const i = incrementInteger(ia);
126
+ return i == null ? ia + midpoint(fa, null) : i;
127
+ }
128
+ const ia = integerPart(a);
129
+ const fa = a.slice(ia.length);
130
+ const ib = integerPart(b);
131
+ const fb = b.slice(ib.length);
132
+ if (ia === ib) return ia + midpoint(fa, fb);
133
+ const i = incrementInteger(ia);
134
+ if (i == null) throw new Error('cannot increment any more');
135
+ if (i < b) return i;
136
+ return ia + midpoint(fa, null);
137
+ }
138
+
139
+ /** `n` ascending keys strictly between `a` and `b`, evenly spread. */
140
+ export function keysBetween(a: string | null, b: string | null, n: number): string[] {
141
+ if (n <= 0) return [];
142
+ if (n === 1) return [keyBetween(a, b)];
143
+ if (b == null) {
144
+ let c = keyBetween(a, b);
145
+ const out = [c];
146
+ for (let i = 0; i < n - 1; i++) {
147
+ c = keyBetween(c, b);
148
+ out.push(c);
149
+ }
150
+ return out;
151
+ }
152
+ if (a == null) {
153
+ let c = keyBetween(a, b);
154
+ const out = [c];
155
+ for (let i = 0; i < n - 1; i++) {
156
+ c = keyBetween(a, c);
157
+ out.push(c);
158
+ }
159
+ return out.reverse();
160
+ }
161
+ const mid = Math.floor(n / 2);
162
+ const c = keyBetween(a, b);
163
+ return [...keysBetween(a, c, mid), c, ...keysBetween(c, b, n - mid - 1)];
164
+ }
165
+
166
+ /** Total paint order: index (code-unit order), ties broken by id. */
167
+ export function compareOrder(
168
+ a: { index: string; id: string },
169
+ b: { index: string; id: string }
170
+ ): number {
171
+ if (a.index !== b.index) return a.index < b.index ? -1 : 1;
172
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
173
+ }
174
+
175
+ /**
176
+ * Keys for `ids` (desired order) that reuse `prev` keys where they are already
177
+ * in order (longest increasing run), minting new keys only for moved items.
178
+ */
179
+ export function orderKeys(
180
+ ids: readonly string[],
181
+ prev: ReadonlyMap<string, string>
182
+ ): Map<string, string> {
183
+ const out = new Map<string, string>();
184
+ // Longest increasing subsequence over the items that have a previous key.
185
+ const cand = ids
186
+ .map((id, i) => ({ id, i, k: prev.get(id) }))
187
+ .filter((c) => c.k !== undefined) as Array<{ id: string; i: number; k: string }>;
188
+ const tails: number[] = [];
189
+ const back: number[] = new Array(cand.length).fill(-1);
190
+ for (let j = 0; j < cand.length; j++) {
191
+ const k = (cand[j] as { k: string }).k;
192
+ let lo = 0;
193
+ let hi = tails.length;
194
+ while (lo < hi) {
195
+ const mid = (lo + hi) >> 1;
196
+ if ((cand[tails[mid] as number] as { k: string }).k < k) lo = mid + 1;
197
+ else hi = mid;
198
+ }
199
+ if (lo > 0) back[j] = tails[lo - 1] as number;
200
+ tails[lo] = j;
201
+ }
202
+ const keep = new Set<string>();
203
+ for (
204
+ let j = tails.length ? (tails[tails.length - 1] as number) : -1;
205
+ j >= 0;
206
+ j = back[j] as number
207
+ ) {
208
+ keep.add((cand[j] as { id: string }).id);
209
+ }
210
+ for (const id of keep) out.set(id, prev.get(id) as string);
211
+ // Fill the gaps between kept keys.
212
+ let i = 0;
213
+ while (i < ids.length) {
214
+ if (keep.has(ids[i] as string)) {
215
+ i++;
216
+ continue;
217
+ }
218
+ let j = i;
219
+ while (j < ids.length && !keep.has(ids[j] as string)) j++;
220
+ const lo = i > 0 ? (out.get(ids[i - 1] as string) ?? null) : null;
221
+ const hi = j < ids.length ? (out.get(ids[j] as string) ?? null) : null;
222
+ let keys: string[];
223
+ try {
224
+ keys = keysBetween(lo, hi, j - i);
225
+ } catch {
226
+ // Degenerate neighbours — renumber the whole sibling list.
227
+ const all = keysBetween(null, null, ids.length);
228
+ return new Map(ids.map((id, n) => [id, all[n] as string]));
229
+ }
230
+ for (let n = i; n < j; n++) out.set(ids[n] as string, keys[n - i] as string);
231
+ i = j;
232
+ }
233
+ return out;
234
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * @file annotations/legacy/mini-dom.ts — DOM-free parse of a legacy annotation SVG
3
+ * @scope apps/studio/annotations/legacy/mini-dom.ts
4
+ * @purpose The v1 parser (`strokesFromDocument` in annotations-model.ts)
5
+ * reads through a tiny slice of the DOM API. The hub runs on Node
6
+ * (no DOMParser) yet must upconvert legacy SVG history blobs, and
7
+ * the migration must give the SAME result on every host — so every
8
+ * v2 consumer parses legacy SVG through this one tree, never a
9
+ * browser DOMParser.
10
+ *
11
+ * Supports exactly what the fixed annotation vocabulary needs:
12
+ * elements, quoted attributes, text, the five XML entities plus
13
+ * numeric references, comments / processing instructions (skipped),
14
+ * and the selectors `[data-tool]`, `[attr]` and bare tag names.
15
+ * Malformed input yields a document containing `<parsererror>`, which
16
+ * the v1 parser already treats as "no strokes".
17
+ *
18
+ * `outerHTML` only matters for elements WITHOUT a `data-id` (the
19
+ * v1 `stableAnnotationId` fallback). Its bytes may differ from a
20
+ * browser's serializer; ids of such hand-authored elements are
21
+ * therefore stable across v2 hosts, not necessarily equal to what a
22
+ * v1 browser tab computed. Canonical v1 output always carries data-id.
23
+ */
24
+
25
+ import type { SvgDocLike, SvgElLike } from '../../annotations-model.ts';
26
+
27
+ const MAX_DEPTH = 64;
28
+ const MAX_NODES = 200_000;
29
+
30
+ function decodeEntities(s: string): string {
31
+ return s.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|amp|lt|gt|quot|apos);/g, (m, ent: string) => {
32
+ if (ent === 'amp') return '&';
33
+ if (ent === 'lt') return '<';
34
+ if (ent === 'gt') return '>';
35
+ if (ent === 'quot') return '"';
36
+ if (ent === 'apos') return "'";
37
+ const cp = ent.startsWith('#x')
38
+ ? Number.parseInt(ent.slice(2), 16)
39
+ : Number.parseInt(ent.slice(1), 10);
40
+ return Number.isFinite(cp) && cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
41
+ });
42
+ }
43
+
44
+ function escText(s: string): string {
45
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
46
+ }
47
+
48
+ function escAttr(s: string): string {
49
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
50
+ }
51
+
52
+ type Node = MiniElement | string;
53
+
54
+ export class MiniElement implements SvgElLike {
55
+ readonly tagName: string;
56
+ readonly attrs: Array<[string, string]>;
57
+ readonly children: Node[] = [];
58
+
59
+ constructor(tagName: string, attrs: Array<[string, string]>) {
60
+ this.tagName = tagName;
61
+ this.attrs = attrs;
62
+ }
63
+
64
+ getAttribute(name: string): string | null {
65
+ for (const [k, v] of this.attrs) if (k === name) return v;
66
+ return null;
67
+ }
68
+
69
+ private matches(selector: string): boolean {
70
+ const attr = /^\[([\w:-]+)\]$/.exec(selector);
71
+ if (attr) return this.getAttribute(attr[1] as string) !== null;
72
+ const local = this.tagName.split(':').pop()?.toLowerCase();
73
+ return local === selector.toLowerCase();
74
+ }
75
+
76
+ private *walk(): Generator<MiniElement> {
77
+ for (const c of this.children) {
78
+ if (typeof c === 'string') continue;
79
+ yield c;
80
+ yield* c.walk();
81
+ }
82
+ }
83
+
84
+ querySelector(selector: string): MiniElement | null {
85
+ for (const el of this.walk()) if (el.matches(selector)) return el;
86
+ return null;
87
+ }
88
+
89
+ querySelectorAll(selector: string): MiniElement[] {
90
+ const out: MiniElement[] = [];
91
+ for (const el of this.walk()) if (el.matches(selector)) out.push(el);
92
+ return out;
93
+ }
94
+
95
+ get textContent(): string {
96
+ let out = '';
97
+ for (const c of this.children) out += typeof c === 'string' ? c : c.textContent;
98
+ return out;
99
+ }
100
+
101
+ get outerHTML(): string {
102
+ const attrs = this.attrs.map(([k, v]) => ` ${k}="${escAttr(v)}"`).join('');
103
+ if (!this.children.length) return `<${this.tagName}${attrs}/>`;
104
+ const inner = this.children
105
+ .map((c) => (typeof c === 'string' ? escText(c) : c.outerHTML))
106
+ .join('');
107
+ return `<${this.tagName}${attrs}>${inner}</${this.tagName}>`;
108
+ }
109
+ }
110
+
111
+ function errorDoc(): MiniElement {
112
+ const root = new MiniElement('#document', []);
113
+ root.children.push(new MiniElement('parsererror', []));
114
+ return root;
115
+ }
116
+
117
+ /**
118
+ * Parse SVG text into a document-like tree. Never throws.
119
+ *
120
+ * A single forward scan with `indexOf` — never a regex that can re-scan the
121
+ * input: an unterminated `<!--`, `<?`, CDATA or tag is a parse error found in
122
+ * one pass (the defender review measured the earlier all-in-one regex at
123
+ * quadratic time on `<!--` repeated — minutes of a blocked event loop on a
124
+ * 4 MB body, reachable from the canvas origin and from peers).
125
+ */
126
+ export function parseMiniDom(text: string): SvgDocLike & MiniElement {
127
+ const root = new MiniElement('#document', []);
128
+ const stack: MiniElement[] = [root];
129
+ const top = () => stack[stack.length - 1] as MiniElement;
130
+ const closeRe = /^<\/\s*([\w:-]+)\s*>$/;
131
+ const openRe = /^<([\w:-]+)((?:\s+[\w:-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>$/;
132
+ const attrRe = /([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
133
+ const len = text.length;
134
+ let i = 0;
135
+ let nodes = 0;
136
+ while (i < len) {
137
+ const lt = text.indexOf('<', i);
138
+ const chunk = text.slice(i, lt < 0 ? len : lt);
139
+ if (chunk) top().children.push(decodeEntities(chunk));
140
+ if (lt < 0) break;
141
+ if (++nodes > MAX_NODES) return errorDoc();
142
+ const skipTo = (terminator: string, from: number): number => {
143
+ const end = text.indexOf(terminator, from);
144
+ return end < 0 ? -1 : end + terminator.length;
145
+ };
146
+ if (text.startsWith('<!--', lt)) {
147
+ i = skipTo('-->', lt + 4);
148
+ if (i < 0) return errorDoc();
149
+ continue;
150
+ }
151
+ if (text.startsWith('<?', lt)) {
152
+ i = skipTo('?>', lt + 2);
153
+ if (i < 0) return errorDoc();
154
+ continue;
155
+ }
156
+ if (text.startsWith('<![CDATA[', lt)) {
157
+ const end = text.indexOf(']]>', lt + 9);
158
+ if (end < 0) return errorDoc();
159
+ top().children.push(text.slice(lt + 9, end));
160
+ i = end + 3;
161
+ continue;
162
+ }
163
+ if (text.startsWith('<!', lt)) {
164
+ i = skipTo('>', lt + 2);
165
+ if (i < 0) return errorDoc();
166
+ continue;
167
+ }
168
+ // A tag: scan to its closing '>' honouring quoted attribute values.
169
+ let j = lt + 1;
170
+ let quote = '';
171
+ while (j < len) {
172
+ const ch = text[j] as string;
173
+ if (quote) {
174
+ if (ch === quote) quote = '';
175
+ } else if (ch === '"' || ch === "'") quote = ch;
176
+ else if (ch === '>') break;
177
+ else if (ch === '<') return errorDoc();
178
+ j++;
179
+ }
180
+ if (j >= len) return errorDoc();
181
+ const tag = text.slice(lt, j + 1);
182
+ i = j + 1;
183
+ const close = closeRe.exec(tag);
184
+ if (close) {
185
+ const open = stack.pop();
186
+ if (!open || open === root || open.tagName !== close[1]) return errorDoc();
187
+ continue;
188
+ }
189
+ const m = openRe.exec(tag);
190
+ if (!m) return errorDoc();
191
+ const attrs: Array<[string, string]> = [];
192
+ let a: RegExpExecArray | null;
193
+ attrRe.lastIndex = 0;
194
+ // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
195
+ while ((a = attrRe.exec(m[2] ?? '')) !== null) {
196
+ attrs.push([a[1] as string, decodeEntities(a[2] ?? a[3] ?? '')]);
197
+ }
198
+ const el = new MiniElement(m[1] as string, attrs);
199
+ top().children.push(el);
200
+ if (!m[3]) {
201
+ if (stack.length > MAX_DEPTH) return errorDoc();
202
+ stack.push(el);
203
+ }
204
+ }
205
+ if (stack.length !== 1) return errorDoc();
206
+ return root;
207
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * @file annotations/migrate-boot.ts — one-shot v1 → v2 migration of board files
3
+ * @scope apps/studio/annotations/migrate-boot.ts
4
+ * @purpose DDR-242 §6. For every `<slug>.annotations.svg` at the design root:
5
+ * • no `<slug>.annotations.json` yet → convert (the pure
6
+ * `migrateSvg`), write the board atomically, snapshot the
7
+ * original to `_history/<slug>/pre-annotations-v2/`, move the SVG
8
+ * to `_trash/annotations-v1/`;
9
+ * • a board already exists → the SVG is STALE (an old branch
10
+ * checkout, a v1 peer) and is quarantined to
11
+ * `_trash/annotations-v1/stale-…`, never merged over the board.
12
+ * Idempotent (the steady state is a no-op), never throws, never
13
+ * deletes: every original survives under `_history/` or `_trash/`.
14
+ * Mirrors sync/migrate-flat-fallback.ts. A lock under `_state/` keeps
15
+ * two processes booting at once from racing the same files.
16
+ */
17
+
18
+ import {
19
+ existsSync,
20
+ lstatSync,
21
+ mkdirSync,
22
+ readdirSync,
23
+ readFileSync,
24
+ realpathSync,
25
+ renameSync,
26
+ rmSync,
27
+ statSync,
28
+ writeFileSync,
29
+ } from 'node:fs';
30
+ import path from 'node:path';
31
+
32
+ import { MAX_LEGACY_SVG_BYTES } from './constants.ts';
33
+ import { migrateSvg } from './migrate-v1.ts';
34
+ import { serializeBoard } from './schema.ts';
35
+
36
+ export interface AnnotationsMigration {
37
+ slug: string;
38
+ action: 'migrated' | 'stale-quarantined' | 'failed';
39
+ elements?: number;
40
+ /** Elements that could not be carried over (from the migration report). */
41
+ dropped?: number;
42
+ detail?: string;
43
+ }
44
+
45
+ const LOCK = path.join('_state', 'annotations-v2-migrate.lock');
46
+ const LOCK_STALE_MS = 60_000;
47
+
48
+ function takeLock(designRoot: string, realRoot: string): (() => void) | null {
49
+ const abs = path.join(designRoot, LOCK);
50
+ try {
51
+ containedDir(realRoot, path.dirname(abs));
52
+ if (existsSync(abs) && Date.now() - statSync(abs).mtimeMs > LOCK_STALE_MS)
53
+ rmSync(abs, { force: true });
54
+ writeFileSync(abs, String(process.pid), { flag: 'wx' });
55
+ return () => rmSync(abs, { force: true });
56
+ } catch {
57
+ return null; // another process is migrating right now — it will finish the job
58
+ }
59
+ }
60
+
61
+ function stamp(): string {
62
+ return new Date().toISOString().replace(/[:.]/g, '-');
63
+ }
64
+
65
+ /** `dir/name`, or a timestamped sibling when that is taken — a re-migration
66
+ * (the SVG came back) never overwrites the first original (code review M7). */
67
+ function freshPath(dir: string, name: string): string {
68
+ const plain = path.join(dir, name);
69
+ return existsSync(plain) ? path.join(dir, `${stamp()}-${name}`) : plain;
70
+ }
71
+
72
+ /**
73
+ * Create `dir` and prove it resolves inside the design root. `_history`,
74
+ * `_trash` and `_state` are ordinary directories a synced or checked-out
75
+ * project controls — a symlink among them must not turn a boot-time write
76
+ * into a write anywhere on disk (security review, defender #3).
77
+ */
78
+ function containedDir(realRoot: string, dir: string): string {
79
+ const inside = (p: string) => {
80
+ const real = realpathSync(p);
81
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
82
+ throw new Error(`${path.relative(realRoot, dir)} resolves outside the design root`);
83
+ }
84
+ };
85
+ // Check the deepest EXISTING ancestor before creating anything: a recursive
86
+ // mkdir would already have followed a symlinked `_trash` out of the root.
87
+ let probe = dir;
88
+ while (!existsSync(probe) && path.dirname(probe) !== probe) probe = path.dirname(probe);
89
+ inside(probe);
90
+ mkdirSync(dir, { recursive: true });
91
+ inside(dir);
92
+ return dir;
93
+ }
94
+
95
+ export function migrateAnnotationsV2(opts: {
96
+ designRoot: string;
97
+ log?: (line: string) => void;
98
+ }): AnnotationsMigration[] {
99
+ const { designRoot } = opts;
100
+ const log = opts.log ?? ((line: string) => console.log(line));
101
+ const out: AnnotationsMigration[] = [];
102
+ let names: string[];
103
+ try {
104
+ names = readdirSync(designRoot).filter((n) => n.toLowerCase().endsWith('.annotations.svg'));
105
+ } catch {
106
+ return out;
107
+ }
108
+ if (!names.length) return out;
109
+ let realRoot: string;
110
+ try {
111
+ realRoot = realpathSync(designRoot);
112
+ } catch {
113
+ return out;
114
+ }
115
+ const release = takeLock(designRoot, realRoot);
116
+ if (!release) return out;
117
+ try {
118
+ for (const name of names) {
119
+ const slug = name.slice(0, -'.annotations.svg'.length);
120
+ const svgAbs = path.join(designRoot, name);
121
+ const jsonAbs = path.join(designRoot, `${slug}.annotations.json`);
122
+ try {
123
+ // Only a regular file is migrated — never a symlink's target — and its
124
+ // size is checked before a byte is read (untrusted content, DDR-054).
125
+ const st = lstatSync(svgAbs, { throwIfNoEntry: false });
126
+ if (!st) continue;
127
+ if (!st.isFile()) throw new Error('not a regular file');
128
+ if (st.size > MAX_LEGACY_SVG_BYTES) {
129
+ throw new Error(`larger than ${MAX_LEGACY_SVG_BYTES} bytes`);
130
+ }
131
+ const trashDir = containedDir(realRoot, path.join(designRoot, '_trash', 'annotations-v1'));
132
+ if (existsSync(jsonAbs)) {
133
+ const to = path.join(trashDir, `stale-${stamp()}-${name}`);
134
+ renameSync(svgAbs, to);
135
+ out.push({ slug, action: 'stale-quarantined', detail: path.relative(designRoot, to) });
136
+ log(
137
+ `[annotations] ${name} reappeared next to ${slug}.annotations.json — quarantined to ${path.relative(designRoot, to)} (the board was not changed)`
138
+ );
139
+ continue;
140
+ }
141
+ const text = readFileSync(svgAbs, 'utf8');
142
+ const { elements, report } = migrateSvg(text);
143
+ // Snapshot first: if anything below fails, the original is still in place.
144
+ const snapDir = containedDir(
145
+ realRoot,
146
+ path.join(designRoot, '_history', slug, 'pre-annotations-v2')
147
+ );
148
+ writeFileSync(freshPath(snapDir, name), text, { flag: 'wx' });
149
+ const stateDir = containedDir(realRoot, path.join(designRoot, '_state'));
150
+ const tmp = path.join(stateDir, `annotations-v2-${slug}-${process.pid}.tmp`);
151
+ writeFileSync(tmp, serializeBoard(elements));
152
+ renameSync(tmp, jsonAbs);
153
+ renameSync(svgAbs, freshPath(trashDir, name));
154
+ out.push({ slug, action: 'migrated', elements: elements.length, dropped: report.length });
155
+ log(
156
+ `[annotations] migrated ${name} → ${slug}.annotations.json (${elements.length} elements${
157
+ report.length
158
+ ? `, ${report.length} not carried over — see _history/${slug}/pre-annotations-v2/`
159
+ : ''
160
+ })`
161
+ );
162
+ for (const r of report) log(`[annotations] ${r.id ?? '(board)'}: ${r.reason}`);
163
+ } catch (err) {
164
+ out.push({ slug, action: 'failed', detail: (err as Error).message });
165
+ log(`[annotations] could not migrate ${name}: ${(err as Error).message} — left in place`);
166
+ }
167
+ }
168
+ } finally {
169
+ release();
170
+ }
171
+ return out;
172
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @file annotations/migrate-cli.ts — run the v1 → v2 board migration by hand
3
+ * @scope apps/studio/annotations/migrate-cli.ts
4
+ * @purpose `bun apps/studio/annotations/migrate-cli.ts <designRoot> [--in-place]`
5
+ *
6
+ * Default: the boot migration (`migrateAnnotationsV2`) — originals
7
+ * kept under `_history/` + `_trash/`. `--in-place` converts each
8
+ * `*.annotations.svg` to its `.json` sibling and removes the SVG,
9
+ * with NO runtime dirs — for committed fixtures, where git holds
10
+ * the original.
11
+ */
12
+
13
+ import { readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { migrateAnnotationsV2 } from './migrate-boot.ts';
16
+ import { migrateSvg } from './migrate-v1.ts';
17
+ import { serializeBoard } from './schema.ts';
18
+
19
+ const [root, flag] = process.argv.slice(2);
20
+ if (!root) {
21
+ console.error('usage: migrate-cli.ts <designRoot> [--in-place]');
22
+ process.exit(2);
23
+ }
24
+ if (flag === '--in-place') {
25
+ for (const name of readdirSync(root).filter((n) => n.endsWith('.annotations.svg'))) {
26
+ const abs = path.join(root, name);
27
+ const { elements, report } = migrateSvg(readFileSync(abs, 'utf8'));
28
+ writeFileSync(abs.replace(/\.svg$/, '.json'), serializeBoard(elements));
29
+ rmSync(abs);
30
+ console.log(
31
+ `${name} → ${elements.length} elements${report.length ? ` (${report.length} not carried over)` : ''}`
32
+ );
33
+ }
34
+ } else {
35
+ const r = migrateAnnotationsV2({ designRoot: root });
36
+ console.log(`${r.length} board file(s) processed`);
37
+ }