live-accept.mjs

  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 };