@1agh/maude 0.29.0 → 0.30.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 +1 -1
- 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 +1708 -1660
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +208 -116
- 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/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-shell.tsx +305 -8
- package/apps/studio/client/app.jsx +2812 -238
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/styles/3-shell-maude.css +713 -11
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +16 -2
- package/apps/studio/client/tour/overlay.jsx +105 -17
- package/apps/studio/client/tour/usage-tour.js +23 -10
- 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 +20 -20
- package/apps/studio/dist/client.bundle.js +3322 -602
- package/apps/studio/dist/comment-mount.js +95 -9
- package/apps/studio/dist/styles.css +2490 -167
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/history.ts +20 -3
- package/apps/studio/http.ts +124 -5
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +1 -7
- package/apps/studio/server.ts +5 -2
- package/apps/studio/sync/agent.ts +231 -57
- package/apps/studio/sync/codec.ts +45 -0
- package/apps/studio/sync/cold-start.ts +158 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- 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/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-edit.test.ts +181 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +8 -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/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +52 -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/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-start.test.ts +244 -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/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 +12 -2
- 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-collab.tsx +20 -15
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +135 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +2 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +28 -4
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations-snap.ts — FigJam v3 drag snapping + smart guides
|
|
3
|
+
* @scope apps/studio/annotations-snap.ts
|
|
4
|
+
* @purpose Pure math (no React, no DOM) for edge/center snapping while
|
|
5
|
+
* dragging annotation strokes. Candidates are the bboxes of the
|
|
6
|
+
* non-moved strokes plus the artboard rects; per axis the nearest
|
|
7
|
+
* candidate line within the threshold wins and contributes one
|
|
8
|
+
* guide line for the overlay to paint. Hold ⌘ to suppress
|
|
9
|
+
* (handled by the caller — Figma/FigJam convention).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface SnapBox {
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
w: number;
|
|
16
|
+
h: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SnapGuide {
|
|
20
|
+
/** `x` = a vertical guide at x=`at`; `y` = a horizontal guide at y=`at`. */
|
|
21
|
+
axis: 'x' | 'y';
|
|
22
|
+
at: number;
|
|
23
|
+
from: number;
|
|
24
|
+
to: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SnapResult {
|
|
28
|
+
dx: number;
|
|
29
|
+
dy: number;
|
|
30
|
+
guides: SnapGuide[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Snap threshold in world px at zoom 1 (callers scale by 1/zoom). */
|
|
34
|
+
export const SNAP_THRESHOLD_PX = 6;
|
|
35
|
+
|
|
36
|
+
/** Dot-grid pitch in world px — mirrors the DS `--canvas-grid` token (24px). */
|
|
37
|
+
export const GRID_PITCH_PX = 24;
|
|
38
|
+
|
|
39
|
+
const NO_SNAP: SnapResult = { dx: 0, dy: 0, guides: [] };
|
|
40
|
+
|
|
41
|
+
function lines(b: SnapBox, axis: 'x' | 'y'): [number, number, number] {
|
|
42
|
+
return axis === 'x' ? [b.x, b.x + b.w / 2, b.x + b.w] : [b.y, b.y + b.h / 2, b.y + b.h];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Nearest grid-line correction for one axis position. The dot grid is a
|
|
47
|
+
* world-space lattice at `pitch`; the stroke's leading edge snaps to it.
|
|
48
|
+
*/
|
|
49
|
+
function gridDelta(pos: number, pitch: number, threshold: number): number | null {
|
|
50
|
+
const nearest = Math.round(pos / pitch) * pitch;
|
|
51
|
+
const d = nearest - pos;
|
|
52
|
+
return Math.abs(d) <= threshold ? d : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Best snap correction for the moving bbox against the candidates. The two
|
|
57
|
+
* axes resolve independently (FigJam: a drag can snap horizontally to one
|
|
58
|
+
* neighbour and vertically to another). Edges AND centers participate.
|
|
59
|
+
*
|
|
60
|
+
* Grid snapping (`opts.grid`): when an axis finds NO stroke/artboard snap, its
|
|
61
|
+
* leading edge falls back to the canvas dot grid — weaker than smart guides
|
|
62
|
+
* (geometry alignment wins over the lattice) and silent (no guide line, the
|
|
63
|
+
* dots themselves are the visual). ⌘ suppresses both via the caller.
|
|
64
|
+
*/
|
|
65
|
+
export function computeSnap(
|
|
66
|
+
moving: SnapBox,
|
|
67
|
+
candidates: readonly SnapBox[],
|
|
68
|
+
threshold: number,
|
|
69
|
+
opts?: { grid?: number }
|
|
70
|
+
): SnapResult {
|
|
71
|
+
const grid = opts?.grid ?? 0;
|
|
72
|
+
if (threshold <= 0 || (candidates.length === 0 && grid <= 0)) return NO_SNAP;
|
|
73
|
+
let bestX: { d: number; at: number; cand: SnapBox } | null = null;
|
|
74
|
+
let bestY: { d: number; at: number; cand: SnapBox } | null = null;
|
|
75
|
+
const mx = lines(moving, 'x');
|
|
76
|
+
const my = lines(moving, 'y');
|
|
77
|
+
for (const c of candidates) {
|
|
78
|
+
const cx = lines(c, 'x');
|
|
79
|
+
const cy = lines(c, 'y');
|
|
80
|
+
for (const m of mx) {
|
|
81
|
+
for (const v of cx) {
|
|
82
|
+
const d = v - m;
|
|
83
|
+
if (Math.abs(d) <= threshold && (!bestX || Math.abs(d) < Math.abs(bestX.d))) {
|
|
84
|
+
bestX = { d, at: v, cand: c };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const m of my) {
|
|
89
|
+
for (const v of cy) {
|
|
90
|
+
const d = v - m;
|
|
91
|
+
if (Math.abs(d) <= threshold && (!bestY || Math.abs(d) < Math.abs(bestY.d))) {
|
|
92
|
+
bestY = { d, at: v, cand: c };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Grid fallback per axis — only where no smart-guide candidate won.
|
|
98
|
+
let gridDx: number | null = null;
|
|
99
|
+
let gridDy: number | null = null;
|
|
100
|
+
if (grid > 0) {
|
|
101
|
+
if (!bestX) gridDx = gridDelta(moving.x, grid, threshold);
|
|
102
|
+
if (!bestY) gridDy = gridDelta(moving.y, grid, threshold);
|
|
103
|
+
}
|
|
104
|
+
if (!bestX && !bestY && gridDx == null && gridDy == null) return NO_SNAP;
|
|
105
|
+
const dx = bestX?.d ?? gridDx ?? 0;
|
|
106
|
+
const dy = bestY?.d ?? gridDy ?? 0;
|
|
107
|
+
const guides: SnapGuide[] = [];
|
|
108
|
+
if (bestX) {
|
|
109
|
+
guides.push({
|
|
110
|
+
axis: 'x',
|
|
111
|
+
at: bestX.at,
|
|
112
|
+
from: Math.min(moving.y + dy, bestX.cand.y),
|
|
113
|
+
to: Math.max(moving.y + moving.h + dy, bestX.cand.y + bestX.cand.h),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (bestY) {
|
|
117
|
+
guides.push({
|
|
118
|
+
axis: 'y',
|
|
119
|
+
at: bestY.at,
|
|
120
|
+
from: Math.min(moving.x + dx, bestY.cand.x),
|
|
121
|
+
to: Math.max(moving.x + moving.w + dx, bestY.cand.x + bestY.cand.w),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return { dx, dy, guides };
|
|
125
|
+
}
|
package/apps/studio/api.ts
CHANGED
|
@@ -7,6 +7,12 @@ import { mkdir, readdir, readFile, rename, stat as statp } from 'node:fs/promise
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
|
|
9
9
|
import { renderBriefBoard, validateCanvasName } from './canvas-create.ts';
|
|
10
|
+
import {
|
|
11
|
+
CanvasEditError,
|
|
12
|
+
editAttribute,
|
|
13
|
+
removeAttribute,
|
|
14
|
+
editText as runEditText,
|
|
15
|
+
} from './canvas-edit.ts';
|
|
10
16
|
import type { Context } from './context.ts';
|
|
11
17
|
|
|
12
18
|
const SKIP_DIRS = new Set([
|
|
@@ -141,6 +147,11 @@ export type DeleteCanvasResult =
|
|
|
141
147
|
| { ok: true; rel: string; slug: string; trashed: string[]; trashDir: string }
|
|
142
148
|
| { ok: false; status: number; error: string };
|
|
143
149
|
|
|
150
|
+
/** Phase 12 — result of an in-canvas direct edit (`editCss` / `editText`). */
|
|
151
|
+
export type EditOpResult =
|
|
152
|
+
| { ok: true; delta: number }
|
|
153
|
+
| { ok: false; status: number; error: string };
|
|
154
|
+
|
|
144
155
|
export interface Api {
|
|
145
156
|
// File tree
|
|
146
157
|
fileSlug(file: string): string;
|
|
@@ -190,6 +201,25 @@ export interface Api {
|
|
|
190
201
|
}): Promise<CreateCanvasResult>;
|
|
191
202
|
// Soft-delete a canvas from the browser (Phase 22 — DELETE /_api/canvas)
|
|
192
203
|
deleteCanvas(input: { file?: unknown }): Promise<DeleteCanvasResult>;
|
|
204
|
+
// Phase 12 (DDR-103) — single-property inline CSS edit (POST /_api/edit-css).
|
|
205
|
+
// Main-origin only: writes one key into the element's inline `style={{}}` object.
|
|
206
|
+
editCss(input: {
|
|
207
|
+
canvas?: unknown;
|
|
208
|
+
id?: unknown;
|
|
209
|
+
property?: unknown;
|
|
210
|
+
value?: unknown;
|
|
211
|
+
}): Promise<EditOpResult>;
|
|
212
|
+
// Phase 12 (DDR-103) — inline text-content edit (POST /_api/edit-text). Main-origin only.
|
|
213
|
+
editText(input: { canvas?: unknown; id?: unknown; text?: unknown }): Promise<EditOpResult>;
|
|
214
|
+
// Phase 12.2 (DDR-104) — custom HTML attribute edit (POST /_api/edit-attr). Main-origin
|
|
215
|
+
// only. The CSS panel's "custom HTML attribute" escape hatch (data-*, aria-*, role, …);
|
|
216
|
+
// writes a plain JSX attribute via editAttribute's non-`style.` path.
|
|
217
|
+
editAttr(input: {
|
|
218
|
+
canvas?: unknown;
|
|
219
|
+
id?: unknown;
|
|
220
|
+
attr?: unknown;
|
|
221
|
+
value?: unknown;
|
|
222
|
+
}): Promise<EditOpResult>;
|
|
193
223
|
// Aggregate data
|
|
194
224
|
buildIndexData(): Promise<unknown>;
|
|
195
225
|
buildSystemData(dsName?: string | null): Promise<unknown>;
|
|
@@ -204,45 +234,14 @@ export interface ApiHooks {
|
|
|
204
234
|
onAnnotationsChanged?: (file: string, svg: string) => void;
|
|
205
235
|
}
|
|
206
236
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
'g',
|
|
214
|
-
'path',
|
|
215
|
-
'rect',
|
|
216
|
-
'ellipse',
|
|
217
|
-
'line',
|
|
218
|
-
'polyline',
|
|
219
|
-
// Phase 24 — `polygon` (diamond / triangle shape primitives + closed arrowhead
|
|
220
|
-
// outlines + diamond heads) and `circle` (circle arrowhead). Inert shape
|
|
221
|
-
// elements with no script capability — same safety class as the rest.
|
|
222
|
-
'polygon',
|
|
223
|
-
'circle',
|
|
224
|
-
'text',
|
|
225
|
-
// Annotation polish (item 4a) — `tspan` carries one line of multi-line text
|
|
226
|
-
// inside a `<text>` (SVG `<text>` ignores `\n`). Inert presentational element
|
|
227
|
-
// with no script capability — same safety class as `text`. Only `x`/`dy`
|
|
228
|
-
// (geometry) survive; the attribute denylist strips any on*/style/href.
|
|
229
|
-
'tspan',
|
|
230
|
-
// Phase 23 — `image` (dropped/pasted raster). The ONLY element allowed to keep
|
|
231
|
-
// an href, and ONLY a relative assets/<sha8>.<ext> path (ASSET_IMAGE_HREF_RE) —
|
|
232
|
-
// every external / data: / javascript: / `..` href is still stripped. <image>
|
|
233
|
-
// is a passive include with no script capability. See DDR (Task 9).
|
|
234
|
-
'image',
|
|
235
|
-
]);
|
|
237
|
+
// FigJam v3 — the annotation sanitizer moved to annotations-model.ts (the
|
|
238
|
+
// schema owner; the allowlist guards exactly that vocabulary, and the
|
|
239
|
+
// headless `maude design annotate` write verb needs it without pulling the
|
|
240
|
+
// server modules). Re-exported here so every existing `from './api.ts'`
|
|
241
|
+
// import keeps working unchanged.
|
|
242
|
+
export { ASSET_IMAGE_HREF_RE, sanitizeAnnotationSvg } from './annotations-model.ts';
|
|
236
243
|
|
|
237
|
-
|
|
238
|
-
* Phase 23 — the ONLY href shape allowed to survive on an `<image>`: a relative,
|
|
239
|
-
* single-segment `assets/<name>.<ext>` path. Anchored (`^…$`), no scheme, no `/`
|
|
240
|
-
* beyond the one `assets/` segment (blocks `assets/../../etc/passwd`), no query,
|
|
241
|
-
* ext ∈ the four raster types the upload route accepts. Everything else —
|
|
242
|
-
* external, `data:`, `javascript:`, `..`, or any href on a non-image element —
|
|
243
|
-
* is stripped by Rule 3. Pairs with the asset-write caps (DDR Task 9).
|
|
244
|
-
*/
|
|
245
|
-
export const ASSET_IMAGE_HREF_RE = /^assets\/[A-Za-z0-9._-]+\.(?:png|jpe?g|webp|gif)$/;
|
|
244
|
+
import { sanitizeAnnotationSvg } from './annotations-model.ts';
|
|
246
245
|
|
|
247
246
|
/** Phase 23 — hard ceiling on a single uploaded asset (10 MB). */
|
|
248
247
|
export const ASSET_MAX_BYTES = 10 * 1024 * 1024;
|
|
@@ -323,84 +322,6 @@ export interface SaveAssetResult {
|
|
|
323
322
|
path?: string;
|
|
324
323
|
}
|
|
325
324
|
|
|
326
|
-
/**
|
|
327
|
-
* A3 (DDR-060 F1 re-audit) — sanitize an annotation SVG before it is persisted /
|
|
328
|
-
* synced / mirrored into the collab Y.Map. ALLOWLIST, not denylist (the F1
|
|
329
|
-
* confirming pass showed a denylist loses the race: `<svg:script>` via namespace,
|
|
330
|
-
* `javascript:` via HTML entity, `<style>@import url()>`, CSS `url(javascript:)`
|
|
331
|
-
* all slipped a tag-name denylist). Two rules, both keyed to the fixed legit
|
|
332
|
-
* vocabulary so they're zero-regression on real annotations:
|
|
333
|
-
*
|
|
334
|
-
* 1. Element allowlist — drop the markup of any tag whose LOCAL name (namespace
|
|
335
|
-
* stripped, so `svg:script` → `script`) isn't in ANNOTATION_SVG_ELEMENTS.
|
|
336
|
-
* Dropped-tag text content survives as inert text (not in a script/style
|
|
337
|
-
* context), which is harmless.
|
|
338
|
-
* 2. Attribute denylist on survivors — the legit vocabulary uses none of
|
|
339
|
-
* `on*=`, `style=`, or any `href`, so stripping them closes inline handlers,
|
|
340
|
-
* CSS `url(javascript:)`, and entity-encoded `xlink:href` in one pass.
|
|
341
|
-
*
|
|
342
|
-
* The current consumer (`svgToStrokes` → DOMParser image/svg+xml → structured
|
|
343
|
-
* strokes → React) never raw-renders this string, so the live XSS risk is already
|
|
344
|
-
* nil; this keeps "inert" TRUE for any future raw-render consumer and for the
|
|
345
|
-
* synced file a peer / Claude-context ingests (defense-in-depth, DDR-054 §3).
|
|
346
|
-
*/
|
|
347
|
-
export function sanitizeAnnotationSvg(svg: string): string {
|
|
348
|
-
// Phase 23 — <image> href handling. Rule 3 below strips EVERY href (that's the
|
|
349
|
-
// zero-bypass invariant). To let a legit assets/ href survive WITHOUT
|
|
350
|
-
// re-opening the denylist race, we (a) neutralize any input-supplied marker so
|
|
351
|
-
// only this pre-pass can mint one, (b) on `<image>` ONLY, hoist a regex-valid
|
|
352
|
-
// assets href into a sanitizer-inert `data-mdcc-asset` marker (an external /
|
|
353
|
-
// data: / `..` / non-image href is left as a plain href → stripped by Rule 3),
|
|
354
|
-
// then (c) after the three rules, restore the marker back to `href` — but ONLY
|
|
355
|
-
// after RE-validating the value, so a forged marker can never smuggle a
|
|
356
|
-
// scheme/traversal back in. The restored value is, by construction, a safe
|
|
357
|
-
// same-origin path. See ASSET_IMAGE_HREF_RE + DDR (Task 9).
|
|
358
|
-
const hoisted = svg
|
|
359
|
-
.replace(/\sdata-mdcc-asset\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
|
360
|
-
.replace(/<\s*(?:[\w-]+:)?image\b[^>]*>/gi, (tag) => {
|
|
361
|
-
const m = tag.match(/(?:xlink:)?href\s*=\s*"([^"]*)"|(?:xlink:)?href\s*=\s*'([^']*)'/i);
|
|
362
|
-
const val = m ? (m[1] ?? m[2] ?? '') : '';
|
|
363
|
-
if (!val || !ASSET_IMAGE_HREF_RE.test(val)) return tag;
|
|
364
|
-
return tag.replace(/(?:xlink:)?href\s*=\s*("[^"]*"|'[^']*')/i, `data-mdcc-asset="${val}"`);
|
|
365
|
-
});
|
|
366
|
-
const cleaned = hoisted
|
|
367
|
-
// 1. Remove the CONTENT of executable / instruction-bearing elements (not
|
|
368
|
-
// just their tags) so an injected script body / `@import` / prompt-
|
|
369
|
-
// injection string can't survive as inert text a future raw-renderer or
|
|
370
|
-
// Claude-context read might act on. Namespace-tolerant (`svg:script`),
|
|
371
|
-
// non-greedy to the first matching dangerous close tag.
|
|
372
|
-
.replace(
|
|
373
|
-
/<\s*(?:[\w-]+:)?(?:script|style|foreignObject|title|desc)\b[\s\S]*?<\s*\/\s*(?:[\w-]+:)?(?:script|style|foreignObject|title|desc)\s*>/gi,
|
|
374
|
-
''
|
|
375
|
-
)
|
|
376
|
-
// 2. Element allowlist — drop the markup of any tag whose LOCAL name isn't
|
|
377
|
-
// in the fixed annotation vocabulary. `[^>]*` stops at the first `>`;
|
|
378
|
-
// annotation attrs never contain a literal `>`.
|
|
379
|
-
.replace(/<\/?\s*([a-zA-Z][\w:-]*)\b[^>]*>/g, (match, rawName: string) => {
|
|
380
|
-
const local = String(rawName).split(':').pop()?.toLowerCase() ?? '';
|
|
381
|
-
return ANNOTATION_SVG_ELEMENTS.has(local) ? match : '';
|
|
382
|
-
})
|
|
383
|
-
// 3. Attribute denylist on the surviving allowlisted elements — the legit
|
|
384
|
-
// vocabulary uses no on*= / style= / *href=, so stripping them closes
|
|
385
|
-
// inline handlers, CSS url(javascript:), and entity-encoded hrefs.
|
|
386
|
-
// The leading boundary is a LOOKBEHIND on whitespace / quote / slash
|
|
387
|
-
// (not a consumed `\s`) so a handler glued to the previous attribute's
|
|
388
|
-
// closing quote — `<circle r="2"onload="…"/>`, which HTML/SVG parsers
|
|
389
|
-
// accept as a distinct attribute — is also stripped (Phase 24 security
|
|
390
|
-
// review, DDR-067). The non-consuming lookbehind leaves the preceding
|
|
391
|
-
// quote intact so the legit attribute it belonged to survives.
|
|
392
|
-
.replace(
|
|
393
|
-
/(?<=[\s"'/])(?:on[a-z]+|style|(?:[\w-]+:)?href)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi,
|
|
394
|
-
''
|
|
395
|
-
);
|
|
396
|
-
// Post-pass — restore the validated assets href on <image>. Re-validate the
|
|
397
|
-
// marker value (defense-in-depth: a forged data-mdcc-asset can only resolve to
|
|
398
|
-
// a safe same-origin assets path; anything with a scheme/traversal is dropped).
|
|
399
|
-
return cleaned.replace(/\sdata-mdcc-asset\s*=\s*"([^"]*)"/gi, (_whole, val: string) =>
|
|
400
|
-
ASSET_IMAGE_HREF_RE.test(val) ? ` href="${val}"` : ''
|
|
401
|
-
);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
325
|
export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
405
326
|
const onCommentsChanged = hooks.onCommentsChanged;
|
|
406
327
|
const onAnnotationsChanged = hooks.onAnnotationsChanged;
|
|
@@ -1126,6 +1047,174 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1126
1047
|
};
|
|
1127
1048
|
}
|
|
1128
1049
|
|
|
1050
|
+
// Phase 12 (DDR-103) — resolve a v2 canvas slug (`selected.canvas`: POSIX,
|
|
1051
|
+
// extension-less, designRoot-relative — matches `_locator.json` keys + the
|
|
1052
|
+
// `canvasSlug()` shape) to its absolute `.tsx` path, with a containment
|
|
1053
|
+
// backstop. Same main-origin-only trust boundary as createCanvas: the
|
|
1054
|
+
// untrusted canvas iframe origin never reaches a source-write endpoint.
|
|
1055
|
+
function resolveCanvasAbs(
|
|
1056
|
+
slugRaw: unknown
|
|
1057
|
+
): { ok: true; abs: string } | { ok: false; status: number; error: string } {
|
|
1058
|
+
if (typeof slugRaw !== 'string' || !slugRaw.trim()) {
|
|
1059
|
+
return { ok: false, status: 400, error: 'canvas (slug) required' };
|
|
1060
|
+
}
|
|
1061
|
+
let slug = slugRaw.replace(/^\/+|\/+$/g, '').replace(/\.(tsx|html)$/i, '');
|
|
1062
|
+
// Accept either the bare slug (`ui/Foo`) or the designRel-prefixed file path
|
|
1063
|
+
// the client `selected.file` carries (`.design/ui/Foo.tsx`) — strip a leading
|
|
1064
|
+
// designRel so both shapes resolve to the same canvas (mirrors deriveCanvasSlug).
|
|
1065
|
+
const dr = paths.designRel.replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
|
|
1066
|
+
if (dr && slug.startsWith(`${dr}/`)) slug = slug.slice(dr.length + 1);
|
|
1067
|
+
if (
|
|
1068
|
+
!slug ||
|
|
1069
|
+
path.isAbsolute(slug) ||
|
|
1070
|
+
slug.split('/').some((seg) => seg === '..' || seg === '.' || seg === '')
|
|
1071
|
+
) {
|
|
1072
|
+
return { ok: false, status: 400, error: 'invalid canvas slug' };
|
|
1073
|
+
}
|
|
1074
|
+
const abs = `${path.join(paths.designRoot, ...slug.split('/'))}.tsx`;
|
|
1075
|
+
const resolvedRoot = path.resolve(paths.designRoot);
|
|
1076
|
+
const resolved = path.resolve(abs);
|
|
1077
|
+
if (!resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
1078
|
+
return { ok: false, status: 400, error: 'canvas resolves outside the design root' };
|
|
1079
|
+
}
|
|
1080
|
+
return { ok: true, abs };
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// 8-hex lowercase, the shape `computeId` (canvas-edit.ts) stamps on data-cd-id.
|
|
1084
|
+
const CD_ID_RE = /^[0-9a-f]{8}$/;
|
|
1085
|
+
|
|
1086
|
+
async function editCss(input: {
|
|
1087
|
+
canvas?: unknown;
|
|
1088
|
+
id?: unknown;
|
|
1089
|
+
property?: unknown;
|
|
1090
|
+
value?: unknown;
|
|
1091
|
+
reset?: unknown;
|
|
1092
|
+
}): Promise<EditOpResult> {
|
|
1093
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
1094
|
+
if (!r.ok) return r;
|
|
1095
|
+
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
1096
|
+
if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
|
|
1097
|
+
const property = typeof input.property === 'string' ? input.property.trim() : '';
|
|
1098
|
+
// CSS property names are ASCII letters + hyphens only (optionally a leading
|
|
1099
|
+
// `-` for vendor prefixes) — reject anything that could smuggle a second key
|
|
1100
|
+
// or an expression into the inline style object.
|
|
1101
|
+
if (!property || !/^-?[a-z][a-z-]*$/.test(property)) {
|
|
1102
|
+
return { ok: false, status: 400, error: 'invalid css property' };
|
|
1103
|
+
}
|
|
1104
|
+
// kebab → camelCase JSX style key.
|
|
1105
|
+
const camel = property.replace(/-([a-z])/g, (_m, c: string) => c.toUpperCase());
|
|
1106
|
+
// Phase 12.3 — `reset: true` REMOVES the inline property (back to class /
|
|
1107
|
+
// inherited). No value needed; a missing key is a no-op (delta 0).
|
|
1108
|
+
if (input.reset === true) {
|
|
1109
|
+
try {
|
|
1110
|
+
const res = await removeAttribute(r.abs, id, `style.${camel}`);
|
|
1111
|
+
return { ok: true, delta: res.delta };
|
|
1112
|
+
} catch (err) {
|
|
1113
|
+
return {
|
|
1114
|
+
ok: false,
|
|
1115
|
+
status: 422,
|
|
1116
|
+
error: err instanceof CanvasEditError ? err.message : 'reset failed',
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
const value = typeof input.value === 'string' ? input.value : '';
|
|
1121
|
+
if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
|
|
1122
|
+
if (value.length > 256) return { ok: false, status: 413, error: 'value too long' };
|
|
1123
|
+
// The value is always written as a JS STRING literal: JSON.stringify escapes
|
|
1124
|
+
// quotes/backslashes/newlines so it can never break out of the string, and
|
|
1125
|
+
// React accepts string values for every style prop — so `var(--accent)`,
|
|
1126
|
+
// `#fff`, `8px`, `700`, `1.5` all ride verbatim.
|
|
1127
|
+
try {
|
|
1128
|
+
const res = await editAttribute(r.abs, id, `style.${camel}`, JSON.stringify(value));
|
|
1129
|
+
return { ok: true, delta: res.delta };
|
|
1130
|
+
} catch (err) {
|
|
1131
|
+
return {
|
|
1132
|
+
ok: false,
|
|
1133
|
+
status: 422,
|
|
1134
|
+
error: err instanceof CanvasEditError ? err.message : 'edit failed',
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
async function editText(input: {
|
|
1140
|
+
canvas?: unknown;
|
|
1141
|
+
id?: unknown;
|
|
1142
|
+
text?: unknown;
|
|
1143
|
+
}): Promise<EditOpResult> {
|
|
1144
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
1145
|
+
if (!r.ok) return r;
|
|
1146
|
+
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
1147
|
+
if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
|
|
1148
|
+
if (typeof input.text !== 'string') return { ok: false, status: 400, error: 'text required' };
|
|
1149
|
+
if (input.text.length > 5000) return { ok: false, status: 413, error: 'text too long' };
|
|
1150
|
+
try {
|
|
1151
|
+
const res = await runEditText(r.abs, id, input.text);
|
|
1152
|
+
return { ok: true, delta: res.delta };
|
|
1153
|
+
} catch (err) {
|
|
1154
|
+
return {
|
|
1155
|
+
ok: false,
|
|
1156
|
+
status: 422,
|
|
1157
|
+
error: err instanceof CanvasEditError ? err.message : 'edit failed',
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
async function editAttr(input: {
|
|
1163
|
+
canvas?: unknown;
|
|
1164
|
+
id?: unknown;
|
|
1165
|
+
attr?: unknown;
|
|
1166
|
+
value?: unknown;
|
|
1167
|
+
reset?: unknown;
|
|
1168
|
+
}): Promise<EditOpResult> {
|
|
1169
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
1170
|
+
if (!r.ok) return r;
|
|
1171
|
+
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
1172
|
+
if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
|
|
1173
|
+
const attr = typeof input.attr === 'string' ? input.attr.trim() : '';
|
|
1174
|
+
// Plain HTML attributes only — data-*, aria-*, role, title, id, lang, dir…
|
|
1175
|
+
// Reject `style*` (that's /_api/edit-css), `data-cd-id` (pipeline-owned, also
|
|
1176
|
+
// refused downstream by editAttribute), and anything that isn't a bare html
|
|
1177
|
+
// attribute name. Digits allowed mid-name (e.g. data-2x).
|
|
1178
|
+
if (
|
|
1179
|
+
!attr ||
|
|
1180
|
+
!/^[a-z][a-z0-9-]*$/.test(attr) ||
|
|
1181
|
+
attr === 'data-cd-id' ||
|
|
1182
|
+
attr.startsWith('style')
|
|
1183
|
+
) {
|
|
1184
|
+
return { ok: false, status: 400, error: 'invalid attribute' };
|
|
1185
|
+
}
|
|
1186
|
+
// Phase 12.3 — `reset: true` REMOVES the custom attribute. No-op if absent.
|
|
1187
|
+
if (input.reset === true) {
|
|
1188
|
+
try {
|
|
1189
|
+
const res = await removeAttribute(r.abs, id, attr);
|
|
1190
|
+
return { ok: true, delta: res.delta };
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
return {
|
|
1193
|
+
ok: false,
|
|
1194
|
+
status: 422,
|
|
1195
|
+
error: err instanceof CanvasEditError ? err.message : 'reset failed',
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
const value = typeof input.value === 'string' ? input.value : '';
|
|
1200
|
+
if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
|
|
1201
|
+
if (value.length > 256) return { ok: false, status: 413, error: 'value too long' };
|
|
1202
|
+
try {
|
|
1203
|
+
// Non-`style.` attr name → editAttribute writes a plain quoted JSX attribute.
|
|
1204
|
+
// Pass the value RAW: editStringAttr quotes/escapes it itself (JSON.stringify
|
|
1205
|
+
// on replace, escapeAttr on insert) — pre-stringifying here double-encoded
|
|
1206
|
+
// the value (`data-x="\"ok\""`; knob-smoke finding, 2026-06-12).
|
|
1207
|
+
const res = await editAttribute(r.abs, id, attr, value);
|
|
1208
|
+
return { ok: true, delta: res.delta };
|
|
1209
|
+
} catch (err) {
|
|
1210
|
+
return {
|
|
1211
|
+
ok: false,
|
|
1212
|
+
status: 422,
|
|
1213
|
+
error: err instanceof CanvasEditError ? err.message : 'edit failed',
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1129
1218
|
async function saveCanvasState(file: string, state: Record<string, unknown>) {
|
|
1130
1219
|
if (!state || typeof state !== 'object') return;
|
|
1131
1220
|
const safe: Record<string, unknown> = {};
|
|
@@ -1532,6 +1621,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1532
1621
|
saveAsset,
|
|
1533
1622
|
createCanvas,
|
|
1534
1623
|
deleteCanvas,
|
|
1624
|
+
editCss,
|
|
1625
|
+
editText,
|
|
1626
|
+
editAttr,
|
|
1535
1627
|
buildIndexData,
|
|
1536
1628
|
buildSystemData,
|
|
1537
1629
|
loadExportHistory,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// producing clipped captures. With our own playwright we control the
|
|
6
6
|
// viewport exactly and crop to the target element's bounding box.
|
|
7
7
|
|
|
8
|
-
import { mkdirSync
|
|
8
|
+
import { mkdirSync } from 'node:fs';
|
|
9
9
|
import { dirname, join } from 'node:path';
|
|
10
10
|
import { launchChromium } from './_pw-launch.mjs';
|
|
11
11
|
|