返回 AiToEarn
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
16 import fs from 'node:fs';
17 import path from 'node:path';
18 import { isGeneratedFile } from './is-generated.mjs';
19 import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
20 import {
21 applyDeferredSvelteComponentAccepts,
22 findSvelteComponentManifest,
23 inlineSvelteComponentAccept,
24 removeSvelteComponentSession,
25 } from './live-svelte-component.mjs';
26
27 const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
28
29 // ---------------------------------------------------------------------------
30 // CLI
31 // ---------------------------------------------------------------------------
32
33 export async function acceptCli() {
34 const args = process.argv.slice(2);
35
36 if (args.includes('--help') || args.includes('-h')) {
37 console.log(`Usage: node live-accept.mjs [options]
38
39 Deterministic accept/discard for live variant sessions.
40
41 Modes:
42 --discard Remove variants, restore original
43 --variant N Accept variant N, discard the rest
44
45 Required:
46 --id SESSION_ID Session ID of the variant wrapper
47
48 Options:
49 --page-url URL Current browser page URL; scopes staged copy-edit cleanup
50 --defer-source-write
51 Deprecated compatibility flag. Svelte component accepts
52 now write the real source immediately.
53
54 Output (JSON):
55 { handled, file, carbonize }`);
56 process.exit(0);
57 }
58
59 const id = argVal(args, '--id');
60 const variantNum = argVal(args, '--variant');
61 const paramValuesRaw = argVal(args, '--param-values');
62 const pageUrl = argVal(args, '--page-url');
63 const isDiscard = args.includes('--discard');
64
65 if (!id) { console.error('Missing --id'); process.exit(1); }
66 if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
67
68 let paramValues = null;
69 if (paramValuesRaw) {
70 try { paramValues = JSON.parse(paramValuesRaw); }
71 catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept
72 }
73
74 // Find the file containing this session's markers
75 const found = findSessionFile(id, process.cwd());
76 const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());
77
78 if (!found && !svelteComponentManifest) {
79 console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
80 process.exit(0);
81 }
82
83 if (svelteComponentManifest) {
84 if (isDiscard) {
85 removeSvelteComponentSession(id, process.cwd());
86 console.log(JSON.stringify({
87 handled: true,
88 file: svelteComponentManifest.sourceFile,
89 carbonize: false,
90 previewMode: 'svelte-component',
91 componentDir: svelteComponentManifest.componentDir,
92 }));
93 return;
94 }
95
96 let result;
97 try {
98 result = inlineSvelteComponentAccept(
99 svelteComponentManifest,
100 variantNum,
101 paramValues,
102 process.cwd(),
103 );
104 } catch (err) {
105 result = {
106 handled: false,
107 error: err.message,
108 file: svelteComponentManifest.sourceFile,
109 sourceFile: svelteComponentManifest.sourceFile,
110 previewMode: 'svelte-component',
111 componentDir: svelteComponentManifest.componentDir,
112 };
113 }
114 if (result.carbonize) {
115 result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
116 }
117 console.log(JSON.stringify({ handled: result.handled !== false, ...result }));
118 return;
119 }
120
121 const { file: targetFile, content, lines } = found;
122 const relFile = path.relative(process.cwd(), targetFile);
123 const previewBlock = findMarkerBlock(id, lines);
124 const sourceShadowPreview = previewBlock
125 ? readSourceShadowPreviewMeta(content, id)
126 : null;
127
128 if (sourceShadowPreview) {
129 console.log(JSON.stringify({
130 handled: false,
131 error: 'source_shadow_preview_deprecated',
132 hint: 'Svelte live mode now uses svelte-component injection. Re-wrap the element and regenerate variants.',
133 }));
134 process.exit(0);
135 }
136
137 if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
138 console.log(JSON.stringify({
139 handled: false,
140 mode: 'fallback',
141 file: relFile,
142 hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',
143 }));
144 process.exit(0);
145 }
146
147 if (isDiscard) {
148 const result = handleDiscard(id, lines, targetFile);
149 console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
150 } else {
151 const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
152 const acceptedOriginalText = result.acceptedOriginalText || '';
153 delete result.acceptedOriginalText;
154 // Single-line attention-grabber when cleanup is required. The full
155 // five-step checklist lives in reference/live.md (loaded once per
156 // session); repeating it per-event would waste tokens.
157 if (result.carbonize) {
158 result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".';
159 }
160 // Scrub stash entries whose text appeared inside the just-replaced
161 // original wrap block. The accept embodies those manual edits (wrap was
162 // buffer-aware), so only those scoped ops are redundant.
163 if (result.handled !== false) {
164 try {
165 scrubManualEditsAgainstOriginalBlock(acceptedOriginalText, process.cwd(), pageUrl);
166 } catch {
167 // Non-fatal; the buffer stays as-is and the user can discard later.
168 }
169 }
170 console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
171 }
172 }
173
174 /**
175 * After a variant accept rewrites one wrapper, drop only buffer ops whose
176 * text appeared inside that wrapper's original block. The previous file-wide
177 * scrub dropped unrelated staged edits from other components/files whenever
178 * their originalText wasn't present in the just-accepted file.
179 *
180 * Match both originalText and newText because live-wrap rewrites the original
181 * preview block to reflect pending manual edits before variants are generated.
182 */
183 function scrubManualEditsAgainstOriginalBlock(originalBlockText, cwd = process.cwd(), pageUrl = null) {
184 const originalBlock = String(originalBlockText || '');
185 if (!originalBlock) return;
186 if (!pageUrl) return;
187 const buffer = readManualEditsBuffer(cwd);
188 if (buffer.entries.length === 0) return;
189 let mutated = false;
190 for (const entry of buffer.entries) {
191 if (entry.pageUrl !== pageUrl) continue;
192 const before = entry.ops.length;
193 entry.ops = entry.ops.filter((op) => {
194 return !manualEditOpAppearsInBlock(op, originalBlock);
195 });
196 if (entry.ops.length !== before) mutated = true;
197 }
198 buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0);
199 if (mutated) writeManualEditsBuffer(cwd, buffer);
200 }
201
202 function manualEditOpAppearsInBlock(op, originalBlock) {
203 const candidates = [op?.newText, op?.originalText]
204 .filter((text) => typeof text === 'string' && text.length > 0);
205 return candidates.some((text) => originalBlockHasExactManualText(originalBlock, text));
206 }
207
208 function originalBlockHasExactManualText(originalBlock, text) {
209 const needle = normalizeManualEditText(text);
210 if (!needle) return false;
211 return manualEditTextSegments(originalBlock).some((segment) => segment === needle);
212 }
213
214 function manualEditTextSegments(source) {
215 return String(source || '')
216 .replace(/<[^>]*>/g, '\n')
217 .replace(/\{\/\*[\s\S]*?\*\/\}/g, '\n')
218 .replace(/<!--[\s\S]*?-->/g, '\n')
219 .split(/\n+/)
220 .map(normalizeManualEditText)
221 .filter(Boolean);
222 }
223
224 function normalizeManualEditText(text) {
225 return String(text || '').replace(/\s+/g, ' ').trim();
226 }
227
228 // Compatibility export for older tests/callers. The unsafe file-wide scrub was
229 // removed; callers must pass accepted original-block text for scoped cleanup.
230 function scrubManualEditsAgainstFile(_targetFile, cwd = process.cwd(), originalBlockText = '', pageUrl = null) {
231 return scrubManualEditsAgainstOriginalBlock(originalBlockText, cwd, pageUrl);
232 }
233
234 // ---------------------------------------------------------------------------
235 // Discard
236 // ---------------------------------------------------------------------------
237
238 function handleDiscard(id, lines, targetFile) {
239 const block = findMarkerBlock(id, lines);
240 if (!block) return { handled: false, error: 'Markers not found' };
241
242 const original = extractOriginal(lines, block);
243 const isJsx = detectCommentSyntax(targetFile).open === '{/*';
244 const replaceRange = expandReplaceRange(block, lines, isJsx);
245
246 // Restore at the line we're actually replacing FROM, not the marker line.
247 // For JSX wrappers the marker comments live INSIDE the outer `<div>`, so
248 // `block.start` sits 2 spaces deeper than the original element. Using that
249 // as the deindent base would push the restored content 2 spaces too far
250 // right on every JSX/TSX session. `replaceRange.start` is the outer wrapper
251 // line, which is at the original element's indent for both HTML and JSX.
252 const indent = lines[replaceRange.start].match(/^(\s*)/)[1];
253 const restored = deindentContent(original, indent);
254
255 const newLines = [
256 ...lines.slice(0, replaceRange.start),
257 ...restored,
258 ...lines.slice(replaceRange.end + 1),
259 ];
260 fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
261 return {};
262 }
263
264 // ---------------------------------------------------------------------------
265 // Accept
266 // ---------------------------------------------------------------------------
267
268 /**
269 * Build carbonize stitch-in lines. JSX targets occupy a single child slot
270 * (ternary branch, return value, etc.) — the same constraint as live-wrap.
271 * When isJsx, tuck markers + <style> + variant wrapper inside one outer
272 * <div data-impeccable-carbonize> so the slot keeps a single root node.
273 */
274 function buildCarbonizeReplacement({
275 indent,
276 commentSyntax,
277 isJsx,
278 id,
279 variantNum,
280 cssContent,
281 paramValues,
282 restored,
283 }) {
284 const lines = [];
285 if (!cssContent) {
286 lines.push(...restored);
287 return lines;
288 }
289
290 const variantStyleAttr = isJsx
291 ? "style={{ display: 'contents' }}"
292 : 'style="display: contents"';
293
294 const pushCarbonizeBody = (bodyIndent) => {
295 const bodyRestored = reindentContent(restored, indent, bodyIndent + ' ');
296 lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
297 lines.push(bodyIndent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
298 for (const cssLine of cssContent) {
299 lines.push(bodyIndent + cssLine.trimStart());
300 }
301 lines.push(bodyIndent + (isJsx ? '`}</style>' : '</style>'));
302 if (paramValues && Object.keys(paramValues).length > 0) {
303 lines.push(
304 bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close,
305 );
306 }
307 lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
308 lines.push(bodyIndent + '<div data-impeccable-variant="' + variantNum + '" ' + variantStyleAttr + '>');
309 lines.push(...bodyRestored);
310 lines.push(bodyIndent + '</div>');
311 };
312
313 if (isJsx) {
314 const wrapperStyle = 'style={{ display: "contents" }}';
315 lines.push(indent + '<div data-impeccable-carbonize="' + id + '" ' + wrapperStyle + '>');
316 pushCarbonizeBody(indent + ' ');
317 lines.push(indent + '</div>');
318 } else {
319 pushCarbonizeBody(indent);
320 }
321
322 return lines;
323 }
324
325 function reindentContent(contentLines, fromIndent, toIndent) {
326 return contentLines.map((line) => {
327 if (line.trim() === '') return '';
328 if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length);
329 return toIndent + line.trimStart();
330 });
331 }
332
333 function handleAccept(id, variantNum, lines, targetFile, paramValues) {
334 const block = findMarkerBlock(id, lines);
335 if (!block) return { handled: false, error: 'Markers not found' };
336
337 const commentSyntax = detectCommentSyntax(targetFile);
338 const isJsx = commentSyntax.open === '{/*';
339 // Anchor indent on the line we're replacing FROM (the outer wrapper),
340 // not on `block.start` — for JSX that's the marker comment 2 spaces
341 // deeper than the original element. See handleDiscard for the full
342 // rationale.
343 const replaceRange = expandReplaceRange(block, lines, isJsx);
344 const indent = lines[replaceRange.start].match(/^(\s*)/)[1];
345
346 // Extract the chosen variant's inner content
347 const variantContent = extractVariant(lines, block, variantNum);
348 if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
349 const originalContent = extractOriginal(lines, block);
350
351 // Extract CSS block if present
352 const cssContent = extractCss(lines, block, id);
353
354 // Check if carbonizing is needed:
355 // - CSS block exists, OR
356 // - variant HTML contains helper classes/attributes that need cleanup
357 const variantText = variantContent.join('\n');
358 const hasHelperAttrs = variantText.includes('data-impeccable-variant');
359 const needsCarbonize = !!(cssContent || hasHelperAttrs);
360
361 const restored = deindentContent(variantContent, indent);
362 const replacement = buildCarbonizeReplacement({
363 indent,
364 commentSyntax,
365 isJsx,
366 id,
367 variantNum,
368 cssContent,
369 paramValues,
370 restored,
371 });
372
373 const newLines = [
374 ...lines.slice(0, replaceRange.start),
375 ...replacement,
376 ...lines.slice(replaceRange.end + 1),
377 ];
378 fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
379
380 return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };
381 }
382
383 function readSourceShadowPreviewMeta(content, id) {
384 const escaped = escapeRegExp(id);
385 const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');
386 const match = String(content || '').match(wrapperRe);
387 if (!match) return null;
388 const tag = match[0];
389 if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null;
390 const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file');
391 const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start'));
392 const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end'));
393 if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null;
394 return { sourceFile, sourceStartLine, sourceEndLine };
395 }
396
397 function readHtmlAttr(tag, name) {
398 const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1'));
399 if (!match) return null;
400 return decodeHtmlAttr(match[2]);
401 }
402
403 function decodeHtmlAttr(value) {
404 return String(value || '')
405 .replace(/&quot;/g, '"')
406 .replace(/&lt;/g, '<')
407 .replace(/&gt;/g, '>')
408 .replace(/&amp;/g, '&');
409 }
410
411 // ---------------------------------------------------------------------------
412 // Parsing helpers
413 // ---------------------------------------------------------------------------
414
415 /**
416 * Find the start/end marker lines for a session.
417 * Returns { start, end } (0-indexed line numbers) or null.
418 */
419 function findMarkerBlock(id, lines) {
420 let start = -1;
421 let end = -1;
422 const startPattern = 'impeccable-variants-start ' + id;
423 const endPattern = 'impeccable-variants-end ' + id;
424
425 for (let i = 0; i < lines.length; i++) {
426 if (start === -1 && lines[i].includes(startPattern)) start = i;
427 if (lines[i].includes(endPattern)) { end = i; break; }
428 }
429
430 return (start !== -1 && end !== -1) ? { start, end, id } : null;
431 }
432
433 /**
434 * Compute the line range to REPLACE (vs. just the marker range to extract
435 * from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE
436 * the `<div data-impeccable-variants="ID">` outer wrapper so the picked
437 * element's JSX slot keeps a single child — a Fragment `<></>` would have
438 * solved the multi-sibling case but failed inside `asChild` / cloneElement
439 * parents with "Invalid prop supplied to React.Fragment".
440 *
441 * That means the marker block is enclosed by the wrapper `<div>` opener
442 * (with `data-impeccable-variants="ID"`) and its matching `</div>`. We
443 * walk back to the opener and forward to the closer so accept/discard
444 * remove the entire scaffold, not just the inner markers.
445 *
446 * Marker lines themselves stay where they were so extractOriginal /
447 * extractVariant / extractCss continue to walk the same range.
448 */
449 function expandReplaceRange(block, lines, isJsx) {
450 if (!isJsx) return { start: block.start, end: block.end };
451
452 let { start, end } = block;
453
454 // Walk back for the wrapper `<div data-impeccable-variants="..."` opener.
455 // The attr may sit on a continuation line of a multi-line opening tag, so
456 // also walk to the line that actually contains `<div`.
457 for (let i = start - 1; i >= 0; i--) {
458 if (isVariantEndMarkerLine(lines[i], block.id)) break;
459 if (hasVariantWrapperAttr(lines[i], block.id)) {
460 let opener = i;
461 while (opener > 0 && !/<div\b/.test(lines[opener]) && !isVariantEndMarkerLine(lines[opener], block.id)) {
462 opener--;
463 }
464 if (/<div\b/.test(lines[opener])) start = opener;
465 break;
466 }
467 }
468
469 // Walk forward to the matching `</div>` by div-depth tracking from the
470 // wrapper opener. Operate on JOINED text instead of per-line: a
471 // multi-line self-closing JSX `<div\n className="spacer"\n/>` would
472 // fool per-line regex tracking (the `<div` line matches openRe but the
473 // `/>` line never matches selfCloseRe since it needs `<div` on the same
474 // line). That left depth permanently over-counted and the wrapper's
475 // outer `</div>` orphaned after accept/discard. Single regex with
476 // `[^>]*?` (which spans newlines in JS) handles either form correctly.
477 const joined = lines.slice(start).join('\n');
478 // Match either `<div … />` (self-close, group 1 is `/`), `<div … >`
479 // (open, group 1 is empty), or `</div>`.
480 const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
481 let depth = 0;
482 let m;
483 while ((m = tagRe.exec(joined)) !== null) {
484 const isClose = m[0].startsWith('</');
485 const isSelfClose = !isClose && m[1] === '/';
486 if (isClose) depth--;
487 else if (!isSelfClose) depth++;
488 if (depth <= 0) {
489 // m.index is offset within `joined`; convert back to a file line.
490 const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
491 const candidateEnd = start + linesBefore;
492 if (candidateEnd >= end) {
493 end = candidateEnd;
494 break;
495 }
496 }
497 }
498
499 return { start, end };
500 }
501
502 function escapeRegExp(value) {
503 return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
504 }
505
506 function isVariantEndMarkerLine(line, id) {
507 return new RegExp('impeccable-variants-end\\s+' + escapeRegExp(id) + '(?:\\s|--|\\*/|$)').test(line);
508 }
509
510 function hasVariantWrapperAttr(line, id) {
511 const escaped = escapeRegExp(id);
512 return new RegExp(`data-impeccable-variants\\s*=\\s*(?:"${escaped}"|'${escaped}'|\\{["']${escaped}["']\\})`).test(line);
513 }
514
515 /**
516 * Join wrapper lines into a single string with `<style>` elements removed so
517 * marker matching and div-depth tracking aren't confused by:
518 * - CSS `@scope ([data-impeccable-variant="N"])` strings that look like the
519 * HTML marker we're searching for
520 * - JSX self-closing `<style ... />` (no separate `</style>` to close on)
521 * - Same-line `<style>…</style>` blocks
522 * - Multi-line `<style>\n…\n</style>` blocks
523 */
524 function stripStyleAndJoin(lines, block) {
525 const out = [];
526 let inStyle = false;
527 for (let i = block.start; i <= block.end; i++) {
528 let line = lines[i];
529
530 if (!inStyle) {
531 // Strip any complete <style> elements on this line (self-closed or
532 // same-line-closed), including their body content.
533 line = line
534 .replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/g, '')
535 .replace(/<style\b[^>]*\/\s*>/g, '');
536
537 // If a <style> opener remains (multi-line body starts here), strip from
538 // the opener to end-of-line and flip into skip mode.
539 const openerIdx = line.search(/<style\b/);
540 if (openerIdx !== -1) {
541 line = line.slice(0, openerIdx);
542 inStyle = true;
543 }
544 out.push(line);
545 } else {
546 // In multi-line style body; drop everything until we see </style>.
547 const closeIdx = line.search(/<\/style\s*>/);
548 if (closeIdx !== -1) {
549 inStyle = false;
550 out.push(line.slice(closeIdx).replace(/<\/style\s*>/, ''));
551 }
552 // else: skip line entirely
553 }
554 }
555 return out.join('\n');
556 }
557
558 /**
559 * Find the inner content of `<TAG ...attrMatch...>…</TAG>` inside `text`,
560 * handling nested same-tag elements via depth counting. `attrMatch` is a
561 * regex source fragment that must appear inside the opener tag.
562 * Returns the inner string (may be empty), or null if not found.
563 */
564 function extractInnerByAttr(text, attrMatch) {
565 const openerRe = new RegExp('<([A-Za-z][A-Za-z0-9]*)\\b[^>]*' + attrMatch + '[^>]*>');
566 const openMatch = text.match(openerRe);
567 if (!openMatch) return null;
568
569 const tagName = openMatch[1];
570 const innerStart = openMatch.index + openMatch[0].length;
571
572 // Match any opener or closer of this tag name after innerStart.
573 // (Does not match self-closing <TAG … />, which doesn't contribute to depth.)
574 const tagRe = new RegExp('<(?:/)?' + tagName + '\\b[^>]*>', 'g');
575 tagRe.lastIndex = innerStart;
576
577 let depth = 1;
578 let m;
579 while ((m = tagRe.exec(text))) {
580 const isClose = m[0].startsWith('</');
581 const isSelfClose = !isClose && /\/\s*>$/.test(m[0]);
582 if (isClose) {
583 depth--;
584 if (depth === 0) return text.slice(innerStart, m.index);
585 } else if (!isSelfClose) {
586 depth++;
587 }
588 }
589 return null;
590 }
591
592 /**
593 * Extract the original element content from within the variant wrapper.
594 * Returns an array of lines.
595 */
596 function extractOriginal(lines, block) {
597 const text = stripStyleAndJoin(lines, block);
598 const inner = extractInnerByAttr(text, 'data-impeccable-variant="original"');
599 if (inner === null) return [];
600 return inner.split('\n');
601 }
602
603 /**
604 * Extract a specific variant's inner content (stripping the wrapper div).
605 * Returns an array of lines, or null if not found.
606 */
607 function extractVariant(lines, block, variantNum) {
608 const text = stripStyleAndJoin(lines, block);
609 const inner = extractInnerByAttr(text, 'data-impeccable-variant="' + variantNum + '"');
610 if (inner === null) return null;
611 const result = inner.split('\n');
612 // Collapse a lone empty leading/trailing line (common after string splice).
613 while (result.length > 1 && result[0].trim() === '') result.shift();
614 while (result.length > 1 && result[result.length - 1].trim() === '') result.pop();
615 return result.length > 0 ? result : null;
616 }
617
618 /**
619 * Extract the colocated <style> block content (between the style tags).
620 * Returns an array of CSS lines, or null if no style block found.
621 *
622 * Handles three shapes of `<style data-impeccable-css="ID" ...>`:
623 * 1. Self-closing: `<style ... />` — no body; return null (nothing to carbonize).
624 * 2. Same-line open+close: `<style>...</style>` — return the inner content.
625 * 3. Multi-line: `<style>` on one line, `</style>` on a later line — return
626 * the lines between them.
627 */
628 function extractCss(lines, block, id) {
629 const styleAttr = 'data-impeccable-css="' + id + '"';
630 let inStyle = false;
631 const content = [];
632
633 for (let i = block.start; i <= block.end; i++) {
634 const line = lines[i];
635
636 if (!inStyle && line.includes(styleAttr)) {
637 // Self-closing: nothing to carbonize.
638 if (/<style\b[^>]*\/\s*>/.test(line)) return null;
639 // Same-line open + close: extract inner text.
640 const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
641 if (sameLine) {
642 const inner = stripJsxTemplateWrap(sameLine[1]);
643 return inner.length > 0 ? inner.split('\n') : null;
644 }
645 inStyle = true;
646 continue; // skip the <style> opening tag
647 }
648
649 if (inStyle) {
650 // Detect </style> anywhere on the line — JSX template-literal closes
651 // (`}</style>`) put the close mid-line, and we don't want to absorb the
652 // template-literal punctuation as CSS content.
653 const closeIdx = line.indexOf('</style>');
654 if (closeIdx !== -1) break;
655 content.push(line);
656 }
657 }
658
659 if (content.length === 0) return null;
660 return stripJsxTemplateLines(content);
661 }
662
663 /**
664 * Strip a JSX template-literal wrap (`{` … `}`) from CSS extracted out of a
665 * `<style>` element in a JSX/TSX file. The agent may write the wrap with
666 * `{` and `}` directly attached to the `<style>` tags, on their own lines,
667 * or attached to the first/last CSS lines — all three are JSX-legal.
668 *
669 * Stripping is required because handleAccept re-wraps the CSS itself when
670 * carbonizing. Without this, two consecutive accepts (or a previously-
671 * accepted variants block being carbonized) would produce nested
672 * `{` `{` … `}` `}`, which oxc rejects with "Expected `}` but found `@`".
673 */
674 function stripJsxTemplateLines(content) {
675 const out = content.slice();
676
677 // Drop any leading blank lines so we don't miss a `{` line buried below
678 // them; same for trailing.
679 while (out.length > 0 && out[0].trim() === '') out.shift();
680 while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
681 if (out.length === 0) return null;
682
683 // Leading `{`: own line, or attached to the first CSS line.
684 const firstTrim = out[0].trimStart();
685 if (firstTrim === '{`') {
686 out.shift();
687 } else if (firstTrim.startsWith('{`')) {
688 const idx = out[0].indexOf('{`');
689 out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
690 if (out[0].trim() === '') out.shift();
691 }
692 if (out.length === 0) return null;
693
694 // Trailing `` ` `` `}`: own line, or attached to the last CSS line.
695 const lastIdx = out.length - 1;
696 const lastTrim = out[lastIdx].trimEnd();
697 if (lastTrim === '`}') {
698 out.pop();
699 } else if (lastTrim.endsWith('`}')) {
700 const text = out[lastIdx];
701 const idx = text.lastIndexOf('`}');
702 out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
703 if (out[lastIdx].trim() === '') out.pop();
704 }
705
706 return out.length > 0 ? out : null;
707 }
708
709 function stripJsxTemplateWrap(text) {
710 const lines = text.split('\n');
711 const stripped = stripJsxTemplateLines(lines);
712 return stripped ? stripped.join('\n') : '';
713 }
714
715 /**
716 * De-indent content that was indented by live-wrap.mjs.
717 * The wrap script adds `indent + ' '` (4 extra spaces) to each line.
718 * We restore to just `indent` level.
719 */
720 function deindentContent(contentLines, baseIndent) {
721 // Find the minimum indentation in the content to determine how much was added
722 let minIndent = Infinity;
723 for (const line of contentLines) {
724 if (line.trim() === '') continue;
725 const leadingSpaces = line.match(/^(\s*)/)[1].length;
726 minIndent = Math.min(minIndent, leadingSpaces);
727 }
728 if (minIndent === Infinity) minIndent = 0;
729
730 // Strip the extra indentation and re-add base indent
731 return contentLines.map(line => {
732 if (line.trim() === '') return '';
733 return baseIndent + line.slice(minIndent);
734 });
735 }
736
737 function detectCommentSyntax(filePath) {
738 const ext = path.extname(filePath).toLowerCase();
739 if (ext === '.jsx' || ext === '.tsx') {
740 return { open: '{/*', close: '*/}' };
741 }
742 return { open: '<!--', close: '-->' };
743 }
744
745 // ---------------------------------------------------------------------------
746 // File search (find the file containing session markers)
747 // ---------------------------------------------------------------------------
748
749 function findSessionFile(id, cwd) {
750 const marker = 'impeccable-variants-start ' + id;
751 const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
752 const seen = new Set();
753
754 for (const dir of searchDirs) {
755 const absDir = path.join(cwd, dir);
756 if (!fs.existsSync(absDir)) continue;
757 const result = searchDir(absDir, marker, seen, 0);
758 if (result) {
759 const content = fs.readFileSync(result, 'utf-8');
760 return { file: result, content, lines: content.split('\n') };
761 }
762 }
763 return null;
764 }
765
766 function searchDir(dir, query, seen, depth) {
767 if (depth > 5) return null;
768 let realDir;
769 try { realDir = fs.realpathSync(dir); } catch { return null; }
770 if (seen.has(realDir)) return null;
771 seen.add(realDir);
772
773 let entries;
774 try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
775 catch { return null; }
776
777 for (const entry of entries) {
778 if (!entry.isFile()) continue;
779 if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
780 const filePath = path.join(dir, entry.name);
781 try {
782 const content = fs.readFileSync(filePath, 'utf-8');
783 if (content.includes(query)) return filePath;
784 } catch { /* skip */ }
785 }
786
787 for (const entry of entries) {
788 if (!entry.isDirectory()) continue;
789 if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
790 const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
791 if (result) return result;
792 }
793
794 return null;
795 }
796
797 // ---------------------------------------------------------------------------
798 // Utilities
799 // ---------------------------------------------------------------------------
800
801 function argVal(args, flag) {
802 const idx = args.indexOf(flag);
803 return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
804 }
805
806 // Auto-execute when run directly
807 const _running = process.argv[1];
808 if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
809 acceptCli();
810 }
811
812 export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts };
813
813 lines Plain Text