// AST-aware single-element edits for the `/design:edit` Step 3a fast path // (DDR-019, Phase 3.6 Task 5). // // Caller hands us (canvasAbsPath, dataCdId, attr, value); we parse the TSX, // re-walk it with the same component/jsxIndex bookkeeping as canvas-pipeline.ts, // find the JSX element whose ID matches, and rewrite a single attribute via // magic-string. The two-pass-transform contract (DDR-019) is the only thing // that keeps source-DOM identity stable across edits — so the editor lives // next to the transpiler and shares its toolchain (oxc-parser + magic-string). // // Supported `attr` syntaxes: // - "className" → swap the value of the `className` JSX attribute // (insert one if missing). Value form: bare string. // - "style." → swap (or insert) a single CSS-property key inside // the inline `style={{ ... }}` object. Value form: // literal text inserted between `:` and `,` — pass a // JS expression (string with quotes for strings, raw // number for numbers). // - "" → swap (or insert) the value of a plain string // attribute (aria-label, role, title, ...). // // All edits preserve every other attribute byte-for-byte. The `data-cd-id` // attribute is intentionally NOT writable through this path — the pipeline // owns those. // // Concurrent edits against the same canvas serialise behind a per-file mutex // (matches the locator.ts pattern). Two parallel edits against different // canvases run in parallel. import { mkdir, open, stat, unlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import MagicString from 'magic-string'; import { parseSync } from 'oxc-parser'; export class CanvasEditError extends Error { readonly canvas: string; readonly id: string; /** The target no longer holds the value the caller expected (a peer changed it). */ readonly conflict: boolean; constructor(message: string, info: { canvas: string; id: string; conflict?: boolean }) { super(message); this.name = 'CanvasEditError'; this.canvas = info.canvas; this.id = info.id; this.conflict = info.conflict === true; } } /** * Expected-current-value guard for a single-attribute write (audit 2026-09-13 * P1 #5). `expected` is what the caller believes the source holds now, `next` * is what it is about to write; `null` means "attribute / style key absent". * Values are the RAW strings the edit routes take (not JSON-encoded). */ export interface AttributePrecondition { expected: string | null; next: string | null; } const PASCAL_CASE = /^[A-Z][A-Za-z0-9_]*$/; // biome-ignore lint/suspicious/noExplicitAny: oxc-parser AST nodes are heterogeneous. type AnyNode = any; function isPascalIdent(name: unknown): name is string { return typeof name === 'string' && PASCAL_CASE.test(name); } function componentNameOf(node: AnyNode): string | null { if (!node || typeof node !== 'object') return null; if (node.type === 'FunctionDeclaration' && isPascalIdent(node.id?.name)) return node.id.name; if (node.type === 'VariableDeclarator' && isPascalIdent(node.id?.name)) { const init = node.init; if (init && (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression')) { return node.id.name; } } if (node.type === 'FunctionExpression' && isPascalIdent(node.id?.name)) return node.id.name; return null; } function computeId(componentName: string, idx: number): string { return Bun.hash(`${componentName}:${idx}`).toString(16).padStart(16, '0').slice(0, 8); } interface OpeningHit { opening: AnyNode; /** The full JSXElement node — `editText` needs `.children` (JSXText), not just the opening tag. */ element: AnyNode; } /** * Find the openingElement of the JSX element whose pipeline-computed ID matches * `targetId`. Walks pre-order with the same component+jsxIndex bookkeeping the * pipeline uses, so the ID arithmetic stays in lockstep. Returns null if no * match. */ /** * Hand-authored `data-cd-id` literal on an opening element, when present. The * pipeline PRESERVES an authored id (it skips injection — canvas-pipeline.ts * `hasJsxAttr` gate), so the DOM carries the AUTHORED value while the * positional `computeId` for that element never exists anywhere. Every walker * that maps ids therefore has to prefer the authored literal — without this, * authored-id elements were unreachable by the whole edit engine (dogfood * 2026-07-20: "Convert failed: invalid container data-cd-id" on * `data-cd-id="wal-hero-nav"`). */ function authoredCdId(opening: AnyNode): string | null { const attr = findAttribute(opening, 'data-cd-id'); const v = attr?.value; if (v?.type === 'Literal' && typeof v.value === 'string' && v.value) return v.value; return null; } function findOpening(program: AnyNode, targetId: string): OpeningHit | null { interface Frame { componentName: string; jsxIndex: number; } const stack: Frame[] = [{ componentName: '', jsxIndex: 0 }]; let hit: OpeningHit | null = null; function visit(node: AnyNode): void { if (hit || !node || typeof node !== 'object') return; if (Array.isArray(node)) { for (const c of node) { if (hit) return; visit(c); } return; } if (typeof node.type !== 'string') return; const newComp = componentNameOf(node); let pushed = false; if (newComp !== null) { stack.push({ componentName: newComp, jsxIndex: 0 }); pushed = true; } if (node.type === 'JSXElement') { const frame = stack[stack.length - 1] as Frame; const idx = frame.jsxIndex; frame.jsxIndex += 1; const id = authoredCdId(node.openingElement) ?? computeId(frame.componentName, idx); if (id === targetId) { hit = { opening: node.openingElement, element: node }; } if (!hit) { if (node.openingElement) visit(node.openingElement.attributes); visit(node.children); } if (pushed) stack.pop(); return; } for (const k of Object.keys(node)) { if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue; visit(node[k]); } if (pushed) stack.pop(); } visit(program); return hit; } function findAttribute(opening: AnyNode, name: string): AnyNode | null { const attrs = opening?.attributes; if (!Array.isArray(attrs)) return null; for (const a of attrs) { if (a?.type === 'JSXAttribute' && a.name?.type === 'JSXIdentifier' && a.name.name === name) { return a; } } return null; } // --------------------------------------------------------------------------- // Per-canvas mutex — TWO layers (DDR-150 P2). (1) An in-process Promise chain // serialises edits within THIS process (fast). (2) A cross-process advisory // lockfile serialises against edits from ANOTHER process — the `/design:edit` // CLI (`import.meta.main` below) or the HMR file-watcher — so their // read-modify-write can't interleave with ours and lose an update (the in- // process `locks` Map alone couldn't see them). The lockfile lives in the OS // temp dir (NOT the versioned design root — never touches the gitignore // taxonomy) keyed by the canvas absolute path. A crashed holder leaves a STALE // lock, stolen after LOCK_STALE_MS; if it stays contended past LOCK_MAX_WAIT_MS // we proceed anyway rather than deadlock — the atomic tmp-rename write + the // content-hash fingerprint are the backstop against a truly simultaneous writer. const LOCK_DIR = path.join(tmpdir(), 'maude-locks'); const LOCK_STALE_MS = 15_000; const LOCK_POLL_MS = 25; const LOCK_MAX_WAIT_MS = 10_000; /** OS-temp lockfile path for a canvas (shared across processes; keyed by abs path). */ export function lockPathFor(filePath: string): string { return path.join(LOCK_DIR, `${Bun.hash(filePath).toString(16).padStart(16, '0')}.lock`); } /** * Acquire the cross-process advisory lock for `filePath`; resolves to an * idempotent release fn. Exported for tests. Degrades to a no-op lock (rather * than breaking every edit) if the temp dir is unwritable or contention outlasts * LOCK_MAX_WAIT_MS. */ export async function acquireFileLock(filePath: string): Promise<() => Promise> { const lp = lockPathFor(filePath); await mkdir(LOCK_DIR, { recursive: true }).catch(() => {}); const start = Date.now(); for (;;) { try { const fh = await open(lp, 'wx'); // O_CREAT | O_EXCL — fails if already held await fh.writeFile(`${process.pid} ${Date.now()}`); await fh.close(); let released = false; return async () => { if (released) return; released = true; await unlink(lp).catch(() => {}); }; } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { return async () => {}; // can't create a lockfile at all → in-process-only } try { const st = await stat(lp); if (Date.now() - st.mtimeMs > LOCK_STALE_MS) { await unlink(lp).catch(() => {}); // holder crashed — steal continue; } } catch { continue; // vanished between EEXIST and stat — retry the create } if (Date.now() - start > LOCK_MAX_WAIT_MS) return async () => {}; // don't deadlock await new Promise((r) => setTimeout(r, LOCK_POLL_MS)); } } } const locks = new Map>(); export function withLock(filePath: string, fn: () => Promise): Promise { const prev = locks.get(filePath) ?? Promise.resolve(); let release!: () => void; const gate = new Promise((res) => { release = res; }); const next = prev.then(() => gate); locks.set(filePath, next); return prev .then(async () => { // In-process calls are already serialised by the chain above, so only ONE // local call ever contends the lockfile — cross-process is its only job. const releaseFile = await acquireFileLock(filePath); try { return await fn(); } finally { await releaseFile(); } }) .finally(() => { release(); if (locks.get(filePath) === next) locks.delete(filePath); }); } // --------------------------------------------------------------------------- // Public API. export interface EditResult { /** The post-edit source (also written to disk by editAttribute()). */ source: string; /** Number of bytes the edit changed (positive = grew, negative = shrunk). */ delta: number; /** * Whether a disk write actually happened. Set by the disk-op wrappers only * (pure apply* variants leave it undefined). Callers must NOT infer this from * `delta` — an equal-length replacement (e.g. text "Alpha" → "Gamma") is a * real write with delta 0 (RC1 rim-suppression finding). */ changed?: boolean; /** * What the target held BEFORE this write, read under the same lock. The * value an undo must restore is what the write replaced — never what a * client panel last believed was there (it can be a teammate's edit old). */ previous?: AttributeState; } /** * Apply a single-attribute edit to the JSX element with the given `data-cd-id`. * Reads the canvas, rewrites in memory, writes atomically (via Bun.write to a * tmp + rename) so a concurrent reader never sees a partial file. */ export async function editAttribute( canvasAbsPath: string, id: string, attr: string, value: string, occurrence?: number, precondition?: AttributePrecondition ): Promise { return withLock(canvasAbsPath, async () => { const file = Bun.file(canvasAbsPath); if (!(await file.exists())) { throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, { canvas: canvasAbsPath, id, }); } const source = await file.text(); // Read under the same per-file lock as the write — no gap between the read // that proves the precondition (and records what is replaced) and the rename. const previous = readAttributeState(canvasAbsPath, source, id, attr, occurrence); if ( precondition && checkPrecondition(previous, id, attr, canvasAbsPath, precondition) === 'already' ) { return { source, delta: 0, changed: false, previous }; } const next = applyEdit(canvasAbsPath, source, id, attr, value, occurrence); if (next.source === source) return { source, delta: 0, changed: false, previous }; const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`; await Bun.write(tmp, next.source); const { rename } = await import('node:fs/promises'); await rename(tmp, canvasAbsPath); return { ...next, changed: true, previous }; }); } /** * Remove an attribute (or one inline-style property) from the element with the * given `data-cd-id` — the "reset to original" path (Phase 12.3). `attr` follows * the same shape as `editAttribute`: `style.` removes one inline * style key (dropping the whole `style={{}}` when it was the last key); any other * name removes that plain JSX attribute. A missing key/attribute is a no-op * (delta 0), never an error. Same atomic write + per-file lock as `editAttribute`. */ export async function removeAttribute( canvasAbsPath: string, id: string, attr: string, occurrence?: number, precondition?: AttributePrecondition ): Promise { return withLock(canvasAbsPath, async () => { const file = Bun.file(canvasAbsPath); if (!(await file.exists())) { throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, { canvas: canvasAbsPath, id, }); } const source = await file.text(); const previous = readAttributeState(canvasAbsPath, source, id, attr, occurrence); if ( precondition && checkPrecondition(previous, id, attr, canvasAbsPath, precondition) === 'already' ) { return { source, delta: 0, changed: false, previous }; } const next = applyRemove(canvasAbsPath, source, id, attr, occurrence); if (next.source === source) return { source, delta: 0, changed: false, previous }; const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`; await Bun.write(tmp, next.source); const { rename } = await import('node:fs/promises'); await rename(tmp, canvasAbsPath); return { ...next, changed: true, previous }; }); } /** What one plain attribute or inline style key currently holds in source. */ export type AttributeState = | { kind: 'absent' } | { kind: 'literal'; value: string } /** Present, but not a literal this module can compare (an expression, a spread). */ | { kind: 'expression' }; function literalText(node: AnyNode): string | null { if (!node) return null; if (node.type === 'Literal' || node.type === 'StringLiteral' || node.type === 'NumericLiteral') { return typeof node.value === 'string' || typeof node.value === 'number' ? String(node.value) : null; } if (node.type === 'TemplateLiteral' && node.expressions?.length === 0) { return node.quasis?.[0]?.value?.cooked ?? null; } return null; } /** * Read the current value of `attr` (`style.` or a plain attribute name) * on the element with `data-cd-id` `id`, with the same occurrence routing as * `applyEdit` / `applyRemove`. Pure — exposed for tests and preconditions. */ export function readAttributeState( canvasAbsPath: string, source: string, id: string, attr: string, occurrence?: number ): AttributeState { const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' }); if (parsed.errors && parsed.errors.length > 0) { throw new CanvasEditError( `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`, { canvas: canvasAbsPath, id } ); } if (typeof occurrence === 'number' && Number.isFinite(occurrence)) { id = resolveUsageId(parsed.program, id, occurrence); } const hit = findOpening(parsed.program, id); if (!hit) { throw new CanvasEditError(`data-cd-id "${id}" not found in ${canvasAbsPath}`, { canvas: canvasAbsPath, id, }); } if (attr.startsWith('style.')) { const style = findAttribute(hit.opening, 'style'); if (!style) return { kind: 'absent' }; const obj = style.value?.type === 'JSXExpressionContainer' ? style.value.expression : null; if (obj?.type !== 'ObjectExpression') return { kind: 'expression' }; const prop = attr.slice('style.'.length); const propCamel = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); for (const p of obj.properties as AnyNode[]) { if (p?.type !== 'Property' && p?.type !== 'ObjectProperty') continue; const k = p.key; const kname = k?.type === 'Identifier' ? k.name : k?.type === 'Literal' ? String(k.value) : null; if (kname !== prop && kname !== propCamel) continue; const text = literalText(p.value); return text === null ? { kind: 'expression' } : { kind: 'literal', value: text }; } // A spread may supply the key at runtime; "absent" would be a guess. return (obj.properties as AnyNode[]).some((p) => p?.type === 'SpreadElement') ? { kind: 'expression' } : { kind: 'absent' }; } const found = findAttribute(hit.opening, attr); if (!found) return { kind: 'absent' }; if (found.value == null) return { kind: 'literal', value: '' }; const direct = literalText(found.value); if (direct !== null) return { kind: 'literal', value: direct }; const inner = found.value.type === 'JSXExpressionContainer' ? literalText(found.value.expression) : null; return inner === null ? { kind: 'expression' } : { kind: 'literal', value: inner }; } /** * `'apply'` when the source still holds `expected`; `'already'` when it holds * `next` (a retried or already-applied write — idempotent success). Anything * else is a peer's newer value: refuse rather than overwrite it. */ function checkPrecondition( state: AttributeState, id: string, attr: string, canvasAbsPath: string, pre: AttributePrecondition ): 'apply' | 'already' { const holds = (value: string | null) => value === null ? state.kind === 'absent' : state.kind === 'literal' && state.value === value; if (holds(pre.next)) return 'already'; if (holds(pre.expected)) return 'apply'; const name = attr.startsWith('style.') ? attr.slice('style.'.length) : attr; throw new CanvasEditError(`${name} was changed by someone else — kept their value`, { canvas: canvasAbsPath, id, conflict: true, }); } /** Pure variant of `removeAttribute` — exposed for tests. */ export function applyRemove( canvasAbsPath: string, source: string, id: string, attr: string, occurrence?: number ): EditResult { const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' }); if (parsed.errors && parsed.errors.length > 0) { const first = parsed.errors[0]; throw new CanvasEditError( `oxc-parser failed on ${canvasAbsPath}: ${first?.message ?? 'unknown'}`, { canvas: canvasAbsPath, id, } ); } // Stage H3 — mirror applyEdit: a whole-instance reset routes to the dragged // occurrence's `` usage (no-op for a normal / single-usage element). if (typeof occurrence === 'number' && Number.isFinite(occurrence)) { id = resolveUsageId(parsed.program, id, occurrence); } const hit = findOpening(parsed.program, id); if (!hit) { throw new CanvasEditError(`data-cd-id "${id}" not found in ${canvasAbsPath}`, { canvas: canvasAbsPath, id, }); } const s = new MagicString(source); if (attr.startsWith('style.')) { removeStyleProp(s, hit.opening, attr.slice('style.'.length), source); } else if (attr === 'data-cd-id') { throw new CanvasEditError('data-cd-id is owned by the pipeline; cannot be removed', { canvas: canvasAbsPath, id, }); } else { removeStringAttr(s, hit.opening, attr, source); } const out = s.toString(); return { source: out, delta: out.length - source.length }; } /** * Apply an inline TEXT-content edit to the JSX element with the given * `data-cd-id`. Leaf-text only: the element's children must be exactly one * `JSXText` node (whitespace-only siblings are ignored). Mixed/expression * children (`x`, `{count}`) throw `CanvasEditError` — the caller should * surface a "use /design:edit" refusal rather than guess. The text is * JSX-escaped before it touches source. Same atomic write + per-file lock as * `editAttribute`. See DDR-103. */ export async function editText( canvasAbsPath: string, id: string, text: string, opts?: DynamicTextOpts ): Promise { return withLock(canvasAbsPath, async () => { const file = Bun.file(canvasAbsPath); if (!(await file.exists())) { throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, { canvas: canvasAbsPath, id, }); } const source = await file.text(); // Byte cap before the AST walk (ethical-hacker F-C) — a real canvas is well // under this; a pathologically large attacker-authored file that would make // the resolver's per-candidate walk expensive is refused up front. if (source.length > 4_000_000) { throw new CanvasEditError(`canvas too large to edit inline (${source.length} bytes)`, { canvas: canvasAbsPath, id, }); } const next = applyTextEdit(canvasAbsPath, source, id, text, opts); if (next.source === source) return { source, delta: 0, changed: false }; const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`; await Bun.write(tmp, next.source); const { rename } = await import('node:fs/promises'); await rename(tmp, canvasAbsPath); return { ...next, changed: true }; }); } /** * Pure variant — exposed for tests + in-memory pipelines. Caller owns * persistence. Throws CanvasEditError if the ID isn't found or the edit shape * isn't representable. */ export function applyEdit( canvasAbsPath: string, source: string, id: string, attr: string, value: string, occurrence?: number ): EditResult { const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' }); if (parsed.errors && parsed.errors.length > 0) { const first = parsed.errors[0]; throw new CanvasEditError( `oxc-parser failed on ${canvasAbsPath}: ${first?.message ?? 'unknown'}`, { canvas: canvasAbsPath, id } ); } // feature-element-editing-robustness Stage H3 — when the caller passes an // explicit DOM-occurrence index (a whole-component-instance move/resize), route // the write to that occurrence's parent `` USAGE so the edit stays // LOCAL to the dragged instance (its own left/top/width/height) instead of // mutating the shared inner definition (which would move every instance). A // no-op for a normal element or a `.map()`ed single-usage one (resolveUsageId // returns `id`). Deliberately gated on `occurrence` being present: the CssKnobs // / paste-style paths pass NO occurrence, so styling an INNER shared element // stays global-and-labeled — the H2 badge is the answer there (the chosen model). if (typeof occurrence === 'number' && Number.isFinite(occurrence)) { id = resolveUsageId(parsed.program, id, occurrence); } const hit = findOpening(parsed.program, id); if (!hit) { throw new CanvasEditError(`data-cd-id "${id}" not found in ${canvasAbsPath}`, { canvas: canvasAbsPath, id, }); } const s = new MagicString(source); if (attr.startsWith('style.')) { editStyleProp(s, hit.opening, attr.slice('style.'.length), value, canvasAbsPath, id); } else if (attr === 'data-cd-id') { throw new CanvasEditError('data-cd-id is owned by the pipeline; cannot be edited', { canvas: canvasAbsPath, id, }); } else { editStringAttr(s, hit.opening, attr, value, canvasAbsPath, id); } const out = s.toString(); return { source: out, delta: out.length - source.length }; } /** * Extra context for editing text that comes from a `{variable}` rather than a * literal (unified-text-editing follow-up). `occurrence` = which rendered * instance the user edited (index among DOM nodes carrying this same cd-id — a * `.map()` renders one source element N×); `before` = the pre-edit rendered * text, used both to pick the right `.map()` item when the index drifts (a * `.filter().map()` etc.) and to refuse a rewrite we can't confidently target. */ export interface DynamicTextOpts { occurrence?: number; before?: string; } /** * Pure variant of `editText` — parse, locate the JSXText child, overwrite its * source span (preserving the original leading/trailing whitespace so JSX * indentation survives), escaping the new text. A single `{'literal'}` child is * rewritten in place (DDR-150 P1). A single `{variable}` / `{item.prop}` child * is resolved back to its source string (a local `const` or a `.map()`ed array * element) when `opts` carries enough to target it unambiguously — otherwise it * throws `CanvasEditError` (genuinely dynamic → route to /design:edit), same as * mixed content. */ export function applyTextEdit( canvasAbsPath: string, source: string, id: string, text: string, opts?: DynamicTextOpts ): EditResult { const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' }); if (parsed.errors && parsed.errors.length > 0) { const first = parsed.errors[0]; throw new CanvasEditError( `oxc-parser failed on ${canvasAbsPath}: ${first?.message ?? 'unknown'}`, { canvas: canvasAbsPath, id } ); } const hit = findOpening(parsed.program, id); if (!hit) { throw new CanvasEditError(`data-cd-id "${id}" not found in ${canvasAbsPath}`, { canvas: canvasAbsPath, id, }); } const children: AnyNode[] = Array.isArray(hit.element?.children) ? hit.element.children : []; // Ignore whitespace-only JSXText siblings (`` parses // as one JSXText; `\n \n` parses as ws + element + ws — the ws is // noise). What's left is the "real" content. const meaningful = children.filter( (c) => !(c?.type === 'JSXText' && typeof c.value === 'string' && c.value.trim() === '') ); if (meaningful.length === 0) { throw new CanvasEditError(`element "${id}" has no editable text content`, { canvas: canvasAbsPath, id, }); } const only = meaningful[0]; // A single `{'string literal'}` expression child — `

