1/**
2 * CLI helper: deterministic accept/discard of variant sessions.
3 *
4 * Usage:
5 * node live-accept.mjs --id SESSION_ID --discard
6 * node live-accept.mjs --id SESSION_ID --variant N
7 *
8 * For discard: removes the entire variant wrapper and restores the original.
9 * For accept: replaces the wrapper with the chosen variant's content. If the
10 * session had a colocated <style> block, it's preserved with carbonize markers
11 * for a background agent to integrate into the project's CSS.
12 *
13 * Output: JSON to stdout.
14 */
15
16import fs from 'node:fs';
17import path from 'node:path';
18import { isGeneratedFile } from './is-generated.mjs';
19
20const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
21
22// ---------------------------------------------------------------------------
23// CLI
24// ---------------------------------------------------------------------------
25
26export async function acceptCli() {
27 const args = process.argv.slice(2);
28
29 if (args.includes('--help') || args.includes('-h')) {
30 console.log(`Usage: node live-accept.mjs [options]
31
32Deterministic accept/discard for live variant sessions.
33
34Modes:
35 --discard Remove variants, restore original
36 --variant N Accept variant N, discard the rest
37
38Required:
39 --id SESSION_ID Session ID of the variant wrapper
40
41Output (JSON):
42 { handled, file, carbonize }`);
43 process.exit(0);
44 }
45
46 const id = argVal(args, '--id');
47 const variantNum = argVal(args, '--variant');
48 const paramValuesRaw = argVal(args, '--param-values');
49 const isDiscard = args.includes('--discard');
50
51 if (!id) { console.error('Missing --id'); process.exit(1); }
52 if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
53
54 let paramValues = null;
55 if (paramValuesRaw) {
56 try { paramValues = JSON.parse(paramValuesRaw); }
57 catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept
58 }
59
60 // Find the file containing this session's markers
61 const found = findSessionFile(id, process.cwd());
62 if (!found) {
63 console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
64 process.exit(0);
65 }
66
67 const { file: targetFile, content, lines } = found;
68 const relFile = path.relative(process.cwd(), targetFile);
69
70 // Bail if the session lives in a generated file. The agent manually wrote
71 // the wrapper there for preview, and is responsible for writing the
72 // accepted variant to true source (or cleaning up on discard). See
73 // "Handle fallback" in live.md.
74 if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
75 console.log(JSON.stringify({
76 handled: false,
77 mode: 'fallback',
78 file: relFile,
79 hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',
80 }));
81 process.exit(0);
82 }
83
84 if (isDiscard) {
85 const result = handleDiscard(id, lines, targetFile);
86 console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
87 } else {
88 const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
89 // Single-line attention-grabber when cleanup is required. The full
90 // five-step checklist lives in reference/live.md (loaded once per
91 // session); repeating it per-event would waste tokens.
92 if (result.carbonize) {
93 result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".';
94 }
95 console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
96 }
97}
98
99// ---------------------------------------------------------------------------
100// Discard
101// ---------------------------------------------------------------------------
102
103function handleDiscard(id, lines, targetFile) {
104 const block = findMarkerBlock(id, lines);
105 if (!block) return { handled: false, error: 'Markers not found' };
106
107 const original = extractOriginal(lines, block);
108 const isJsx = detectCommentSyntax(targetFile).open === '{/*';
109 const replaceRange = expandReplaceRange(block, lines, isJsx);
110
111 // Restore at the line we're actually replacing FROM, not the marker line.
112 // For JSX wrappers the marker comments live INSIDE the outer `<div>`, so
113 // `block.start` sits 2 spaces deeper than the original element. Using that
114 // as the deindent base would push the restored content 2 spaces too far
115 // right on every JSX/TSX session. `replaceRange.start` is the outer wrapper
116 // line, which is at the original element's indent for both HTML and JSX.
117 const indent = lines[replaceRange.start].match(/^(\s*)/)[1];
118 const restored = deindentContent(original, indent);
119
120 const newLines = [
121 ...lines.slice(0, replaceRange.start),
122 ...restored,
123 ...lines.slice(replaceRange.end + 1),
124 ];
125 fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
126 return {};
127}
128
129// ---------------------------------------------------------------------------
130// Accept
131// ---------------------------------------------------------------------------
132
133function handleAccept(id, variantNum, lines, targetFile, paramValues) {
134 const block = findMarkerBlock(id, lines);
135 if (!block) return { handled: false, error: 'Markers not found' };
136
137 const commentSyntax = detectCommentSyntax(targetFile);
138 const isJsx = commentSyntax.open === '{/*';
139 // Anchor indent on the line we're replacing FROM (the outer wrapper),
140 // not on `block.start` — for JSX that's the marker comment 2 spaces
141 // deeper than the original element. See handleDiscard for the full
142 // rationale.
143 const replaceRange = expandReplaceRange(block, lines, isJsx);
144 const indent = lines[replaceRange.start].match(/^(\s*)/)[1];
145
146 // Extract the chosen variant's inner content
147 const variantContent = extractVariant(lines, block, variantNum);
148 if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
149
150 // Extract CSS block if present
151 const cssContent = extractCss(lines, block, id);
152
153 // Check if carbonizing is needed:
154 // - CSS block exists, OR
155 // - variant HTML contains helper classes/attributes that need cleanup
156 const variantText = variantContent.join('\n');
157 const hasHelperAttrs = variantText.includes('data-impeccable-variant');
158 const needsCarbonize = !!(cssContent || hasHelperAttrs);
159
160 // Build the replacement
161 const restored = deindentContent(variantContent, indent);
162 const replacement = [];
163
164 if (cssContent) {
165 replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
166 // JSX targets need the CSS body wrapped in a template literal so that the
167 // `{` and `}` in CSS rules don't get parsed as JSX expressions.
168 replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
169 // Re-indent CSS content to match
170 for (const cssLine of cssContent) {
171 replacement.push(indent + cssLine.trimStart());
172 }
173 replacement.push(indent + (isJsx ? '`}</style>' : '</style>'));
174 if (paramValues && Object.keys(paramValues).length > 0) {
175 // Preserve the user's knob positions for the carbonize-cleanup agent
176 // to bake into the final CSS when it collapses scoped rules.
177 replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
178 }
179 replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
180 }
181
182 // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the
183 // carbonize CSS block working visually by re-wrapping the accepted content
184 // in a data-impeccable-variant="N" div with `display: contents` (so layout
185 // isn't affected). The carbonize agent strips this attribute + wrapper when
186 // it moves the CSS to a proper stylesheet.
187 //
188 // Style attribute syntax has to follow the host file's flavor — JSX files
189 // need the object form, otherwise React 19 throws "Failed to set indexed
190 // property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
191 if (cssContent) {
192 const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
193 replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
194 replacement.push(...restored);
195 replacement.push(indent + '</div>');
196 } else {
197 replacement.push(...restored);
198 }
199
200 const newLines = [
201 ...lines.slice(0, replaceRange.start),
202 ...replacement,
203 ...lines.slice(replaceRange.end + 1),
204 ];
205 fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
206
207 return { carbonize: needsCarbonize };
208}
209
210// ---------------------------------------------------------------------------
211// Parsing helpers
212// ---------------------------------------------------------------------------
213
214/**
215 * Find the start/end marker lines for a session.
216 * Returns { start, end } (0-indexed line numbers) or null.
217 */
218function findMarkerBlock(id, lines) {
219 let start = -1;
220 let end = -1;
221 const startPattern = 'impeccable-variants-start ' + id;
222 const endPattern = 'impeccable-variants-end ' + id;
223
224 for (let i = 0; i < lines.length; i++) {
225 if (start === -1 && lines[i].includes(startPattern)) start = i;
226 if (lines[i].includes(endPattern)) { end = i; break; }
227 }
228
229 return (start !== -1 && end !== -1) ? { start, end } : null;
230}
231
232/**
233 * Compute the line range to REPLACE (vs. just the marker range to extract
234 * from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE
235 * the `<div data-impeccable-variants="ID">` outer wrapper so the picked
236 * element's JSX slot keeps a single child — a Fragment `<></>` would have
237 * solved the multi-sibling case but failed inside `asChild` / cloneElement
238 * parents with "Invalid prop supplied to React.Fragment".
239 *
240 * That means the marker block is enclosed by the wrapper `<div>` opener
241 * (with `data-impeccable-variants="ID"`) and its matching `</div>`. We
242 * walk back to the opener and forward to the closer so accept/discard
243 * remove the entire scaffold, not just the inner markers.
244 *
245 * Marker lines themselves stay where they were so extractOriginal /
246 * extractVariant / extractCss continue to walk the same range.
247 */
248function expandReplaceRange(block, lines, isJsx) {
249 if (!isJsx) return { start: block.start, end: block.end };
250
251 let { start, end } = block;
252
253 // Walk back for the wrapper `<div data-impeccable-variants="..."` opener.
254 // The attr may sit on a continuation line of a multi-line opening tag, so
255 // also walk to the line that actually contains `<div`.
256 for (let i = start - 1; i >= Math.max(0, start - 12); i--) {
257 if (/data-impeccable-variants=/.test(lines[i])) {
258 let opener = i;
259 while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;
260 start = opener;
261 break;
262 }
263 }
264
265 // Walk forward to the matching `</div>` by div-depth tracking from the
266 // wrapper opener. Operate on JOINED text instead of per-line: a
267 // multi-line self-closing JSX `<div\n className="spacer"\n/>` would
268 // fool per-line regex tracking (the `<div` line matches openRe but the
269 // `/>` line never matches selfCloseRe since it needs `<div` on the same
270 // line). That left depth permanently over-counted and the wrapper's
271 // outer `</div>` orphaned after accept/discard. Single regex with
272 // `[^>]*?` (which spans newlines in JS) handles either form correctly.
273 const joined = lines.slice(start).join('\n');
274 // Match either `<div … />` (self-close, group 1 is `/`), `<div … >`
275 // (open, group 1 is empty), or `</div>`.
276 const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
277 let depth = 0;
278 let m;
279 while ((m = tagRe.exec(joined)) !== null) {
280 const isClose = m[0].startsWith('</');
281 const isSelfClose = !isClose && m[1] === '/';
282 if (isClose) depth--;
283 else if (!isSelfClose) depth++;
284 if (depth <= 0) {
285 // m.index is offset within `joined`; convert back to a file line.
286 const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
287 const candidateEnd = start + linesBefore;
288 if (candidateEnd >= end) {
289 end = candidateEnd;
290 break;
291 }
292 }
293 }
294
295 return { start, end };
296}
297
298/**
299 * Join wrapper lines into a single string with `<style>` elements removed so
300 * marker matching and div-depth tracking aren't confused by:
301 * - CSS `@scope ([data-impeccable-variant="N"])` strings that look like the
302 * HTML marker we're searching for
303 * - JSX self-closing `<style ... />` (no separate `</style>` to close on)
304 * - Same-line `<style>…</style>` blocks
305 * - Multi-line `<style>\n…\n</style>` blocks
306 */
307function stripStyleAndJoin(lines, block) {
308 const out = [];
309 let inStyle = false;
310 for (let i = block.start; i <= block.end; i++) {
311 let line = lines[i];
312
313 if (!inStyle) {
314 // Strip any complete <style> elements on this line (self-closed or
315 // same-line-closed), including their body content.
316 line = line
317 .replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/g, '')
318 .replace(/<style\b[^>]*\/\s*>/g, '');
319
320 // If a <style> opener remains (multi-line body starts here), strip from
321 // the opener to end-of-line and flip into skip mode.
322 const openerIdx = line.search(/<style\b/);
323 if (openerIdx !== -1) {
324 line = line.slice(0, openerIdx);
325 inStyle = true;
326 }
327 out.push(line);
328 } else {
329 // In multi-line style body; drop everything until we see </style>.
330 const closeIdx = line.search(/<\/style\s*>/);
331 if (closeIdx !== -1) {
332 inStyle = false;
333 out.push(line.slice(closeIdx).replace(/<\/style\s*>/, ''));
334 }
335 // else: skip line entirely
336 }
337 }
338 return out.join('\n');
339}
340
341/**
342 * Find the inner content of `<TAG ...attrMatch...>…</TAG>` inside `text`,
343 * handling nested same-tag elements via depth counting. `attrMatch` is a
344 * regex source fragment that must appear inside the opener tag.
345 * Returns the inner string (may be empty), or null if not found.
346 */
347function extractInnerByAttr(text, attrMatch) {
348 const openerRe = new RegExp('<([A-Za-z][A-Za-z0-9]*)\\b[^>]*' + attrMatch + '[^>]*>');
349 const openMatch = text.match(openerRe);
350 if (!openMatch) return null;
351
352 const tagName = openMatch[1];
353 const innerStart = openMatch.index + openMatch[0].length;
354
355 // Match any opener or closer of this tag name after innerStart.
356 // (Does not match self-closing <TAG … />, which doesn't contribute to depth.)
357 const tagRe = new RegExp('<(?:/)?' + tagName + '\\b[^>]*>', 'g');
358 tagRe.lastIndex = innerStart;
359
360 let depth = 1;
361 let m;
362 while ((m = tagRe.exec(text))) {
363 const isClose = m[0].startsWith('</');
364 const isSelfClose = !isClose && /\/\s*>$/.test(m[0]);
365 if (isClose) {
366 depth--;
367 if (depth === 0) return text.slice(innerStart, m.index);
368 } else if (!isSelfClose) {
369 depth++;
370 }
371 }
372 return null;
373}
374
375/**
376 * Extract the original element content from within the variant wrapper.
377 * Returns an array of lines.
378 */
379function extractOriginal(lines, block) {
380 const text = stripStyleAndJoin(lines, block);
381 const inner = extractInnerByAttr(text, 'data-impeccable-variant="original"');
382 if (inner === null) return [];
383 return inner.split('\n');
384}
385
386/**
387 * Extract a specific variant's inner content (stripping the wrapper div).
388 * Returns an array of lines, or null if not found.
389 */
390function extractVariant(lines, block, variantNum) {
391 const text = stripStyleAndJoin(lines, block);
392 const inner = extractInnerByAttr(text, 'data-impeccable-variant="' + variantNum + '"');
393 if (inner === null) return null;
394 const result = inner.split('\n');
395 // Collapse a lone empty leading/trailing line (common after string splice).
396 while (result.length > 1 && result[0].trim() === '') result.shift();
397 while (result.length > 1 && result[result.length - 1].trim() === '') result.pop();
398 return result.length > 0 ? result : null;
399}
400
401/**
402 * Extract the colocated <style> block content (between the style tags).
403 * Returns an array of CSS lines, or null if no style block found.
404 *
405 * Handles three shapes of `<style data-impeccable-css="ID" ...>`:
406 * 1. Self-closing: `<style ... />` — no body; return null (nothing to carbonize).
407 * 2. Same-line open+close: `<style>...</style>` — return the inner content.
408 * 3. Multi-line: `<style>` on one line, `</style>` on a later line — return
409 * the lines between them.
410 */
411function extractCss(lines, block, id) {
412 const styleAttr = 'data-impeccable-css="' + id + '"';
413 let inStyle = false;
414 const content = [];
415
416 for (let i = block.start; i <= block.end; i++) {
417 const line = lines[i];
418
419 if (!inStyle && line.includes(styleAttr)) {
420 // Self-closing: nothing to carbonize.
421 if (/<style\b[^>]*\/\s*>/.test(line)) return null;
422 // Same-line open + close: extract inner text.
423 const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
424 if (sameLine) {
425 const inner = stripJsxTemplateWrap(sameLine[1]);
426 return inner.length > 0 ? inner.split('\n') : null;
427 }
428 inStyle = true;
429 continue; // skip the <style> opening tag
430 }
431
432 if (inStyle) {
433 // Detect </style> anywhere on the line — JSX template-literal closes
434 // (`}</style>`) put the close mid-line, and we don't want to absorb the
435 // template-literal punctuation as CSS content.
436 const closeIdx = line.indexOf('</style>');
437 if (closeIdx !== -1) break;
438 content.push(line);
439 }
440 }
441
442 if (content.length === 0) return null;
443 return stripJsxTemplateLines(content);
444}
445
446/**
447 * Strip a JSX template-literal wrap (`{` … `}`) from CSS extracted out of a
448 * `<style>` element in a JSX/TSX file. The agent may write the wrap with
449 * `{` and `}` directly attached to the `<style>` tags, on their own lines,
450 * or attached to the first/last CSS lines — all three are JSX-legal.
451 *
452 * Stripping is required because handleAccept re-wraps the CSS itself when
453 * carbonizing. Without this, two consecutive accepts (or a previously-
454 * accepted variants block being carbonized) would produce nested
455 * `{` `{` … `}` `}`, which oxc rejects with "Expected `}` but found `@`".
456 */
457function stripJsxTemplateLines(content) {
458 const out = content.slice();
459
460 // Drop any leading blank lines so we don't miss a `{` line buried below
461 // them; same for trailing.
462 while (out.length > 0 && out[0].trim() === '') out.shift();
463 while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
464 if (out.length === 0) return null;
465
466 // Leading `{`: own line, or attached to the first CSS line.
467 const firstTrim = out[0].trimStart();
468 if (firstTrim === '{`') {
469 out.shift();
470 } else if (firstTrim.startsWith('{`')) {
471 const idx = out[0].indexOf('{`');
472 out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
473 if (out[0].trim() === '') out.shift();
474 }
475 if (out.length === 0) return null;
476
477 // Trailing `` ` `` `}`: own line, or attached to the last CSS line.
478 const lastIdx = out.length - 1;
479 const lastTrim = out[lastIdx].trimEnd();
480 if (lastTrim === '`}') {
481 out.pop();
482 } else if (lastTrim.endsWith('`}')) {
483 const text = out[lastIdx];
484 const idx = text.lastIndexOf('`}');
485 out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
486 if (out[lastIdx].trim() === '') out.pop();
487 }
488
489 return out.length > 0 ? out : null;
490}
491
492function stripJsxTemplateWrap(text) {
493 const lines = text.split('\n');
494 const stripped = stripJsxTemplateLines(lines);
495 return stripped ? stripped.join('\n') : '';
496}
497
498/**
499 * De-indent content that was indented by live-wrap.mjs.
500 * The wrap script adds `indent + ' '` (4 extra spaces) to each line.
501 * We restore to just `indent` level.
502 */
503function deindentContent(contentLines, baseIndent) {
504 // Find the minimum indentation in the content to determine how much was added
505 let minIndent = Infinity;
506 for (const line of contentLines) {
507 if (line.trim() === '') continue;
508 const leadingSpaces = line.match(/^(\s*)/)[1].length;
509 minIndent = Math.min(minIndent, leadingSpaces);
510 }
511 if (minIndent === Infinity) minIndent = 0;
512
513 // Strip the extra indentation and re-add base indent
514 return contentLines.map(line => {
515 if (line.trim() === '') return '';
516 return baseIndent + line.slice(minIndent);
517 });
518}
519
520function detectCommentSyntax(filePath) {
521 const ext = path.extname(filePath).toLowerCase();
522 if (ext === '.jsx' || ext === '.tsx') {
523 return { open: '{/*', close: '*/}' };
524 }
525 return { open: '<!--', close: '-->' };
526}
527
528// ---------------------------------------------------------------------------
529// File search (find the file containing session markers)
530// ---------------------------------------------------------------------------
531
532function findSessionFile(id, cwd) {
533 const marker = 'impeccable-variants-start ' + id;
534 const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
535 const seen = new Set();
536
537 for (const dir of searchDirs) {
538 const absDir = path.join(cwd, dir);
539 if (!fs.existsSync(absDir)) continue;
540 const result = searchDir(absDir, marker, seen, 0);
541 if (result) {
542 const content = fs.readFileSync(result, 'utf-8');
543 return { file: result, content, lines: content.split('\n') };
544 }
545 }
546 return null;
547}
548
549function searchDir(dir, query, seen, depth) {
550 if (depth > 5) return null;
551 let realDir;
552 try { realDir = fs.realpathSync(dir); } catch { return null; }
553 if (seen.has(realDir)) return null;
554 seen.add(realDir);
555
556 let entries;
557 try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
558 catch { return null; }
559
560 for (const entry of entries) {
561 if (!entry.isFile()) continue;
562 if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
563 const filePath = path.join(dir, entry.name);
564 try {
565 const content = fs.readFileSync(filePath, 'utf-8');
566 if (content.includes(query)) return filePath;
567 } catch { /* skip */ }
568 }
569
570 for (const entry of entries) {
571 if (!entry.isDirectory()) continue;
572 if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
573 const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
574 if (result) return result;
575 }
576
577 return null;
578}
579
580// ---------------------------------------------------------------------------
581// Utilities
582// ---------------------------------------------------------------------------
583
584function argVal(args, flag) {
585 const idx = args.indexOf(flag);
586 return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
587}
588
589// Auto-execute when run directly
590const _running = process.argv[1];
591if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
592 acceptCli();
593}
594
595export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax };