@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
package/apps/studio/api.ts
CHANGED
|
@@ -3,13 +3,22 @@
|
|
|
3
3
|
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import type { Dirent } from 'node:fs';
|
|
6
|
-
import { mkdir, readdir, readFile, rename, stat as statp } from 'node:fs/promises';
|
|
6
|
+
import { mkdir, readdir, readFile, rename, rm, stat as statp } from 'node:fs/promises';
|
|
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
|
+
// Directories that never hold user-facing canvases. Exported so the
|
|
19
|
+
// external-canvas watcher (`canvas-list-watch.ts`) shares one source instead of
|
|
20
|
+
// a hand-synced copy. (activity.ts still carries its own historical mirror.)
|
|
21
|
+
export const SKIP_DIRS = new Set([
|
|
13
22
|
'node_modules',
|
|
14
23
|
'.git',
|
|
15
24
|
'.next',
|
|
@@ -56,6 +65,31 @@ export async function findHtmlFiles(absRoot: string, prefixUnderRepo: string): P
|
|
|
56
65
|
return out;
|
|
57
66
|
}
|
|
58
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Canonical canvas slug from a (repo- or design-root-relative) canvas path.
|
|
70
|
+
* Pure — the `fileSlug` closure inside `createApi` delegates here, and the
|
|
71
|
+
* external-canvas watcher (`canvas-list-watch.ts`) imports it so both creation
|
|
72
|
+
* paths derive identical `canvas-list-update` slugs. Strips an optional
|
|
73
|
+
* `<designRel>/` prefix, then `/`→`-`, whitespace→`_`, drops the `.tsx`/`.html`
|
|
74
|
+
* extension, and lowercases.
|
|
75
|
+
*/
|
|
76
|
+
export function canvasSlugFromRel(file: string, designRel: string): string {
|
|
77
|
+
let p = String(file).replace(/^\/+|\/+$/g, '');
|
|
78
|
+
try {
|
|
79
|
+
p = decodeURIComponent(p);
|
|
80
|
+
} catch {
|
|
81
|
+
/* ignore */
|
|
82
|
+
}
|
|
83
|
+
const prefix = `${designRel.replace(/^\/+|\/+$/g, '')}/`;
|
|
84
|
+
if (p.startsWith(prefix)) p = p.slice(prefix.length);
|
|
85
|
+
return p
|
|
86
|
+
.replace(/\//g, '-')
|
|
87
|
+
.replace(/\s+/g, '_')
|
|
88
|
+
.replace(/\.(tsx|html)$/i, '')
|
|
89
|
+
.replace(/^\.+/, '')
|
|
90
|
+
.toLowerCase();
|
|
91
|
+
}
|
|
92
|
+
|
|
59
93
|
async function findFiles(absRoot: string, prefix: string, exts: string[]): Promise<string[]> {
|
|
60
94
|
const out: string[] = [];
|
|
61
95
|
let entries: Dirent[];
|
|
@@ -141,6 +175,11 @@ export type DeleteCanvasResult =
|
|
|
141
175
|
| { ok: true; rel: string; slug: string; trashed: string[]; trashDir: string }
|
|
142
176
|
| { ok: false; status: number; error: string };
|
|
143
177
|
|
|
178
|
+
/** Phase 12 — result of an in-canvas direct edit (`editCss` / `editText`). */
|
|
179
|
+
export type EditOpResult =
|
|
180
|
+
| { ok: true; delta: number }
|
|
181
|
+
| { ok: false; status: number; error: string };
|
|
182
|
+
|
|
144
183
|
export interface Api {
|
|
145
184
|
// File tree
|
|
146
185
|
fileSlug(file: string): string;
|
|
@@ -190,6 +229,25 @@ export interface Api {
|
|
|
190
229
|
}): Promise<CreateCanvasResult>;
|
|
191
230
|
// Soft-delete a canvas from the browser (Phase 22 — DELETE /_api/canvas)
|
|
192
231
|
deleteCanvas(input: { file?: unknown }): Promise<DeleteCanvasResult>;
|
|
232
|
+
// Phase 12 (DDR-103) — single-property inline CSS edit (POST /_api/edit-css).
|
|
233
|
+
// Main-origin only: writes one key into the element's inline `style={{}}` object.
|
|
234
|
+
editCss(input: {
|
|
235
|
+
canvas?: unknown;
|
|
236
|
+
id?: unknown;
|
|
237
|
+
property?: unknown;
|
|
238
|
+
value?: unknown;
|
|
239
|
+
}): Promise<EditOpResult>;
|
|
240
|
+
// Phase 12 (DDR-103) — inline text-content edit (POST /_api/edit-text). Main-origin only.
|
|
241
|
+
editText(input: { canvas?: unknown; id?: unknown; text?: unknown }): Promise<EditOpResult>;
|
|
242
|
+
// Phase 12.2 (DDR-104) — custom HTML attribute edit (POST /_api/edit-attr). Main-origin
|
|
243
|
+
// only. The CSS panel's "custom HTML attribute" escape hatch (data-*, aria-*, role, …);
|
|
244
|
+
// writes a plain JSX attribute via editAttribute's non-`style.` path.
|
|
245
|
+
editAttr(input: {
|
|
246
|
+
canvas?: unknown;
|
|
247
|
+
id?: unknown;
|
|
248
|
+
attr?: unknown;
|
|
249
|
+
value?: unknown;
|
|
250
|
+
}): Promise<EditOpResult>;
|
|
193
251
|
// Aggregate data
|
|
194
252
|
buildIndexData(): Promise<unknown>;
|
|
195
253
|
buildSystemData(dsName?: string | null): Promise<unknown>;
|
|
@@ -204,45 +262,14 @@ export interface ApiHooks {
|
|
|
204
262
|
onAnnotationsChanged?: (file: string, svg: string) => void;
|
|
205
263
|
}
|
|
206
264
|
|
|
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
|
-
]);
|
|
265
|
+
// FigJam v3 — the annotation sanitizer moved to annotations-model.ts (the
|
|
266
|
+
// schema owner; the allowlist guards exactly that vocabulary, and the
|
|
267
|
+
// headless `maude design annotate` write verb needs it without pulling the
|
|
268
|
+
// server modules). Re-exported here so every existing `from './api.ts'`
|
|
269
|
+
// import keeps working unchanged.
|
|
270
|
+
export { ASSET_IMAGE_HREF_RE, sanitizeAnnotationSvg } from './annotations-model.ts';
|
|
236
271
|
|
|
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)$/;
|
|
272
|
+
import { sanitizeAnnotationSvg } from './annotations-model.ts';
|
|
246
273
|
|
|
247
274
|
/** Phase 23 — hard ceiling on a single uploaded asset (10 MB). */
|
|
248
275
|
export const ASSET_MAX_BYTES = 10 * 1024 * 1024;
|
|
@@ -323,104 +350,13 @@ export interface SaveAssetResult {
|
|
|
323
350
|
path?: string;
|
|
324
351
|
}
|
|
325
352
|
|
|
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
353
|
export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
405
354
|
const onCommentsChanged = hooks.onCommentsChanged;
|
|
406
355
|
const onAnnotationsChanged = hooks.onAnnotationsChanged;
|
|
407
356
|
const { paths, cfg } = ctx;
|
|
408
357
|
|
|
409
358
|
function fileSlug(file: string): string {
|
|
410
|
-
|
|
411
|
-
try {
|
|
412
|
-
p = decodeURIComponent(p);
|
|
413
|
-
} catch {
|
|
414
|
-
/* ignore */
|
|
415
|
-
}
|
|
416
|
-
const prefix = `${paths.designRel.replace(/^\/+|\/+$/g, '')}/`;
|
|
417
|
-
if (p.startsWith(prefix)) p = p.slice(prefix.length);
|
|
418
|
-
return p
|
|
419
|
-
.replace(/\//g, '-')
|
|
420
|
-
.replace(/\s+/g, '_')
|
|
421
|
-
.replace(/\.(tsx|html)$/i, '')
|
|
422
|
-
.replace(/^\.+/, '')
|
|
423
|
-
.toLowerCase();
|
|
359
|
+
return canvasSlugFromRel(file, paths.designRel);
|
|
424
360
|
}
|
|
425
361
|
|
|
426
362
|
async function fileForSlug(slug: string): Promise<string | null> {
|
|
@@ -716,21 +652,28 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
716
652
|
}
|
|
717
653
|
}
|
|
718
654
|
|
|
719
|
-
// ---------- Canvas meta sidecar (Phase 4 T5) ----------
|
|
655
|
+
// ---------- Canvas meta sidecar (Phase 4 T5; split DDR-115) ----------
|
|
720
656
|
//
|
|
721
657
|
// Each canvas under `<designRoot>/ui/<name>.tsx` has a sibling
|
|
722
|
-
// `<name>.meta.json
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
//
|
|
726
|
-
//
|
|
658
|
+
// `<name>.meta.json` — the SHARED, versioned document (title, sections,
|
|
659
|
+
// `layout` per-artboard world-coord rects, css_mode, …). The PATCH path is
|
|
660
|
+
// intentionally merge-shallow on top-level keys — never clobber `title`,
|
|
661
|
+
// `sections`, `ai_context`, or any other authoring metadata.
|
|
662
|
+
//
|
|
663
|
+
// DDR-115 — the PER-USER camera (`viewport` pan/zoom) NO LONGER lives in
|
|
664
|
+
// `.meta.json`. It churns on every mouse pan/zoom, so persisting it inline
|
|
665
|
+
// dirtied a tracked file. It now lives in a gitignored per-machine view file
|
|
666
|
+
// (`canvasViewPath` below). PATCH splits the lanes (viewport → view file,
|
|
667
|
+
// layout → meta); GET merges them back so the client (`window.__canvas_meta__`)
|
|
668
|
+
// is unchanged. `last_modified` is stamped into meta ONLY on a real shared
|
|
669
|
+
// (layout) change — never on a viewport-only patch.
|
|
727
670
|
|
|
728
671
|
/**
|
|
729
|
-
* Resolve `file` (a path relative to repoRoot like `.design/ui/Foo.tsx`)
|
|
730
|
-
*
|
|
731
|
-
*
|
|
672
|
+
* Resolve `file` (a path relative to repoRoot like `.design/ui/Foo.tsx`) into
|
|
673
|
+
* the absolute path of the canvas SOURCE file. Refuses traversal, paths that
|
|
674
|
+
* escape repoRoot, and non-canvas extensions. Returns null on rejection.
|
|
732
675
|
*/
|
|
733
|
-
function
|
|
676
|
+
function canvasSourceAbs(file: string): string | null {
|
|
734
677
|
let p = String(file).replace(/^\/+/, '');
|
|
735
678
|
try {
|
|
736
679
|
p = decodeURIComponent(p);
|
|
@@ -742,29 +685,133 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
742
685
|
if (!abs.startsWith(`${paths.repoRoot}/`)) return null;
|
|
743
686
|
const ext = path.extname(abs).toLowerCase();
|
|
744
687
|
if (ext !== '.tsx' && ext !== '.html') return null;
|
|
745
|
-
return abs
|
|
688
|
+
return abs;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Resolve `file` into the absolute path of its sibling `.meta.json` sidecar.
|
|
693
|
+
* Same containment guard as `canvasSourceAbs` (refuses paths that escape
|
|
694
|
+
* repoRoot / non-canvas extensions).
|
|
695
|
+
*/
|
|
696
|
+
function canvasMetaPath(file: string): string | null {
|
|
697
|
+
const abs = canvasSourceAbs(file);
|
|
698
|
+
return abs ? abs.replace(/\.(tsx|html)$/i, '.meta.json') : null;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// ---------- Per-machine canvas view / camera (DDR-115) ----------
|
|
702
|
+
//
|
|
703
|
+
// The canvas pan/zoom ("camera") is PER-USER runtime state, separate from the
|
|
704
|
+
// shared `.meta.json` document. It lives in `_canvas-state/<slug>.view.json`
|
|
705
|
+
// ({ viewport }) — gitignored, swept on delete, export-excluded. DISTINCT from
|
|
706
|
+
// the legacy `_canvas-state/<slug>.json` ({ sections, viewport:{x,y,scale} })
|
|
707
|
+
// store: that uses `scale` clamped 0.05–8, this uses `zoom` clamped 0.1–4, so
|
|
708
|
+
// overloading one file would let the two writers clobber each other's shape.
|
|
709
|
+
|
|
710
|
+
/** Validate a candidate viewport — finite x/y, zoom clamped [0.1, 4] (the
|
|
711
|
+
* Phase 4 rule). Returns the normalized viewport, or null when invalid. */
|
|
712
|
+
function normalizeViewport(v: unknown): { x: number; y: number; zoom: number } | null {
|
|
713
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
|
|
714
|
+
const vv = v as { x?: unknown; y?: unknown; zoom?: unknown };
|
|
715
|
+
if (
|
|
716
|
+
Number.isFinite(vv.x as number) &&
|
|
717
|
+
Number.isFinite(vv.y as number) &&
|
|
718
|
+
Number.isFinite(vv.zoom as number)
|
|
719
|
+
) {
|
|
720
|
+
const zoom = Math.min(4, Math.max(0.1, vv.zoom as number));
|
|
721
|
+
return { x: vv.x as number, y: vv.y as number, zoom };
|
|
722
|
+
}
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Per-machine view file for a canvas: `_canvas-state/<slug>.view.json`. Gated
|
|
727
|
+
* by the same containment guard as the meta sidecar (traversal / repoRoot /
|
|
728
|
+
* canvas-ext). Returns null when `file` is not a valid canvas path. */
|
|
729
|
+
function canvasViewPath(file: string): string | null {
|
|
730
|
+
if (!canvasMetaPath(file)) return null; // reuse the containment + ext gate
|
|
731
|
+
return path.join(paths.canvasStateDir, `${fileSlug(file)}.view.json`);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function loadCanvasView(
|
|
735
|
+
file: string
|
|
736
|
+
): Promise<{ viewport?: { x: number; y: number; zoom: number } } | null> {
|
|
737
|
+
const viewAbs = canvasViewPath(file);
|
|
738
|
+
if (!viewAbs) return null;
|
|
739
|
+
try {
|
|
740
|
+
const obj = JSON.parse(await Bun.file(viewAbs).text());
|
|
741
|
+
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
742
|
+
const vp = normalizeViewport((obj as { viewport?: unknown }).viewport);
|
|
743
|
+
return vp ? { viewport: vp } : {};
|
|
744
|
+
}
|
|
745
|
+
return null;
|
|
746
|
+
} catch {
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Persist the per-user camera. Validates + clamps; best-effort (mkdir the
|
|
752
|
+
* bucket if absent). Returns the normalized viewport on write, null when the
|
|
753
|
+
* path is rejected or the viewport is invalid (no write). */
|
|
754
|
+
async function saveCanvasView(
|
|
755
|
+
file: string,
|
|
756
|
+
viewport: unknown
|
|
757
|
+
): Promise<{ x: number; y: number; zoom: number } | null> {
|
|
758
|
+
const viewAbs = canvasViewPath(file);
|
|
759
|
+
if (!viewAbs) return null;
|
|
760
|
+
const vp = normalizeViewport(viewport);
|
|
761
|
+
if (!vp) return null;
|
|
762
|
+
try {
|
|
763
|
+
await mkdir(paths.canvasStateDir, { recursive: true });
|
|
764
|
+
await Bun.write(viewAbs, `${JSON.stringify({ viewport: vp }, null, 2)}\n`);
|
|
765
|
+
return vp;
|
|
766
|
+
} catch {
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
746
769
|
}
|
|
747
770
|
|
|
748
771
|
async function loadCanvasMeta(file: string): Promise<Record<string, unknown> | null> {
|
|
749
772
|
const metaAbs = canvasMetaPath(file);
|
|
750
773
|
if (!metaAbs) return null;
|
|
774
|
+
let obj: Record<string, unknown> = {};
|
|
775
|
+
let hadMeta = false;
|
|
751
776
|
try {
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
777
|
+
const parsed = JSON.parse(await Bun.file(metaAbs).text());
|
|
778
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
779
|
+
obj = parsed as Record<string, unknown>;
|
|
780
|
+
hadMeta = true;
|
|
781
|
+
}
|
|
757
782
|
} catch {
|
|
758
|
-
|
|
783
|
+
// No meta on disk — fall through to a possible view-only result.
|
|
759
784
|
}
|
|
785
|
+
// DDR-115 — never surface the per-user camera or the local write-timestamp
|
|
786
|
+
// from the on-disk meta: `viewport` is stale (the live camera lives in the
|
|
787
|
+
// view file), `last_modified` is local bookkeeping. Strip both, then overlay
|
|
788
|
+
// the current camera so the shell's `window.__canvas_meta__.viewport` still
|
|
789
|
+
// restores on reload — the client stays unchanged. (`= undefined` over
|
|
790
|
+
// `delete` — JSON.stringify/Response.json drop undefined keys, matching the
|
|
791
|
+
// codebase convention + biome's noDelete.)
|
|
792
|
+
obj.viewport = undefined;
|
|
793
|
+
obj.last_modified = undefined;
|
|
794
|
+
const view = await loadCanvasView(file);
|
|
795
|
+
if (view?.viewport) obj.viewport = view.viewport;
|
|
796
|
+
// Preserve the historic contract: no meta AND no camera → null (GET → {},
|
|
797
|
+
// PATCH-on-rejected-path → 404). A view-only canvas still returns its camera.
|
|
798
|
+
if (!hadMeta && !view?.viewport) return null;
|
|
799
|
+
return obj;
|
|
760
800
|
}
|
|
761
801
|
|
|
762
802
|
/**
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
* the
|
|
766
|
-
*
|
|
767
|
-
*
|
|
803
|
+
* Apply a `patch` from the (untrusted) client, splitting the two lanes
|
|
804
|
+
* (DDR-115):
|
|
805
|
+
* - `viewport` → the per-machine view file (`saveCanvasView`); NEVER the
|
|
806
|
+
* versioned meta. A viewport-only patch leaves `.meta.json` byte-unchanged
|
|
807
|
+
* (no `last_modified` bump) — this is the mouse-move churn killer.
|
|
808
|
+
* - `layout` → the shared `.meta.json`, shallow-merged so `title`,
|
|
809
|
+
* `sections`, `ai_context`, … are preserved; `last_modified` is stamped
|
|
810
|
+
* ONLY here (a real shared change the user wants committable).
|
|
811
|
+
* Returns the same coherent object a GET would produce (shared meta + camera),
|
|
812
|
+
* or null only when the path itself is rejected (traversal / bad ext) so the
|
|
813
|
+
* route maps it to 404. A viewport-only patch on a canvas that has no meta yet
|
|
814
|
+
* still succeeds (writes only the view file) and returns `{ viewport }`.
|
|
768
815
|
*/
|
|
769
816
|
async function patchCanvasMeta(
|
|
770
817
|
file: string,
|
|
@@ -773,43 +820,63 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
773
820
|
const metaAbs = canvasMetaPath(file);
|
|
774
821
|
if (!metaAbs) return null;
|
|
775
822
|
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return null;
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
823
|
+
|
|
824
|
+
// DDR-115 security (F-A2) — the PATCH lanes are reachable from the untrusted
|
|
825
|
+
// canvas origin (DDR-054). Refuse to mint per-canvas state (view file or
|
|
826
|
+
// `.meta.json`) for a canvas that doesn't exist, so a malicious origin can't
|
|
827
|
+
// spray arbitrary-slug files/inodes via fabricated `file` paths. A valid
|
|
828
|
+
// patch only ever targets a canvas the user actually has; `.meta.json` may
|
|
829
|
+
// still be absent (first layout/viewport write), but the source must exist.
|
|
830
|
+
const srcAbs = canvasSourceAbs(file);
|
|
831
|
+
if (!srcAbs || !(await Bun.file(srcAbs).exists())) return null;
|
|
832
|
+
|
|
833
|
+
// --- Per-user camera lane: viewport → view file, never the versioned meta.
|
|
834
|
+
if (patch.viewport !== undefined) {
|
|
835
|
+
if (patch.viewport === null) {
|
|
836
|
+
// Explicit clear — best-effort remove the view file.
|
|
837
|
+
const viewAbs = canvasViewPath(file);
|
|
838
|
+
if (viewAbs) {
|
|
839
|
+
try {
|
|
840
|
+
await rm(viewAbs);
|
|
841
|
+
} catch {
|
|
842
|
+
/* absent / unreadable — nothing to clear */
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
} else {
|
|
846
|
+
// saveCanvasView validates + clamps; an invalid viewport is a silent no-op.
|
|
847
|
+
await saveCanvasView(file, patch.viewport);
|
|
782
848
|
}
|
|
783
|
-
} catch {
|
|
784
|
-
// No existing meta — create one with just the Phase 4 keys.
|
|
785
849
|
}
|
|
786
|
-
|
|
787
|
-
//
|
|
850
|
+
|
|
851
|
+
// --- Shared document lane: layout → versioned meta, stamps last_modified. ---
|
|
788
852
|
if (patch.layout !== undefined) {
|
|
853
|
+
let current: Record<string, unknown> = {};
|
|
854
|
+
try {
|
|
855
|
+
const parsed = JSON.parse(await Bun.file(metaAbs).text());
|
|
856
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
857
|
+
current = parsed as Record<string, unknown>;
|
|
858
|
+
}
|
|
859
|
+
} catch {
|
|
860
|
+
// No existing meta — create one with just the layout key.
|
|
861
|
+
}
|
|
862
|
+
const next = { ...current };
|
|
789
863
|
if (patch.layout === null) {
|
|
790
864
|
next.layout = undefined;
|
|
791
865
|
} else if (typeof patch.layout === 'object' && !Array.isArray(patch.layout)) {
|
|
792
866
|
next.layout = patch.layout;
|
|
793
867
|
}
|
|
868
|
+
// Defensive: a stale inline viewport must never persist in the versioned
|
|
869
|
+
// file (the camera lane owns it now). JSON.stringify drops undefined keys.
|
|
870
|
+
next.viewport = undefined;
|
|
871
|
+
next.last_modified = new Date().toISOString();
|
|
872
|
+
// Trailing newline — consistent with canvas-create.ts + sync/codec.ts
|
|
873
|
+
// (mergeSharedMetaIntoLocal), so a layout edit doesn't churn the newline.
|
|
874
|
+
await Bun.write(metaAbs, `${JSON.stringify(next, null, 2)}\n`);
|
|
794
875
|
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
const v = patch.viewport as { x?: unknown; y?: unknown; zoom?: unknown };
|
|
800
|
-
if (
|
|
801
|
-
Number.isFinite(v.x as number) &&
|
|
802
|
-
Number.isFinite(v.y as number) &&
|
|
803
|
-
Number.isFinite(v.zoom as number)
|
|
804
|
-
) {
|
|
805
|
-
const zoom = Math.min(4, Math.max(0.1, v.zoom as number));
|
|
806
|
-
next.viewport = { x: v.x as number, y: v.y as number, zoom };
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
next.last_modified = new Date().toISOString();
|
|
811
|
-
await Bun.write(metaAbs, JSON.stringify(next, null, 2));
|
|
812
|
-
return next;
|
|
876
|
+
|
|
877
|
+
// Return the merged view (shared meta + camera) — identical to GET, so the
|
|
878
|
+
// client gets a coherent object regardless of which lane(s) the patch hit.
|
|
879
|
+
return await loadCanvasMeta(file);
|
|
813
880
|
}
|
|
814
881
|
|
|
815
882
|
// ---------- Annotations sidecar (Phase 5) ----------
|
|
@@ -1009,6 +1076,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1009
1076
|
path.join(groupAbs, `${v.name}.meta.json`),
|
|
1010
1077
|
`${JSON.stringify(meta, null, 2)}\n`
|
|
1011
1078
|
);
|
|
1079
|
+
// Phase 30 — same-machine live tree refresh. Other tabs on THIS dev-server
|
|
1080
|
+
// re-read the (branch-scoped, on-disk) canvas list so a freshly-created
|
|
1081
|
+
// canvas appears without a reload. Cross-machine peers get the new canvas
|
|
1082
|
+
// via git "Get latest" — the file travels through git, this event is only a
|
|
1083
|
+
// "refresh your list" nudge for online local tabs (loopback inspector WS).
|
|
1084
|
+
ctx.bus.emit('canvas-list-update', { action: 'added', rel, slug });
|
|
1012
1085
|
// designRel-prefixed path — matches the file-tree `file.path` shape so the
|
|
1013
1086
|
// client can open it directly after reloadTree().
|
|
1014
1087
|
return { ok: true, file: path.posix.join(paths.designRel, rel), rel, slug };
|
|
@@ -1110,6 +1183,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1110
1183
|
path.join(paths.canvasStateDir, `${slug}.json`),
|
|
1111
1184
|
`_canvas-state__${slug}.json`
|
|
1112
1185
|
);
|
|
1186
|
+
// DDR-115 — the per-machine camera view file.
|
|
1187
|
+
await moveIfExists(
|
|
1188
|
+
path.join(paths.canvasStateDir, `${slug}.view.json`),
|
|
1189
|
+
`_canvas-state__${slug}.view.json`
|
|
1190
|
+
);
|
|
1113
1191
|
await moveIfExists(path.join(paths.commentsDir, `${slug}.json`), `_comments__${slug}.json`);
|
|
1114
1192
|
|
|
1115
1193
|
await Bun.write(
|
|
@@ -1117,6 +1195,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1117
1195
|
`${JSON.stringify({ canvas: rel, slug, deletedAt: new Date().toISOString(), trashed }, null, 2)}\n`
|
|
1118
1196
|
);
|
|
1119
1197
|
|
|
1198
|
+
// Phase 30 — live tree refresh for other local tabs (see createCanvas).
|
|
1199
|
+
ctx.bus.emit('canvas-list-update', { action: 'removed', rel, slug });
|
|
1120
1200
|
return {
|
|
1121
1201
|
ok: true,
|
|
1122
1202
|
rel,
|
|
@@ -1126,6 +1206,174 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1126
1206
|
};
|
|
1127
1207
|
}
|
|
1128
1208
|
|
|
1209
|
+
// Phase 12 (DDR-103) — resolve a v2 canvas slug (`selected.canvas`: POSIX,
|
|
1210
|
+
// extension-less, designRoot-relative — matches `_locator.json` keys + the
|
|
1211
|
+
// `canvasSlug()` shape) to its absolute `.tsx` path, with a containment
|
|
1212
|
+
// backstop. Same main-origin-only trust boundary as createCanvas: the
|
|
1213
|
+
// untrusted canvas iframe origin never reaches a source-write endpoint.
|
|
1214
|
+
function resolveCanvasAbs(
|
|
1215
|
+
slugRaw: unknown
|
|
1216
|
+
): { ok: true; abs: string } | { ok: false; status: number; error: string } {
|
|
1217
|
+
if (typeof slugRaw !== 'string' || !slugRaw.trim()) {
|
|
1218
|
+
return { ok: false, status: 400, error: 'canvas (slug) required' };
|
|
1219
|
+
}
|
|
1220
|
+
let slug = slugRaw.replace(/^\/+|\/+$/g, '').replace(/\.(tsx|html)$/i, '');
|
|
1221
|
+
// Accept either the bare slug (`ui/Foo`) or the designRel-prefixed file path
|
|
1222
|
+
// the client `selected.file` carries (`.design/ui/Foo.tsx`) — strip a leading
|
|
1223
|
+
// designRel so both shapes resolve to the same canvas (mirrors deriveCanvasSlug).
|
|
1224
|
+
const dr = paths.designRel.replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
|
|
1225
|
+
if (dr && slug.startsWith(`${dr}/`)) slug = slug.slice(dr.length + 1);
|
|
1226
|
+
if (
|
|
1227
|
+
!slug ||
|
|
1228
|
+
path.isAbsolute(slug) ||
|
|
1229
|
+
slug.split('/').some((seg) => seg === '..' || seg === '.' || seg === '')
|
|
1230
|
+
) {
|
|
1231
|
+
return { ok: false, status: 400, error: 'invalid canvas slug' };
|
|
1232
|
+
}
|
|
1233
|
+
const abs = `${path.join(paths.designRoot, ...slug.split('/'))}.tsx`;
|
|
1234
|
+
const resolvedRoot = path.resolve(paths.designRoot);
|
|
1235
|
+
const resolved = path.resolve(abs);
|
|
1236
|
+
if (!resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
1237
|
+
return { ok: false, status: 400, error: 'canvas resolves outside the design root' };
|
|
1238
|
+
}
|
|
1239
|
+
return { ok: true, abs };
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// 8-hex lowercase, the shape `computeId` (canvas-edit.ts) stamps on data-cd-id.
|
|
1243
|
+
const CD_ID_RE = /^[0-9a-f]{8}$/;
|
|
1244
|
+
|
|
1245
|
+
async function editCss(input: {
|
|
1246
|
+
canvas?: unknown;
|
|
1247
|
+
id?: unknown;
|
|
1248
|
+
property?: unknown;
|
|
1249
|
+
value?: unknown;
|
|
1250
|
+
reset?: unknown;
|
|
1251
|
+
}): Promise<EditOpResult> {
|
|
1252
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
1253
|
+
if (!r.ok) return r;
|
|
1254
|
+
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
1255
|
+
if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
|
|
1256
|
+
const property = typeof input.property === 'string' ? input.property.trim() : '';
|
|
1257
|
+
// CSS property names are ASCII letters + hyphens only (optionally a leading
|
|
1258
|
+
// `-` for vendor prefixes) — reject anything that could smuggle a second key
|
|
1259
|
+
// or an expression into the inline style object.
|
|
1260
|
+
if (!property || !/^-?[a-z][a-z-]*$/.test(property)) {
|
|
1261
|
+
return { ok: false, status: 400, error: 'invalid css property' };
|
|
1262
|
+
}
|
|
1263
|
+
// kebab → camelCase JSX style key.
|
|
1264
|
+
const camel = property.replace(/-([a-z])/g, (_m, c: string) => c.toUpperCase());
|
|
1265
|
+
// Phase 12.3 — `reset: true` REMOVES the inline property (back to class /
|
|
1266
|
+
// inherited). No value needed; a missing key is a no-op (delta 0).
|
|
1267
|
+
if (input.reset === true) {
|
|
1268
|
+
try {
|
|
1269
|
+
const res = await removeAttribute(r.abs, id, `style.${camel}`);
|
|
1270
|
+
return { ok: true, delta: res.delta };
|
|
1271
|
+
} catch (err) {
|
|
1272
|
+
return {
|
|
1273
|
+
ok: false,
|
|
1274
|
+
status: 422,
|
|
1275
|
+
error: err instanceof CanvasEditError ? err.message : 'reset failed',
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
const value = typeof input.value === 'string' ? input.value : '';
|
|
1280
|
+
if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
|
|
1281
|
+
if (value.length > 256) return { ok: false, status: 413, error: 'value too long' };
|
|
1282
|
+
// The value is always written as a JS STRING literal: JSON.stringify escapes
|
|
1283
|
+
// quotes/backslashes/newlines so it can never break out of the string, and
|
|
1284
|
+
// React accepts string values for every style prop — so `var(--accent)`,
|
|
1285
|
+
// `#fff`, `8px`, `700`, `1.5` all ride verbatim.
|
|
1286
|
+
try {
|
|
1287
|
+
const res = await editAttribute(r.abs, id, `style.${camel}`, JSON.stringify(value));
|
|
1288
|
+
return { ok: true, delta: res.delta };
|
|
1289
|
+
} catch (err) {
|
|
1290
|
+
return {
|
|
1291
|
+
ok: false,
|
|
1292
|
+
status: 422,
|
|
1293
|
+
error: err instanceof CanvasEditError ? err.message : 'edit failed',
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
async function editText(input: {
|
|
1299
|
+
canvas?: unknown;
|
|
1300
|
+
id?: unknown;
|
|
1301
|
+
text?: unknown;
|
|
1302
|
+
}): Promise<EditOpResult> {
|
|
1303
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
1304
|
+
if (!r.ok) return r;
|
|
1305
|
+
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
1306
|
+
if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
|
|
1307
|
+
if (typeof input.text !== 'string') return { ok: false, status: 400, error: 'text required' };
|
|
1308
|
+
if (input.text.length > 5000) return { ok: false, status: 413, error: 'text too long' };
|
|
1309
|
+
try {
|
|
1310
|
+
const res = await runEditText(r.abs, id, input.text);
|
|
1311
|
+
return { ok: true, delta: res.delta };
|
|
1312
|
+
} catch (err) {
|
|
1313
|
+
return {
|
|
1314
|
+
ok: false,
|
|
1315
|
+
status: 422,
|
|
1316
|
+
error: err instanceof CanvasEditError ? err.message : 'edit failed',
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
async function editAttr(input: {
|
|
1322
|
+
canvas?: unknown;
|
|
1323
|
+
id?: unknown;
|
|
1324
|
+
attr?: unknown;
|
|
1325
|
+
value?: unknown;
|
|
1326
|
+
reset?: unknown;
|
|
1327
|
+
}): Promise<EditOpResult> {
|
|
1328
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
1329
|
+
if (!r.ok) return r;
|
|
1330
|
+
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
1331
|
+
if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
|
|
1332
|
+
const attr = typeof input.attr === 'string' ? input.attr.trim() : '';
|
|
1333
|
+
// Plain HTML attributes only — data-*, aria-*, role, title, id, lang, dir…
|
|
1334
|
+
// Reject `style*` (that's /_api/edit-css), `data-cd-id` (pipeline-owned, also
|
|
1335
|
+
// refused downstream by editAttribute), and anything that isn't a bare html
|
|
1336
|
+
// attribute name. Digits allowed mid-name (e.g. data-2x).
|
|
1337
|
+
if (
|
|
1338
|
+
!attr ||
|
|
1339
|
+
!/^[a-z][a-z0-9-]*$/.test(attr) ||
|
|
1340
|
+
attr === 'data-cd-id' ||
|
|
1341
|
+
attr.startsWith('style')
|
|
1342
|
+
) {
|
|
1343
|
+
return { ok: false, status: 400, error: 'invalid attribute' };
|
|
1344
|
+
}
|
|
1345
|
+
// Phase 12.3 — `reset: true` REMOVES the custom attribute. No-op if absent.
|
|
1346
|
+
if (input.reset === true) {
|
|
1347
|
+
try {
|
|
1348
|
+
const res = await removeAttribute(r.abs, id, attr);
|
|
1349
|
+
return { ok: true, delta: res.delta };
|
|
1350
|
+
} catch (err) {
|
|
1351
|
+
return {
|
|
1352
|
+
ok: false,
|
|
1353
|
+
status: 422,
|
|
1354
|
+
error: err instanceof CanvasEditError ? err.message : 'reset failed',
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
const value = typeof input.value === 'string' ? input.value : '';
|
|
1359
|
+
if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
|
|
1360
|
+
if (value.length > 256) return { ok: false, status: 413, error: 'value too long' };
|
|
1361
|
+
try {
|
|
1362
|
+
// Non-`style.` attr name → editAttribute writes a plain quoted JSX attribute.
|
|
1363
|
+
// Pass the value RAW: editStringAttr quotes/escapes it itself (JSON.stringify
|
|
1364
|
+
// on replace, escapeAttr on insert) — pre-stringifying here double-encoded
|
|
1365
|
+
// the value (`data-x="\"ok\""`; knob-smoke finding, 2026-06-12).
|
|
1366
|
+
const res = await editAttribute(r.abs, id, attr, value);
|
|
1367
|
+
return { ok: true, delta: res.delta };
|
|
1368
|
+
} catch (err) {
|
|
1369
|
+
return {
|
|
1370
|
+
ok: false,
|
|
1371
|
+
status: 422,
|
|
1372
|
+
error: err instanceof CanvasEditError ? err.message : 'edit failed',
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1129
1377
|
async function saveCanvasState(file: string, state: Record<string, unknown>) {
|
|
1130
1378
|
if (!state || typeof state !== 'object') return;
|
|
1131
1379
|
const safe: Record<string, unknown> = {};
|
|
@@ -1532,6 +1780,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1532
1780
|
saveAsset,
|
|
1533
1781
|
createCanvas,
|
|
1534
1782
|
deleteCanvas,
|
|
1783
|
+
editCss,
|
|
1784
|
+
editText,
|
|
1785
|
+
editAttr,
|
|
1535
1786
|
buildIndexData,
|
|
1536
1787
|
buildSystemData,
|
|
1537
1788
|
loadExportHistory,
|