{'Title'}

` — is // editable (DDR-150 P1): rewrite the literal in place. Written back via // JSON.stringify so the result is an inert, correctly-escaped quoted string — // the value never leaves the `{...}`, so (unlike JSXText) there is no markup / // entity injection surface to guard. Any OTHER expression (identifier, // template, call, member — `{title}`, `` {`${n} items`} ``) is genuinely // dynamic: refuse and route to /design:edit rather than delete the binding. if (meaningful.length === 1 && only?.type === 'JSXExpressionContainer') { const expr = (only as AnyNode).expression; // A single `{'string literal'}` — rewrite the literal in place (DDR-150 P1). if (isStringLit(expr)) { if (expr.value === text) return { source, delta: 0 }; const s = new MagicString(source); s.overwrite(expr.start as number, expr.end as number, JSON.stringify(text)); const out = s.toString(); return { source: out, delta: out.length - source.length }; } // A single `{variable}` / `{item.prop}` — trace it back to its source string // (a local const, or a `.map()`ed array element) and rewrite THERE. The // literal that comes back is a JS string, so it round-trips through // JSON.stringify like the `{'literal'}` case above (no JSX-entity surface). const span = resolveDynamicTextSpan(parsed.program, hit.element, expr, id, opts); if (span) { const s = new MagicString(source); s.overwrite(span.start as number, span.end as number, JSON.stringify(text)); const out = s.toString(); return { source: out, delta: out.length - source.length }; } throw new CanvasEditError(`element "${id}" has dynamic content — edit it via /design:edit`, { canvas: canvasAbsPath, id, }); } if (meaningful.length > 1 || only?.type !== 'JSXText') { throw new CanvasEditError( `element "${id}" has mixed or expression content — edit it via /design:edit`, { canvas: canvasAbsPath, id } ); } const start = only.start as number; const end = only.end as number; const raw = source.slice(start, end); // The text it already shows is not an edit (plan T23): a multi-line JSX text // renders as its lines joined by one space, and rewriting it with that same // text used to fold the author's line breaks into one line. if (jsxRenderedText(raw) === escapeJsxText(text)) return { source, delta: 0 }; // Preserve the original indentation/newline framing; swap only the visible text. const lead = /^\s*/.exec(raw)?.[0] ?? ''; const trail = /\s*$/.exec(raw)?.[0] ?? ''; const s = new MagicString(source); s.overwrite(start, end, `${lead}${escapeJsxText(text)}${trail}`); const out = s.toString(); return { source: out, delta: out.length - source.length }; } // --------------------------------------------------------------------------- // Dynamic-text resolution (unified-text-editing follow-up). A `{variable}` / // `{item.prop}` text child has no literal to rewrite at the element — the // string lives in a `const` or a `.map()`ed data array. These helpers trace it // back to that source StringLiteral so inline editing works there too, WITHOUT // ever risking the wrong rewrite: the occurrence index picks the `.map()` item, // and the pre-edit text (`before`) both verifies that pick and rescues it when // the index drifts (`.filter().map()`, reorders). Anything we can't target // unambiguously returns null → the caller throws → routes to /design:edit. /** * JSX's own whitespace rule for a text child, on the raw source: each line is * trimmed (inner lines on both sides), empty lines drop, and the rest join with * one space. Entities stay encoded — the caller compares against escaped text. */ function jsxRenderedText(raw: string): string { const lines = raw.split(/\r\n|\n|\r/); if (lines.length === 1) return raw.trim(); return lines .map((line, i) => { let l = line; if (i !== 0) l = l.replace(/^[ \t]+/, ''); if (i !== lines.length - 1) l = l.replace(/[ \t]+$/, ''); return l; }) .filter((l) => l.length > 0) .join(' ') .trim(); } function isStringLit(n: AnyNode): boolean { return !!( n && (n.type === 'Literal' || n.type === 'StringLiteral') && typeof n.value === 'string' ); } /** Depth-first visit every AST node (skips location metadata keys). */ function walkAst(root: AnyNode, fn: (node: AnyNode) => void): void { function visit(node: AnyNode): void { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { for (const c of node) visit(c); return; } if (typeof node.type !== 'string') return; fn(node); for (const k of Object.keys(node)) { if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue; visit(node[k]); } } visit(root); } /** The innermost `xs.map((param) => …)` whose callback param is `paramName` * and whose body byte-range encloses `element`. Returns the mapped array * expression (an Identifier or an inline ArrayExpression), or null. */ function findEnclosingMapArray( program: AnyNode, element: AnyNode, paramName: string ): AnyNode | null { const es = element.start as number; const ee = element.end as number; let best: AnyNode | null = null; let bestSize = Number.POSITIVE_INFINITY; walkAst(program, (node) => { if (node.type !== 'CallExpression') return; const callee = node.callee; if (callee?.type !== 'MemberExpression' || callee.computed) return; if (callee.property?.name !== 'map') return; const cb = node.arguments?.[0]; if (cb?.type !== 'ArrowFunctionExpression' && cb?.type !== 'FunctionExpression') return; const p0 = cb.params?.[0]; if (p0?.type !== 'Identifier' || p0.name !== paramName) return; const body = cb.body; if (typeof body?.start !== 'number' || typeof body?.end !== 'number') return; if (!(body.start <= es && ee <= body.end)) return; const size = (body.end as number) - (body.start as number); if (size < bestSize) { bestSize = size; best = callee.object; } }); return best; } /** Resolve an array expression (an inline `[…]` or an Identifier bound to a * `const xs = […]`) to its ArrayExpression node. */ function resolveArrayExpr(program: AnyNode, arrayExpr: AnyNode): AnyNode | null { if (arrayExpr?.type === 'ArrayExpression') return arrayExpr; if (arrayExpr?.type === 'Identifier') { let found: AnyNode | null = null; walkAst(program, (node) => { if ( node.type === 'VariableDeclarator' && node.id?.type === 'Identifier' && node.id.name === arrayExpr.name && node.init?.type === 'ArrayExpression' ) { found = node.init; } }); return found; } return null; } /** The value node of property `propName` on an ObjectExpression, or null. */ function objPropValue(objExpr: AnyNode, propName: string): AnyNode | null { if (objExpr?.type !== 'ObjectExpression') return null; for (const p of objExpr.properties ?? []) { if (p?.type !== 'Property' || p.computed) continue; const keyOk = (p.key?.type === 'Identifier' && p.key.name === propName) || (isStringLit(p.key) && p.key.value === propName); if (keyOk) return p.value ?? null; } return null; } /** * Bounded expression evaluator — resolve `expr` to a concrete value node * (a StringLiteral, ObjectExpression, or ArrayExpression) by following const * bindings, numeric array indices (`XS[0]`), and object-field access * (`obj.field`). Returns null for anything genuinely computed (calls, template * strings, arithmetic). Depth-capped so a cyclic const can't loop. */ function resolveValueNode(program: AnyNode, expr: AnyNode, depth = 0): AnyNode | null { if (!expr || depth > 8) return null; // Unwrap `x as T` / `x satisfies T` type-cast wrappers (e.g. a shared // `print={A1_PRINT as any}` prop, or a `const X = {...} as const` // declaration) — the cast carries no runtime value, so the wrapped // expression is what every branch below actually needs to match against. while (expr && (expr.type === 'TSAsExpression' || expr.type === 'TSSatisfiesExpression')) { expr = expr.expression; } if (!expr) return null; if (isStringLit(expr)) return expr; if (expr.type === 'ObjectExpression' || expr.type === 'ArrayExpression') return expr; if (expr.type === 'Identifier') { let init: AnyNode | null = null; walkAst(program, (n) => { if ( n.type === 'VariableDeclarator' && n.id?.type === 'Identifier' && n.id.name === expr.name && n.init ) { init = n.init; } }); return init ? resolveValueNode(program, init, depth + 1) : null; } if (expr.type === 'MemberExpression') { const obj = resolveValueNode(program, expr.object, depth + 1); if (!obj) return null; if (expr.computed) { const idx = expr.property; if ( (idx?.type === 'Literal' || idx?.type === 'NumericLiteral') && typeof idx.value === 'number' && obj.type === 'ArrayExpression' ) { return resolveValueNode(program, obj.elements?.[idx.value], depth + 1); } return null; } if (expr.property?.type === 'Identifier') { return resolveValueNode(program, objPropValue(obj, expr.property.name), depth + 1); } return null; } return null; } /** Ordered value-expressions of a `` usage, one per * usage in source order (aligns with the DOM occurrence of the element the * prop feeds). null slot = a usage that omits the prop. */ function componentUsageValues( program: AnyNode, componentName: string, propName: string ): Array | null { if (!componentName) return null; const out: Array = []; walkAst(program, (node) => { if (node.type !== 'JSXElement') return; const name = node.openingElement?.name; if (name?.type !== 'JSXIdentifier' || name.name !== componentName) return; let val: AnyNode | null = null; for (const a of node.openingElement?.attributes ?? []) { if ( a?.type === 'JSXAttribute' && a.name?.type === 'JSXIdentifier' && a.name.name === propName ) { val = a.value?.type === 'JSXExpressionContainer' ? a.value.expression : isStringLit(a.value) ? a.value : null; break; } } out.push(val); }); return out.length ? out : null; } /** * Trace a single `{base}` / `{base.field}` text expression back to the * StringLiteral node that holds its text, or null when it can't be targeted * confidently. `base` is resolved through three bindings — a `.map()` callback * param (array items), a component PROP (each `` usage), or a * local `const` — and each candidate is run through the bounded evaluator * (`beat.caption` where `beat={BEATS[0]}` → `BEATS[0].caption`). The occurrence * index picks the slot; `before` verifies it and rescues a drifted index; ties * are never guessed. `occurrence`/`before` come from the edited DOM instance. */ function resolveDynamicTextSpan( program: AnyNode, element: AnyNode, expr: AnyNode, id: string, opts?: DynamicTextOpts ): AnyNode | null { // Decompose into base Identifier + optional single field. let baseName: string; let field: string | null; if (expr?.type === 'Identifier') { baseName = expr.name; field = null; } else if ( expr?.type === 'MemberExpression' && !expr.computed && expr.object?.type === 'Identifier' && expr.property?.type === 'Identifier' ) { baseName = expr.object.name; field = expr.property.name; } else { return null; } // Candidate value-expressions for `base`, one per rendered slot. let candidates: Array | null = null; // (1) a `.map()` callback param → the array items. const arrExpr = findEnclosingMapArray(program, element, baseName); if (arrExpr) { const arr = resolveArrayExpr(program, arrExpr); candidates = arr ? (arr.elements ?? []) : null; } // (2) a component prop → each `` usage's value. if (!candidates) { const componentName = collectElementsFull(program).find((e) => e.id === id)?.componentName ?? ''; candidates = componentUsageValues(program, componentName, baseName); } // (3) a local const → a single candidate. if (!candidates) { let init: AnyNode | null = null; walkAst(program, (n) => { if ( n.type === 'VariableDeclarator' && n.id?.type === 'Identifier' && n.id.name === baseName ) { init = n.init ?? null; } }); candidates = init ? [init] : null; } if (!candidates) return null; // Complexity cap (ethical-hacker F-C): each candidate runs a full-program // walkAst per resolution step, so an attacker-authored canvas with a huge // array of `{identifier}` items could make this O(N × program). A real // `.map()`/usage list is tiny; anything past the cap is refused (a single // edit couldn't confidently target one of thousands of slots anyway). const MAX_CANDIDATES = 1000; if (candidates.length > MAX_CANDIDATES) return null; // Resolve each candidate to a StringLiteral (applying `.field` when present). const lits: Array = candidates.map((c) => { const base = resolveValueNode(program, c); if (!base) return null; if (field) { const v = resolveValueNode(program, objPropValue(base, field)); return isStringLit(v) ? v : null; } return isStringLit(base) ? base : null; }); // Pick: occurrence index (verified by `before`), else the UNIQUE `before` // match, else — only with no `before` to verify — the raw index. No guessing. const beforeTrim = (opts?.before ?? '').trim(); const occ = typeof opts?.occurrence === 'number' && opts.occurrence >= 0 ? opts.occurrence : null; const at = occ != null ? lits[occ] : null; if (at && (!beforeTrim || String(at.value).trim() === beforeTrim)) return at; if (beforeTrim) { const hits = lits.filter((c): c is AnyNode => !!c && String(c.value).trim() === beforeTrim); return hits.length === 1 ? (hits[0] ?? null) : null; } return at ?? null; } // --------------------------------------------------------------------------- // Node-move reorder (DDR-138, phase-12.1). Moving a whole JSXElement to a new // sibling/parent position — the one structural edit `editAttribute`/`editText` // don't do. Remove the element's line-span + insert a re-indented copy at an // anchor derived from `refId` + `position`. A same-parent reorder is the // degenerate case where source/target indent match (no re-indent). Reparenting // works because we re-indent (not raw magic-string.move). Guardrails refuse // structurally-unsafe moves; a post-move reparse gate guarantees we never write // corrupt source. The move response recomputes the moved element's positional // id (matches what the pipeline assigns on the next pass) + surfaces its // author-semantic `data-dc-element` handle so the client can re-settle the // selection through the id churn. export type MovePosition = 'before' | 'after' | 'inside-start' | 'inside-end'; export interface MoveResult extends EditResult { /** Recomputed positional id of the moved element (== the DOM `data-cd-id` * after the next pipeline pass). Best-effort — null if not resolvable. */ movedId: string | null; /** The moved element's `data-dc-element` value (DDR-007), if it has one — a * stable re-select key that survives the id churn byte-for-byte. */ semanticId: string | null; } /** Read a plain-string JSX attribute value off an opening element (null when * absent or not a string literal). */ function getStringAttr(opening: AnyNode, name: string): string | null { const attr = findAttribute(opening, name); if (!attr) return null; const v = attr.value; if (v == null) return ''; if (v.type === 'Literal' || v.type === 'StringLiteral') return String(v.value); return null; } /** Info about the line a byte offset sits on: the whitespace indentation, where * it begins, and whether a newline immediately precedes it. */ export function lineStartInfo( source: string, pos: number ): { indent: string; indentStart: number; newlineBefore: boolean } { let i = pos; while (i > 0 && (source[i - 1] === ' ' || source[i - 1] === '\t')) i--; return { indent: source.slice(i, pos), indentStart: i, newlineBefore: i > 0 && source[i - 1] === '\n', }; } /** Detect the file's indentation unit (tab vs N spaces). Canvases are Prettier * 2-space by default; fall back to that. */ function detectIndentUnit(source: string): string { if (/\n\t/.test(source)) return '\t'; const m = /\n( +)\S/.exec(source); return m ? m[1] : ' '; } /** Re-indent an element's source text from `fromIndent` (its old line indent) to * `toIndent` (the target depth). The first line carries no leading indent in * `elText` (it starts at the element), so only continuation lines shift, by the * same delta, preserving the element's internal structure. */ function reindentBlock(elText: string, fromIndent: string, toIndent: string): string { if (fromIndent === toIndent) return elText; const lines = elText.split('\n'); return lines .map((line, i) => { if (i === 0) return line; const lead = /^[ \t]*/.exec(line)?.[0] ?? ''; const rest = line.slice(lead.length); if (rest === '') return line; // blank line — leave as-is const rel = lead.startsWith(fromIndent) ? lead.slice(fromIndent.length) : lead; return toIndent + rel + rest; }) .join('\n'); } /** Normalize element text for duplicate-tolerant matching: trim each line so * re-indentation differences don't defeat the match. */ function normalizeForMatch(t: string): string { return t .split('\n') .map((l) => l.trim()) .join('\n'); } /** Walk the program with the SAME component + jsxIndex bookkeeping the pipeline * uses (canvas-pipeline.ts walkInjectIds), collecting every JSXElement with the * id it will be assigned. Reused to recompute the moved element's post-move id. */ function collectElements(program: AnyNode): Array<{ id: string; node: AnyNode }> { interface Frame { componentName: string; jsxIndex: number; } const stack: Frame[] = [{ componentName: '', jsxIndex: 0 }]; const out: Array<{ id: string; node: AnyNode }> = []; function visit(node: AnyNode): void { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { for (const c of node) visit(c); return; } if (typeof node.type !== 'string') return; const newComp = componentNameOf(node); let pushed = false; if (newComp !== null) { stack.push({ componentName: newComp, jsxIndex: 0 }); pushed = true; } if (node.type === 'JSXElement') { const frame = stack[stack.length - 1] as Frame; const idx = frame.jsxIndex; frame.jsxIndex += 1; out.push({ id: authoredCdId(node.openingElement) ?? computeId(frame.componentName, idx), node, }); if (node.openingElement) visit(node.openingElement.attributes); visit(node.children); if (pushed) stack.pop(); return; } for (const k of Object.keys(node)) { if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue; visit(node[k]); } if (pushed) stack.pop(); } visit(program); return out; } /** * Every JSX element with its enclosing-component frame + tag. Superset of * collectElements used to resolve a reused-component INSTANCE to its parent * USAGE (see resolveUsageId). */ function collectElementsFull( program: AnyNode ): Array<{ id: string; componentName: string; isFrameRoot: boolean; tag: string | null }> { interface Frame { componentName: string; jsxIndex: number; } const stack: Frame[] = [{ componentName: '', jsxIndex: 0 }]; const out: Array<{ id: string; componentName: string; isFrameRoot: boolean; tag: string | null; }> = []; function tagOf(node: AnyNode): string | null { const n = node?.openingElement?.name; if (n?.type === 'JSXIdentifier' && typeof n.name === 'string') return n.name; return null; } function visit(node: AnyNode): void { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { for (const c of node) visit(c); return; } if (typeof node.type !== 'string') return; const newComp = componentNameOf(node); let pushed = false; if (newComp !== null) { stack.push({ componentName: newComp, jsxIndex: 0 }); pushed = true; } if (node.type === 'JSXElement') { const frame = stack[stack.length - 1] as Frame; const idx = frame.jsxIndex; frame.jsxIndex += 1; out.push({ id: authoredCdId(node.openingElement) ?? computeId(frame.componentName, idx), componentName: frame.componentName, isFrameRoot: idx === 0, tag: tagOf(node), }); if (node.openingElement) visit(node.openingElement.attributes); visit(node.children); if (pushed) stack.pop(); return; } for (const k of Object.keys(node)) { if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue; visit(node[k]); } if (pushed) stack.pop(); } visit(program); return out; } /** * feature-4 T7a (layers purple instances, DDR-187 scope note) — the component * map the shell's Layers panel renders instance rows from. For every element id * whose ENCLOSING component is actually instantiated as a JSX element in this * file (`` — i.e. a reusable component, not the top-level canvas * component, which is exported/mounted but never referenced as JSX here), * report `{ component, root, usages }`. `root` marks the component's own frame * root (the "instance" row in Figma terms — ◆-adjacent); inner members get the * same purple treatment at lower prominence. Elements of never-instantiated * components (the canvas root) are omitted — keeps the payload proportional to * actual instances. */ export function componentMapForCanvas( canvasAbsPath: string, source: string ): Record { const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' }); if (parsed.errors && parsed.errors.length > 0) return {}; const all = collectElementsFull(parsed.program); // Usage count per component name = JSX elements whose TAG is that name. const usages = new Map(); for (const e of all) { if (e.tag && /^[A-Z]/.test(e.tag)) { usages.set(e.tag, (usages.get(e.tag) ?? 0) + 1); } } const out: Record = {}; for (const e of all) { if (!e.componentName) continue; const n = usages.get(e.componentName) ?? 0; if (n < 1) continue; // top-level canvas component — not an instance // First writer wins — repeated ids (a `.map`) share one source element; the // map is keyed by source id, which is exactly what the Layers rows carry. if (!out[e.id]) out[e.id] = { component: e.componentName, root: e.isFrameRoot, usages: n }; } return out; } /** * Resolve a reused-component INSTANCE id to the parent's `` USAGE id. * * A component used N times (`` … ``) renders N DOM nodes that * all carry ONE data-cd-id — the id of an element INSIDE the component's * definition. That id can't be moved (there's only one, inside the loop-free * component body). But each USAGE in the parent is a distinct, movable JSX * element. So when `domId` names an element that lives inside a component with * multiple usages, map it to the `occurrenceIndex`-th usage (the occurrence index * of ANY element in the component equals the instance index = the usage index). * Falls through to `domId` for a normal element, or a `.map()`ed single-usage * element (one usage → can't split). Reordering elements WITHIN a reused * component's definition isn't reachable via drag (edit the component source). */ function resolveUsageId( program: AnyNode, domId: string, occurrenceIndex: number | undefined ): string { const all = collectElementsFull(program); const target = all.find((e) => e.id === domId); if (!target?.componentName) return domId; const usages = all.filter((e) => e.tag === target.componentName); if (usages.length <= 1) return domId; // single usage (or `.map`) — not splittable const i = typeof occurrenceIndex === 'number' && occurrenceIndex >= 0 && occurrenceIndex < usages.length ? occurrenceIndex : 0; return usages[i]?.id ?? domId; } /** * Edit-scope verdict for the INV-3 predictability badge (feature-element-editing- * robustness Stage H). Tells the Inspector whether a style/attr edit to `domId` * stays LOCAL (this one rendered place) or is SHARED (changes N places). */ export interface EditScope { scope: 'local' | 'shared'; /** Enclosing reused-component name when the element lives inside one, else null. */ componentName: string | null; /** How many rendered places an edit to this element touches. */ affects: number; /** single = a lone element · component = inside an N-usage component · mapped = * one source element rendered N× via `.map()` (DDR-139 §1). */ reason: 'single' | 'component' | 'mapped'; } /** * Resolve whether an edit to `domId` is local or shared — composing the SAME * primitives `resolveUsageId` ships: the element's enclosing `componentName` plus * the source usage count of that component. `renderedCount` is the number of DOM * nodes carrying this cd-id (the client knows it): a single source element * rendered N× through `.map()` is `shared` ('mapped') even with one source usage, * so the badge never lies. A parse failure degrades to `local` (a badge must * never crash selection). Pure — unit-tested without a DOM. */ export function resolveEditScope( canvasAbsPath: string, source: string, domId: string, renderedCount = 1 ): EditScope { const rendered = Number.isFinite(renderedCount) && renderedCount > 0 ? Math.floor(renderedCount) : 1; let program: AnyNode; try { const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' }); if (parsed.errors && parsed.errors.length > 0) { return { scope: 'local', componentName: null, affects: 1, reason: 'single' }; } program = parsed.program; } catch { return { scope: 'local', componentName: null, affects: 1, reason: 'single' }; } const all = collectElementsFull(program); const target = all.find((e) => e.id === domId); const compName = target?.componentName || ''; // Usages of the enclosing component = distinct `` tags in the tree. // 0 for a top-level artboard element (the Canvas fn is never ``'d). const usages = compName ? all.filter((e) => e.tag === compName).length : 0; if (usages > 1) { return { scope: 'shared', componentName: compName, affects: Math.max(usages, rendered), reason: 'component', }; } if (rendered > 1) { return { scope: 'shared', componentName: compName || null, affects: rendered, reason: 'mapped', }; } return { scope: 'local', componentName: null, affects: 1, reason: 'single' }; } /** * Move the element with `data-cd-id === id` to a position relative to the element * with `data-cd-id === refId`. Async wrapper: read, apply, atomic write under the * per-file mutex — identical persistence to `editAttribute`. * * `idIndex` / `refIndex` are the DOM occurrence indices — which rendered instance * of a reused component the id refers to (0 for a normal, single-render element). */ export async function moveElement( canvasAbsPath: string, id: string, refId: string, position: MovePosition, idIndex?: number, refIndex?: number ): Promise { return withLock(canvasAbsPath, async () => { const file = Bun.file(canvasAbsPath); if (!(await file.exists())) { throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, { canvas: canvasAbsPath, id, }); } const source = await file.text(); const next = applyMove(canvasAbsPath, source, id, refId, position, idIndex, refIndex); if (next.source === source) return next; const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`; await Bun.write(tmp, next.source); const { rename } = await import('node:fs/promises'); await rename(tmp, canvasAbsPath); return next; }); } /** Pure variant of `moveElement` — exposed for tests. Never mutates disk. */ export function applyMove( canvasAbsPath: string, source: string, id: string, refId: string, position: MovePosition, idIndex?: number, refIndex?: number ): MoveResult { const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' }); if (parsed.errors && parsed.errors.length > 0) { const first = parsed.errors[0]; throw new CanvasEditError( `oxc-parser failed on ${canvasAbsPath}: ${first?.message ?? 'unknown'}`, { canvas: canvasAbsPath, id } ); } // Map reused-component INSTANCE ids to their parent USAGE elements before the // move (a shared internal id isn't movable per-instance; the usage is). id = resolveUsageId(parsed.program, id, idIndex); refId = resolveUsageId(parsed.program, refId, refIndex); if (id === refId) { throw new CanvasEditError('cannot move an element relative to itself', { canvas: canvasAbsPath, id, }); } const moved = findOpening(parsed.program, id); if (!moved) { throw new CanvasEditError(`data-cd-id "${id}" not found in ${canvasAbsPath}`, { canvas: canvasAbsPath, id, }); } const ref = findOpening(parsed.program, refId); if (!ref) { throw new CanvasEditError(`reference data-cd-id "${refId}" not found in ${canvasAbsPath}`, { canvas: canvasAbsPath, id: refId, }); } const movedEl = moved.element; const refEl = ref.element; const mStart = movedEl.start as number; const mEnd = movedEl.end as number; const rStart = refEl.start as number; const rEnd = refEl.end as number; // Guardrail: the reference must not live inside the moved element's subtree — // that would splice the node into itself. if (rStart >= mStart && rEnd <= mEnd) { throw new CanvasEditError('cannot move an element into its own subtree', { canvas: canvasAbsPath, id, }); } const inside = position === 'inside-start' || position === 'inside-end'; if (inside && (refEl.openingElement?.selfClosing || !refEl.closingElement)) { throw new CanvasEditError( `target "${refId}" is self-closing — cannot nest an element inside it`, { canvas: canvasAbsPath, id: refId } ); } const indentUnit = detectIndentUnit(source); const elText = source.slice(mStart, mEnd); const movedLine = lineStartInfo(source, mStart); const removeStart = movedLine.newlineBefore ? movedLine.indentStart - 1 : movedLine.indentStart; // Resolve the target indent + insertion anchor per position. let targetIndent: string; let anchor: number; let insertText: string; if (position === 'after') { targetIndent = lineStartInfo(source, rStart).indent; const reText = reindentBlock(elText, movedLine.indent, targetIndent); anchor = rEnd; insertText = `\n${targetIndent}${reText}`; } else if (position === 'before') { const rLine = lineStartInfo(source, rStart); targetIndent = rLine.indent; const reText = reindentBlock(elText, movedLine.indent, targetIndent); if (rLine.newlineBefore) { anchor = rLine.indentStart - 1; insertText = `\n${targetIndent}${reText}`; } else { anchor = rLine.indentStart; insertText = `${targetIndent}${reText}\n`; } } else if (position === 'inside-start') { targetIndent = lineStartInfo(source, rStart).indent + indentUnit; const reText = reindentBlock(elText, movedLine.indent, targetIndent); anchor = refEl.openingElement.end as number; insertText = `\n${targetIndent}${reText}`; } else { // inside-end — insert as the last child, before the closing tag's own line. targetIndent = lineStartInfo(source, rStart).indent + indentUnit; const reText = reindentBlock(elText, movedLine.indent, targetIndent); const cStart = refEl.closingElement.start as number; const cLine = lineStartInfo(source, cStart); if (cLine.newlineBefore) { anchor = cLine.indentStart - 1; insertText = `\n${targetIndent}${reText}`; } else { anchor = cStart; insertText = `${reText}`; } } const s = new MagicString(source); s.remove(removeStart, mEnd); s.appendLeft(anchor, insertText); const out = s.toString(); // Reparse gate: never write source that doesn't parse. This is the catch-all // that lets us keep the guardrail set small. const check = parseSync(canvasAbsPath, out, { sourceType: 'module' }); if (check.errors && check.errors.length > 0) { const first = check.errors[0]; throw new CanvasEditError( `move would produce invalid source (${first?.message ?? 'parse error'}); aborted`, { canvas: canvasAbsPath, id } ); } // Re-settle hints. semanticId survives the move verbatim; movedId is the // recomputed positional id (matches the post-reload DOM). const semanticId = getStringAttr(movedEl.openingElement, 'data-dc-element'); let movedId: string | null = null; const wanted = normalizeForMatch(reindentBlock(elText, movedLine.indent, targetIndent)); for (const { id: eid, node } of collectElements(check.program)) { if (normalizeForMatch(out.slice(node.start as number, node.end as number)) === wanted) { movedId = eid; break; } } return { source: out, delta: out.length - source.length, movedId, semanticId }; } // --------------------------------------------------------------------------- // DDR-148 — Timeline drag-to-retime. Rewrites a `<...Sequence>`'s // `durationInFrames` / `from` to a new frame count. Sequences are addressed by // their document ORDER (the same order timeline-parse.js tokenizes them), not a // data-cd-id — a member-expression element (`TransitionSeries.Sequence`) has no // stable cd-id, and the order is what the Timeline UI already knows. export interface RetimePatch { durationInFrames?: number; from?: number; } const SEQ_TAG_RE = /<(?:TransitionSeries\.Sequence|Series\.Sequence|Sequence)\b[^>]*>/g; /** * Rewrite one attribute on a sequence tag. Prefers editing a referenced const * (`durationInFrames={A}` → bump `const A = …`) so a derived total * (`const TOTAL = A + B - XF`) updates in lock-step; falls back to editing a * literal in place; refuses a non-trivial expression (returns false). */ function retimeAttr( s: MagicString, source: string, tag: string, tagStart: number, key: 'durationInFrames' | 'from', newVal: number ): boolean { const am = tag.match(new RegExp(`\\b${key}=\\{\\s*([^}]*?)\\s*\\}`)); if (!am || am.index == null) { // Attr absent. For `from`, INSERT it — moving a cursor-implicit clip // (``) to a new start needs an explicit // `from` (DDR-150 P3 Task 6). durationInFrames is required on a clip, so // never auto-insert it. if (key === 'from') { const nameMatch = tag.match(/^<([A-Za-z][\w.]*)/); if (nameMatch) { s.appendLeft(tagStart + nameMatch[0].length, ` from={${newVal}}`); return true; } } return false; } const inner = am[1].trim(); if (/^[A-Za-z_$][\w$]*$/.test(inner)) { const cm = source.match(new RegExp(`\\bconst\\s+${inner}\\s*=\\s*(-?\\d+)`)); if (cm && cm.index != null && cm[1]) { const numStart = cm.index + cm[0].lastIndexOf(cm[1]); s.overwrite(numStart, numStart + cm[1].length, String(newVal)); return true; } } if (/^-?\d+$/.test(inner)) { const innerRel = am[0].indexOf(inner, am[0].indexOf('{')); const valStart = tagStart + am.index + innerRel; s.overwrite(valStart, valStart + inner.length, String(newVal)); return true; } return false; } /** Pure retime — exposed for tests. Never mutates disk. */ export function applyRetimeSequence( canvasAbsPath: string, source: string, seqIndex: number, patch: RetimePatch ): { source: string } { const s = new MagicString(source); SEQ_TAG_RE.lastIndex = 0; let i = 0; let touched = false; let m: RegExpExecArray | null = SEQ_TAG_RE.exec(source); while (m) { if (i === seqIndex) { const tag = m[0]; const tagStart = m.index; for (const [key, val] of [ ['durationInFrames', patch.durationInFrames], ['from', patch.from], ] as const) { if (val == null || !Number.isFinite(val)) continue; if (retimeAttr(s, source, tag, tagStart, key, Math.max(0, Math.round(val)))) touched = true; } break; } i += 1; m = SEQ_TAG_RE.exec(source); } if (!touched) { throw new CanvasEditError(`no retimable sequence at index ${seqIndex}`, { canvas: canvasAbsPath, id: String(seqIndex), }); } const next = s.toString(); const parsed = parseSync(canvasAbsPath, next, { sourceType: 'module' }); if (parsed.errors && parsed.errors.length > 0) { throw new CanvasEditError( `retime produced invalid source: ${parsed.errors[0]?.message ?? 'parse error'}`, { canvas: canvasAbsPath, id: String(seqIndex) } ); } return { source: next }; } /** Retime a sequence on disk (atomic write + per-file lock, like moveElement). */ export async function retimeSequence( canvasAbsPath: string, seqIndex: number, patch: RetimePatch ): Promise<{ source: string }> { return withLock(canvasAbsPath, async () => { const file = Bun.file(canvasAbsPath); if (!(await file.exists())) { throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, { canvas: canvasAbsPath, id: String(seqIndex), }); } const source = await file.text(); const next = applyRetimeSequence(canvasAbsPath, source, seqIndex, patch); if (next.source === source) return next; const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`; await Bun.write(tmp, next.source); const { rename } = await import('node:fs/promises'); await rename(tmp, canvasAbsPath); return next; }); } /** * Retime a clip addressed by the enumerator's `stableId` (comp-scoped) instead of * a whole-file index — the DDR-150 P2 fix for the multi-comp mis-hit. Verifies * the content-hash fingerprint (refuse a stale/raced target), patches the clip's * own tag via `retimeAttr` (const-preferring), then reparse + semantic gate. */ export function applyRetimeSequenceByClip( canvasAbsPath: string, source: string, artboardId: string | undefined, stableId: string, expectedHash: string | undefined, patch: RetimePatch ): { source: string } { const clip = resolveClip(canvasAbsPath, source, artboardId, stableId, expectedHash); // A `from` move is STANDALONE--only. `` / // `` compute their own offsets — Remotion silently IGNORES a // `from` prop on them, so patching/inserting one writes a lie: the timeline // draws a gap while the rendered video never changes (the dogfood bug — // "video je furt stejné, ať klipem pohnu jakkoliv"). Refuse loudly instead. if (patch.from != null && clip.tag !== 'Sequence') { throw new CanvasEditError( `"${stableId}" is a ${clip.tag} — the series computes its position, so moving it has no effect. Trim its duration instead, or reorder the beats.`, { canvas: canvasAbsPath, id: stableId } ); } const s = new MagicString(source); SEQ_TAG_RE.lastIndex = 0; let touched = false; let m: RegExpExecArray | null = SEQ_TAG_RE.exec(source); while (m) { if (m.index === clip.start) { for (const [key, val] of [ ['durationInFrames', patch.durationInFrames], ['from', patch.from], ] as const) { if (val == null || !Number.isFinite(val)) continue; if (retimeAttr(s, source, m[0], m.index, key, Math.max(0, Math.round(val)))) touched = true; } break; } m = SEQ_TAG_RE.exec(source); } if (!touched) { throw new CanvasEditError(`clip "${stableId}" has no retimable from/durationInFrames`, { canvas: canvasAbsPath, id: stableId, }); } const next = s.toString(); const parsed = parseSync(canvasAbsPath, next, { sourceType: 'module' }); if (parsed.errors && parsed.errors.length > 0) { throw new CanvasEditError( `retime produced invalid source: ${parsed.errors[0]?.message ?? 'parse error'}`, { canvas: canvasAbsPath, id: stableId } ); } assertCompSemantics(canvasAbsPath, next); return { source: next }; } /** Retime a clip by stableId on disk (atomic write + cross-process lock). */ export async function retimeSequenceByClip( canvasAbsPath: string, artboardId: string | undefined, stableId: string, expectedHash: string | undefined, patch: RetimePatch ): Promise<{ source: string }> { return withLock(canvasAbsPath, async () => { const file = Bun.file(canvasAbsPath); if (!(await file.exists())) { throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, { canvas: canvasAbsPath, id: stableId, }); } const source = await file.text(); const next = applyRetimeSequenceByClip( canvasAbsPath, source, artboardId, stableId, expectedHash, patch ); if (next.source === source) return next; const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`; await Bun.write(tmp, next.source); const { rename } = await import('node:fs/promises'); await rename(tmp, canvasAbsPath); return next; }); } // --------------------------------------------------------------------------- // Clip addressing (DDR-150 P2). The SINGLE authoritative tokenizer for a // video-comp's clips. The Timeline UI addresses every op through the `stableId` // this returns, NOT its own regex position — killing the two-tokenizer // document-order disagreement that made destructive ops corrupt the wrong clip // on a multi-comp canvas (the debate's headline defect). AST-based, so it skips // tags inside comments/strings (the regex parser didn't) and scopes cleanly to // one comp's body even when several comps share a file. /** Sequence-family "clip" tags (the timeline rows). */ const CLIP_TAGS = new Set(['Sequence', 'Series.Sequence', 'TransitionSeries.Sequence']); /** Transition tags (occupy a slot between clips inside a TransitionSeries). */ const TRANSITION_TAGS = new Set(['Series.Transition', 'TransitionSeries.Transition']); /** Media tags that carry a `src` (the replace-media + drop targets). */ // `Maude*` spellings are the collision-avoidance ALIASES clip-ops emits when a // canvas already declares a component of that name (e.g. `export default // function Video()`). They are the same elements and must tokenize the same, // or the Timeline silently loses the clip's media (RCA // issue-mp4-audio-export-html5audio-silent-degrade follow-up). const MEDIA_TAGS = new Set([ 'Video', 'OffthreadVideo', 'Audio', 'Img', 'Image', 'MaudeVideo', 'MaudeAudio', 'MaudeImg', ]); /** Remotion/layout primitives that are structural, not their own timeline layer. */ const LAYER_SKIP_TAGS = new Set([ 'AbsoluteFill', 'Sequence', 'Series', 'Series.Sequence', 'Series.Transition', 'TransitionSeries', 'TransitionSeries.Sequence', 'TransitionSeries.Transition', 'Loop', 'Freeze', 'Fragment', ]); /** Full tag string of a JSXElement: `Sequence` | `TransitionSeries.Sequence` | … */ function jsxTagName(node: AnyNode): string | null { const n = node?.openingElement?.name; if (!n) return null; if (n.type === 'JSXIdentifier') return typeof n.name === 'string' ? n.name : null; if (n.type === 'JSXMemberExpression') { const obj = n.object?.type === 'JSXIdentifier' ? n.object.name : null; const prop = n.property?.type === 'JSXIdentifier' ? n.property.name : null; if (obj && prop) return `${obj}.${prop}`; } return null; } /** Evaluate a numeric AST expression against a resolved const map (literal, * negation, const identifier, or simple arithmetic of them). null if not * resolvable — from/duration are best-effort labels, never load-bearing for * addressing (that's stableId + contentHash). */ function evalNum(n: AnyNode, consts: Record): number | null { if (!n || typeof n !== 'object') return null; const t = n.type; if ((t === 'Literal' || t === 'NumericLiteral') && typeof n.value === 'number') return n.value; if (t === 'UnaryExpression' && n.operator === '-') { const v = evalNum(n.argument, consts); return v == null ? null : -v; } if (t === 'Identifier') return Object.hasOwn(consts, n.name) ? (consts[n.name] as number) : null; if (t === 'BinaryExpression') { const l = evalNum(n.left, consts); const r = evalNum(n.right, consts); if (l == null || r == null) return null; switch (n.operator) { case '+': return l + r; case '-': return l - r; case '*': return l * r; case '/': return r ? l / r : null; default: return null; } } return null; } /** Top-level numeric const bindings (3 passes so a derived `TOTAL = A + B` resolves). */ function collectNumericConsts(program: AnyNode): Record { const consts: Record = {}; const decls: Array<{ name: string; init: AnyNode }> = []; for (const node of program?.body ?? []) { if (node?.type !== 'VariableDeclaration') continue; for (const d of node.declarations ?? []) { if (d?.id?.type === 'Identifier' && d.init) decls.push({ name: d.id.name, init: d.init }); } } for (let pass = 0; pass < 3; pass += 1) { for (const { name, init } of decls) { if (Object.hasOwn(consts, name)) continue; const v = evalNum(init, consts); if (v != null && Number.isFinite(v)) consts[name] = Math.round(v); } } return consts; } /** Resolve a numeric JSX attribute (`from={20}` / `durationInFrames={A}`). */ function numAttr(opening: AnyNode, name: string, consts: Record): number | null { const a = findAttribute(opening, name); let v = a?.value; if (!v) return null; if (v.type === 'JSXExpressionContainer') v = v.expression; const n = evalNum(v, consts); return n == null ? null : Math.round(n); } /** First media descendant of a clip (its `