返回 AiToEarn
live-wrap.mjs
1 /**
2 * CLI helper: find an element in source and wrap it in a variant container.
3 *
4 * Usage:
5 * npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
6 *
7 * Searches project files for the element matching the query (class name, ID, or
8 * text snippet), wraps it with the variant scaffolding, and prints the file path
9 * + line range where the agent should insert variant HTML.
10 *
11 * This replaces 3-4 agent tool calls (grep + read + edit) with a single CLI call.
12 */
13
14 import fs from 'node:fs';
15 import path from 'node:path';
16 import { isGeneratedFile } from './is-generated.mjs';
17 import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
18 import {
19 buildSvelteComponentCssAuthoring,
20 scaffoldSvelteComponentSession,
21 shouldUseSvelteComponentInjection,
22 } from './live-svelte-component.mjs';
23
24 const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
25
26 export async function wrapCli() {
27 const args = process.argv.slice(2);
28
29 if (args.includes('--help') || args.includes('-h')) {
30 console.log(`Usage: impeccable wrap [options]
31
32 Find an element in source and wrap it in a variant container.
33
34 Required:
35 --id ID Session ID for the variant wrapper
36 --count N Number of expected variants (1-8)
37
38 Element identification (at least one required):
39 --element-id ID HTML id attribute of the element
40 --classes A,B,C Comma- or space-separated CSS class names
41 --tag TAG Tag name (div, section, etc.)
42 --query TEXT Fallback: raw text to search for
43
44 Optional:
45 --file PATH Source file to search in (skips auto-detection)
46 --text TEXT Picked element's textContent. Used to disambiguate when
47 classes/tag match multiple sibling elements (e.g. a list
48 of <Card>s with the same className). Pass the first ~80
49 chars of event.element.textContent.
50 --page-url URL Current page URL. Required when pending manual edits may
51 affect the picked source block. Pending edits are filtered
52 to this page so an edit on /a doesn't bleed into /b.
53 --help Show this help message
54
55 Output (JSON):
56 { file, startLine, endLine, insertLine, commentSyntax }
57
58 The agent should insert variant HTML at insertLine.`);
59 process.exit(0);
60 }
61
62 const id = argVal(args, '--id');
63 const count = parseInt(argVal(args, '--count') || '3');
64 const elementId = argVal(args, '--element-id');
65 const classes = argVal(args, '--classes');
66 const tag = argVal(args, '--tag');
67 const query = argVal(args, '--query');
68 const filePath = argVal(args, '--file');
69 const text = argVal(args, '--text');
70 const pageUrl = argVal(args, '--page-url');
71
72 if (!id) { console.error('Missing --id'); process.exit(1); }
73 if (!elementId && !classes && !query) {
74 console.error('Need at least one of: --element-id, --classes, --query');
75 process.exit(1);
76 }
77
78 // Build search queries in priority order (most specific first)
79 const queries = buildSearchQueries(elementId, classes, tag, query);
80
81 const genOpts = { cwd: process.cwd() };
82
83 // Find the source file. Generated files are excluded from auto-search so we
84 // don't silently write variants into a file the next build will wipe.
85 let targetFile = filePath;
86 let matchedQuery = null;
87 if (!targetFile) {
88 for (const q of queries) {
89 targetFile = findFileWithQuery(q, process.cwd(), genOpts);
90 if (targetFile) { matchedQuery = q; break; }
91 }
92 if (!targetFile) {
93 // Nothing in source. Did the element show up in a generated file? That
94 // tells the agent "fall back to the agent-driven flow" vs "element just
95 // doesn't exist in this project."
96 let generatedHit = null;
97 for (const q of queries) {
98 generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
99 if (generatedHit) break;
100 }
101 if (generatedHit) {
102 console.error(JSON.stringify({
103 error: 'element_not_in_source',
104 fallback: 'agent-driven',
105 generatedMatch: path.relative(process.cwd(), generatedHit),
106 hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',
107 }));
108 } else {
109 console.error(JSON.stringify({
110 error: 'element_not_found',
111 fallback: 'agent-driven',
112 hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',
113 }));
114 }
115 process.exit(1);
116 }
117 } else {
118 if (isGeneratedFile(targetFile, genOpts)) {
119 console.error(JSON.stringify({
120 error: 'file_is_generated',
121 fallback: 'agent-driven',
122 file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
123 hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',
124 }));
125 process.exit(1);
126 }
127 matchedQuery = queries[0];
128 }
129
130 const content = fs.readFileSync(targetFile, 'utf-8');
131 const lines = content.split('\n');
132
133 // Find the element, trying each query in priority order. When `--text` is
134 // supplied, collect every candidate the queries surface and disambiguate
135 // by the picked element's textContent. Without `--text`, fall back to the
136 // legacy first-match behavior so unmodified callers keep working.
137 let match = null;
138 if (text) {
139 const candidates = [];
140 for (const q of queries) {
141 const all = findAllElements(lines, q, tag);
142 for (const c of all) {
143 if (!candidates.some((x) => x.startLine === c.startLine)) {
144 candidates.push(c);
145 }
146 }
147 // Once a more-specific query (ID, full className combo) yielded a unique
148 // result, stop — falling through to the loose tag+single-class query
149 // would readmit the siblings we just disambiguated past.
150 if (candidates.length === 1) break;
151 }
152 if (candidates.length === 0) {
153 console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
154 process.exit(1);
155 }
156 if (candidates.length === 1) {
157 match = candidates[0];
158 } else {
159 const filtered = filterByText(candidates, lines, text);
160 if (filtered.length === 1) {
161 match = filtered[0];
162 } else if (filtered.length === 0) {
163 // Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
164 // browser-side textContent doesn't appear literally in source. Fall
165 // back to first-match rather than refusing — this is the same
166 // behavior unmodified callers see, just preserved.
167 match = candidates[0];
168 } else {
169 // Multiple candidates ALSO match the text. Truly ambiguous — refuse
170 // rather than pick wrong, and hand the agent the candidate locations
171 // so it can disambiguate by reading the file.
172 console.error(JSON.stringify({
173 error: 'element_ambiguous',
174 fallback: 'agent-driven',
175 file: path.relative(process.cwd(), targetFile),
176 candidates: filtered.map((c) => ({
177 startLine: c.startLine + 1,
178 endLine: c.endLine + 1,
179 })),
180 hint: 'Multiple source elements match both classes/tag and textContent. Pass --element-id, a more specific --text, or write the wrapper manually. See "Handle fallback" in live.md.',
181 }));
182 process.exit(1);
183 }
184 }
185 } else {
186 for (const q of queries) {
187 match = findElement(lines, q, tag);
188 if (match) break;
189 }
190 if (!match) {
191 console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
192 process.exit(1);
193 }
194 }
195
196 const { startLine, endLine } = match;
197 const commentSyntax = detectCommentSyntax(targetFile);
198 const styleMode = detectStyleMode(targetFile);
199 const isJsx = commentSyntax.open === '{/*';
200 const indent = lines[startLine].match(/^(\s*)/)[1];
201
202 // Extract the original element. Reindent under the wrapper while preserving
203 // the relative depth between lines — `l.trimStart()` would strip ALL leading
204 // whitespace and collapse e.g. `<aside>`/` <h1>`/`</aside>` (6/8/6 spaces)
205 // to a single uniform indent, so on accept/discard the round-trip restores
206 // the inner element at its parent's depth instead of nested inside it.
207 // Strip only the COMMON minimum leading whitespace across the picked lines;
208 // `deindentContent` on the accept side already mirrors this convention.
209 let originalLines = lines.slice(startLine, endLine + 1);
210
211 // Buffer-aware "original" content: if the user has pending manual edits for
212 // this page whose originalText appears in the picked source range, apply
213 // them so the wrap block's "original" variant reflects what the user was
214 // looking at (their edited DOM), not the raw source. Source itself stays
215 // untouched here — only the wrap block's embedded "original" copy is
216 // adjusted. The pending edits remain in the buffer until committed.
217 //
218 // Apply buffered edits only when the browser provided the current page URL.
219 // Without it, fail if pending edits plausibly touch this exact source range;
220 // otherwise skip buffer awareness so unrelated staged edits on another page
221 // do not block normal wrap work.
222 let pendingBuffer = { entries: [] };
223 try { pendingBuffer = readManualEditsBuffer(process.cwd()); } catch {}
224 const pendingEntriesForTarget = pageUrl
225 ? []
226 : pendingEntriesThatMayAffectWrap(pendingBuffer.entries, targetFile, originalLines, startLine, process.cwd());
227 if (pendingEntriesForTarget.length > 0) {
228 console.error(JSON.stringify({
229 error: 'missing_page_url_with_pending_edits',
230 pendingEntries: pendingEntriesForTarget.length,
231 hint: 'Pending manual edits may affect the selected source block. Pass --page-url=$event.pageUrl so the wrap block reflects the user\'s staged DOM.',
232 }));
233 process.exit(1);
234 }
235 if (pageUrl) {
236 const failedBufferedOps = [];
237 for (const entry of pendingBuffer.entries || []) {
238 if (entry.pageUrl !== pageUrl) continue;
239 for (const op of entry.ops || []) {
240 const mayAffectWrap = manualEditMayAffectWrap(op, targetFile, originalLines, startLine, process.cwd());
241 const result = applyBufferedManualEditToLines(originalLines, startLine, op);
242 if (result.changed) {
243 originalLines = result.lines;
244 continue;
245 }
246 if (!mayAffectWrap) continue;
247 failedBufferedOps.push({
248 entryId: entry.id,
249 ref: op?.ref || null,
250 originalText: op?.originalText || null,
251 reason: 'ambiguous_or_unmatched_pending_edit',
252 });
253 }
254 }
255 if (failedBufferedOps.length > 0) {
256 console.error(JSON.stringify({
257 error: 'manual_edit_buffer_apply_failed',
258 pendingOps: failedBufferedOps,
259 hint: 'A staged copy edit appears to affect the selected source block, but could not be applied unambiguously to the wrap original. Apply or discard copy edits first, or write the wrapper manually.',
260 }));
261 process.exit(1);
262 }
263 }
264
265 const originalBaseIndent = minLeadingSpaces(originalLines);
266 const reindentOriginal = (extra) => originalLines
267 .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
268 .join('\n');
269 const originalIndented = reindentOriginal(' ');
270 const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
271 const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
272
273 // Wrapper attributes differ by syntax. HTML allows plain string attrs;
274 // JSX requires object-literal style and parses string attrs as HTML (which
275 // either type-errors or renders a literal CSS string).
276 const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
277
278 // JSX/TSX guard: the picked element occupies a single JSX child slot
279 // (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
280 // any other expression position). Replacing it with `comment + <div> +
281 // comment` yields three adjacent siblings — invalid JSX. We can't use a
282 // Fragment `<></>` either: parents that clone children (Radix `asChild`,
283 // Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
284 // they try to pass an `id` through.
285 //
286 // Solution: keep the wrapper `<div>` as the single JSX-slot child and
287 // tuck both marker comments INSIDE it. accept/discard then expands its
288 // replacement range to include the wrapper's `<div>` open / close lines
289 // so the entire scaffold gets removed cleanly.
290 const wrapperLines = isJsx ? [
291 indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
292 indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
293 indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
294 indent + ' <div data-impeccable-variant="original">',
295 reindentOriginal(' '),
296 indent + ' </div>',
297 indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
298 indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
299 indent + '</div>',
300 ] : [
301 indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
302 indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
303 indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
304 indent + ' <div data-impeccable-variant="original">',
305 originalIndented,
306 indent + ' </div>',
307 indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
308 indent + '</div>',
309 indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
310 ];
311
312 let outputFile = targetFile;
313 let outputLines;
314 let outputStartLine = startLine + 1;
315 let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
316 let insertLine;
317 let svelteSession = null;
318
319 if (useSvelteComponent) {
320 // Svelte/SvelteKit resets component-local state on markup HMR updates.
321 // Keep generation source-neutral: agents write real variant components
322 // under the generated componentDir, the browser mounts them into the live
323 // DOM, and live-accept.mjs inlines the accepted variant back into the route.
324 svelteSession = scaffoldSvelteComponentSession({
325 id,
326 count,
327 sourceFile: relTargetFile,
328 sourceStartLine: startLine + 1,
329 sourceEndLine: endLine + 1,
330 originalLines,
331 cwd: process.cwd(),
332 });
333 outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
334 outputStartLine = 1;
335 outputEndLine = 1;
336 insertLine = 1;
337 } else {
338 // Replace the original element with the wrapper
339 const newLines = [
340 ...lines.slice(0, startLine),
341 ...wrapperLines,
342 ...lines.slice(endLine + 1),
343 ];
344 fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
345
346 // Calculate insert line (the "insert below this line" comment).
347 // 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
348 // the insert marker (HTML: start-comment + outer-div + Original-comment +
349 // original-div + content + close-original-div; JSX: outer-div +
350 // start-comment + Original-comment + original-div + content +
351 // close-original-div). Multi-line originals push the marker by their
352 // extra line count.
353 insertLine = startLine + 6 + (originalLines.length - 1) + 1;
354 }
355
356 const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
357
358 const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
359
360 console.log(JSON.stringify({
361 file: outputRelFile,
362 sourceFile: useSvelteComponent ? relTargetFile : undefined,
363 previewMode: useSvelteComponent ? 'svelte-component' : undefined,
364 componentDir: svelteSession?.componentDir,
365 propContract: svelteSession?.propContract,
366 sourceStartLine: useSvelteComponent ? startLine + 1 : undefined,
367 sourceEndLine: useSvelteComponent ? endLine + 1 : undefined,
368 startLine: outputStartLine, // 1-indexed for the agent
369 // wrapperLines is an array but one element (the original-content slot)
370 // is a `\n`-joined multi-line string, so the actual file-row count is
371 // wrapperLines.length + (originalLines.length - 1). Without the offset,
372 // endLine pointed inside the wrapper for any picked element that
373 // spanned more than one source line.
374 endLine: outputEndLine, // 1-indexed
375 insertLine, // 1-indexed: where variants go
376 commentSyntax: commentSyntax,
377 styleMode: useSvelteComponent ? 'svelte-component' : styleMode.mode,
378 styleTag: useSvelteComponent ? null : styleMode.styleTag,
379 cssSelectorPrefixExamples: useSvelteComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
380 cssAuthoring: useSvelteComponent ? svelteComponentAuthoring : buildCssAuthoring(styleMode, count),
381 originalLineCount: originalLines.length,
382 }));
383 }
384
385 // ---------------------------------------------------------------------------
386 // Helpers
387 // ---------------------------------------------------------------------------
388
389 function argVal(args, flag) {
390 const prefix = flag + '=';
391 for (const arg of args) {
392 if (arg.startsWith(prefix)) return arg.slice(prefix.length);
393 }
394 const idx = args.indexOf(flag);
395 return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
396 }
397
398 function pendingEntriesThatMayAffectWrap(entries, targetFile, originalLines, selectionStartLine, cwd) {
399 const targetAbs = path.resolve(cwd, targetFile);
400 return (entries || []).filter((entry) => {
401 return (entry.ops || []).some((op) => {
402 return manualEditMayAffectWrap(op, targetAbs, originalLines, selectionStartLine, cwd);
403 });
404 });
405 }
406
407 function manualEditMayAffectWrap(op, targetFile, originalLines, selectionStartLine, cwd) {
408 const targetAbs = path.resolve(cwd, targetFile);
409 if (manualEditHintFallsInsideSelection(op, targetAbs, originalLines, selectionStartLine, cwd)) return true;
410 if (manualEditLocatorMatchesSelection(op, originalLines)) return true;
411 if (typeof op?.originalText === 'string' && op.originalText.length > 0) {
412 return originalLines.join('\n').includes(op.originalText);
413 }
414 return false;
415 }
416
417 function manualEditHintFallsInsideSelection(op, targetAbs, originalLines, selectionStartLine, cwd) {
418 const hintFile = op?.sourceHint?.file;
419 const hintedLine = Number(op?.sourceHint?.line);
420 if (!hintFile || !Number.isFinite(hintedLine)) return false;
421 const hintAbs = path.isAbsolute(hintFile) ? hintFile : path.resolve(cwd, hintFile);
422 if (path.resolve(hintAbs) !== targetAbs) return false;
423 const hintedIndex = hintedLine - 1 - selectionStartLine;
424 return hintedIndex >= 0
425 && hintedIndex < originalLines.length
426 && typeof op?.originalText === 'string'
427 && originalLines[hintedIndex].includes(op.originalText);
428 }
429
430 function manualEditLocatorMatchesSelection(op, originalLines) {
431 if (!op || typeof op.originalText !== 'string' || op.originalText.length === 0) return false;
432 return originalLines.some((line) => (
433 line.includes(op.originalText) && lineMatchesManualEditLocator(line, op)
434 ));
435 }
436
437 function applyBufferedManualEditToLines(originalLines, selectionStartLine, op) {
438 if (
439 !op
440 || typeof op.originalText !== 'string'
441 || op.originalText.length === 0
442 || typeof op.newText !== 'string'
443 ) {
444 return { lines: originalLines, changed: false };
445 }
446
447 const replaceLine = (lineIndex) => ({
448 lines: originalLines.map((line, index) => (
449 index === lineIndex ? replaceOnce(line, op.originalText, op.newText) : line
450 )),
451 changed: true,
452 });
453
454 const hintedLine = Number(op.sourceHint?.line);
455 if (Number.isFinite(hintedLine)) {
456 const hintedIndex = hintedLine - 1 - selectionStartLine;
457 if (hintedIndex >= 0 && hintedIndex < originalLines.length && originalLines[hintedIndex].includes(op.originalText)) {
458 return replaceLine(hintedIndex);
459 }
460 }
461
462 const locatorMatches = [];
463 for (let index = 0; index < originalLines.length; index += 1) {
464 const line = originalLines[index];
465 if (!line.includes(op.originalText)) continue;
466 if (!lineMatchesManualEditLocator(line, op)) continue;
467 locatorMatches.push(index);
468 }
469 if (locatorMatches.length === 1) return replaceLine(locatorMatches[0]);
470
471 const originalBlock = originalLines.join('\n');
472 if (countOccurrences(originalBlock, op.originalText) === 1) {
473 return {
474 lines: replaceOnce(originalBlock, op.originalText, op.newText).split('\n'),
475 changed: true,
476 };
477 }
478
479 return { lines: originalLines, changed: false };
480 }
481
482 function lineMatchesManualEditLocator(line, op) {
483 if (op.tag) {
484 const tagRe = new RegExp('<\\s*' + escapeRegExp(op.tag) + '(?=[\\s>/]|$)', 'i');
485 if (!tagRe.test(line)) return false;
486 }
487
488 if (op.elementId) {
489 const id = escapeRegExp(op.elementId);
490 const idRe = new RegExp('\\bid\\s*=\\s*["\']' + id + '["\']');
491 if (!idRe.test(line)) return false;
492 }
493
494 const classes = Array.isArray(op.classes) ? op.classes.filter(Boolean) : [];
495 for (const className of classes) {
496 if (!line.includes(className)) return false;
497 }
498
499 return true;
500 }
501
502 function replaceOnce(value, needle, replacement) {
503 const index = value.indexOf(needle);
504 if (index === -1) return value;
505 return value.slice(0, index) + replacement + value.slice(index + needle.length);
506 }
507
508 function countOccurrences(value, needle) {
509 if (!needle) return 0;
510 let count = 0;
511 let index = 0;
512 while (true) {
513 index = value.indexOf(needle, index);
514 if (index === -1) return count;
515 count += 1;
516 index += needle.length;
517 }
518 }
519
520 function escapeRegExp(value) {
521 return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
522 }
523
524 /**
525 * Build search query strings in priority order (most specific first).
526 * ID is most reliable, then specific class combos, then single classes, then raw query.
527 */
528 function buildSearchQueries(elementId, classes, tag, query) {
529 const queries = [];
530
531 // 1. ID is the most specific
532 if (elementId) {
533 queries.push('id="' + elementId + '"');
534 }
535
536 // 2. Full class attribute match (for elements with distinctive multi-class combos).
537 // Emit both class="..." (HTML) and className="..." (React/JSX) so whichever
538 // convention the file uses will match.
539 if (classes) {
540 const classList = splitClassList(classes);
541 if (classList.length > 1) {
542 const joined = classList.join(' ');
543 const sorted = [...classList].sort((a, b) => b.length - a.length);
544 queries.push('class="' + joined + '"');
545 queries.push('className="' + joined + '"');
546 for (const className of sorted) {
547 queries.push(className);
548 }
549 } else if (classList.length === 1) {
550 queries.push(classList[0]);
551 }
552 }
553
554 // 3. Tag + class combo (e.g., <section class="hero">).
555 // Same dual-emit for JSX compatibility.
556 if (tag && classes) {
557 const firstClass = splitClassList(classes)[0];
558 queries.push('<' + tag + ' class="' + firstClass);
559 queries.push('<' + tag + ' className="' + firstClass);
560 }
561
562 // 4. Raw fallback query
563 if (query) {
564 queries.push(query);
565 }
566
567 return queries;
568 }
569
570 function splitClassList(classes) {
571 return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean);
572 }
573
574 function attrEscapeDouble(str) {
575 return String(str)
576 .replace(/&/g, '&amp;')
577 .replace(/"/g, '&quot;')
578 .replace(/</g, '&lt;')
579 .replace(/>/g, '&gt;');
580 }
581
582 function detectCommentSyntax(filePath) {
583 const ext = path.extname(filePath).toLowerCase();
584 if (ext === '.jsx' || ext === '.tsx') {
585 return { open: '{/*', close: '*/}' };
586 }
587 // HTML, Vue, Svelte, Astro all use HTML comments
588 return { open: '<!--', close: '-->' };
589 }
590
591 function detectStyleMode(filePath) {
592 const ext = path.extname(filePath).toLowerCase();
593 if (ext === '.astro') {
594 return {
595 mode: 'astro-global-prefixed',
596 styleTag: '<style is:inline data-impeccable-css="SESSION_ID">',
597 };
598 }
599 return {
600 mode: 'scoped',
601 styleTag: '<style data-impeccable-css="SESSION_ID">',
602 };
603 }
604
605 function buildCssSelectorPrefixExamples(styleMode, count) {
606 if (styleMode !== 'astro-global-prefixed') return [];
607 return Array.from({ length: count }, (_, i) => `[data-impeccable-variant="${i + 1}"]`);
608 }
609
610 function buildCssAuthoring(styleMode, count) {
611 const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
612 if (styleMode.mode === 'astro-global-prefixed') {
613 return {
614 mode: styleMode.mode,
615 styleTag: styleMode.styleTag,
616 strategy: 'global-prefixed',
617 rulePattern: '[data-impeccable-variant="N"] > .variant-class { ... }',
618 selectorExamples: variantNumbers.map((n) => `[data-impeccable-variant="${n}"] > .variant-class`),
619 requirements: [
620 'Use the styleTag exactly; the is:inline attribute is required for this file.',
621 'Put raw CSS directly between the styleTag opening and a plain </style> close.',
622 'Prefix every preview selector with the matching [data-impeccable-variant="N"] selector.',
623 'Keep selectors anchored to the generated variant wrapper; do not rely on component CSS scoping for preview rules.',
624 ],
625 forbidden: [
626 'Do not use @scope for this styleMode.',
627 'Do not wrap style content in a JSX/TSX template literal ({` ... `}); that syntax is for .tsx/.jsx only.',
628 'Do not put { immediately after the style opening tag; Astro parses { as expression syntax.',
629 ],
630 };
631 }
632 return {
633 mode: styleMode.mode,
634 styleTag: styleMode.styleTag,
635 strategy: 'scope-rule',
636 rulePattern: '@scope ([data-impeccable-variant="N"]) { :scope > .variant-class { ... } }',
637 selectorExamples: variantNumbers.map((n) => `@scope ([data-impeccable-variant="${n}"]) { :scope > .variant-class { ... } }`),
638 requirements: [
639 'Use @scope blocks keyed to each [data-impeccable-variant="N"] wrapper.',
640 'Inside each @scope block, make :scope rules step into the replacement element with a descendant combinator.',
641 'Use the styleTag exactly; do not add framework-specific style attributes unless this object says to.',
642 ],
643 forbidden: [
644 'Do not use global [data-impeccable-variant="N"] selector prefixes for this styleMode.',
645 'Do not add is:inline to the style tag for this styleMode.',
646 ],
647 };
648 }
649
650 /**
651 * Search project files for the query string (class name, ID, etc.)
652 * Returns the first matching file path, or null.
653 */
654 function findFileWithQuery(query, cwd, genOpts = {}) {
655 const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
656 const seen = new Set();
657
658 for (const dir of searchDirs) {
659 const absDir = path.join(cwd, dir);
660 if (!fs.existsSync(absDir)) continue;
661 const result = searchDir(absDir, query, seen, 0, genOpts);
662 if (result) return result;
663 }
664 return null;
665 }
666
667 function searchDir(dir, query, seen, depth, genOpts) {
668 if (depth > 5) return null; // don't go too deep
669 const realDir = fs.realpathSync(dir);
670 if (seen.has(realDir)) return null;
671 seen.add(realDir);
672
673 let entries;
674 try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
675 catch { return null; }
676
677 // Check files first
678 for (const entry of entries) {
679 if (!entry.isFile()) continue;
680 const ext = path.extname(entry.name).toLowerCase();
681 if (!EXTENSIONS.includes(ext)) continue;
682
683 const filePath = path.join(dir, entry.name);
684 if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue;
685 try {
686 const content = fs.readFileSync(filePath, 'utf-8');
687 if (content.includes(query)) return filePath;
688 } catch { /* skip unreadable files */ }
689 }
690
691 // Then recurse into directories. Always skip node_modules and .git (never
692 // project content). dist/build/out are left to the isGeneratedFile guard so
693 // the includeGenerated second-pass can still find the element there and
694 // report `generatedMatch`.
695 for (const entry of entries) {
696 if (!entry.isDirectory()) continue;
697 if (entry.name === 'node_modules' || entry.name === '.git') continue;
698 const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts);
699 if (result) return result;
700 }
701
702 return null;
703 }
704
705 /**
706 * Regex that matches a tag opener on a line. Allows the tag name to be
707 * followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX
708 * openers (e.g. `<section\n className="..."\n>`) are recognised.
709 */
710 const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
711
712 /**
713 * Find the element's start and end line in the file.
714 *
715 * `query` is a class name, attribute fragment (`class="..."`, `className="..."`,
716 * `id="..."`), or a raw text snippet. Because a query can appear on a
717 * continuation line of a multi-line tag (e.g. the `className="..."` row of a
718 * `<section\n className="..."\n>` JSX tag), we walk backward from the match
719 * line to find the actual tag opener. When `tag` is provided, opener candidates
720 * must match that tag name.
721 */
722 /**
723 * Return the smallest leading-whitespace count across a set of lines,
724 * ignoring blank lines (whose indent isn't load-bearing). Used to compute
725 * the common base indent of a multi-line picked element so reindenting
726 * under the wrapper preserves the relative depth between lines.
727 */
728 function minLeadingSpaces(lines) {
729 let min = Infinity;
730 for (const l of lines) {
731 if (l.trim() === '') continue;
732 const m = l.match(/^(\s*)/);
733 if (m && m[1].length < min) min = m[1].length;
734 }
735 return min === Infinity ? 0 : min;
736 }
737
738 function findElement(lines, query, tag = null) {
739 // Iterate all matches — the first substring hit isn't always the right one.
740 for (let i = 0; i < lines.length; i++) {
741 if (!lines[i].includes(query)) continue;
742
743 const stripped = lines[i].trim();
744 if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
745 // Skip lines already inside a variant wrapper
746 if (lines[i].includes('data-impeccable-variant')) continue;
747
748 const openerLine = findOpenerLine(lines, i, tag);
749 if (openerLine === -1) continue;
750
751 const endLine = findClosingLine(lines, openerLine);
752 return { startLine: openerLine, endLine };
753 }
754
755 return null;
756 }
757
758 /**
759 * Like findElement, but returns every match. Used for ambiguity detection
760 * when the agent passes --text: when the same className appears on multiple
761 * sibling elements (a list of cards, repeated section variants, etc.),
762 * first-match silently lands on the wrong branch. Returning all matches lets
763 * the caller narrow by textContent or fail with a structured ambiguity error.
764 */
765 function findAllElements(lines, query, tag = null) {
766 const out = [];
767 const seen = new Set();
768 for (let i = 0; i < lines.length; i++) {
769 if (!lines[i].includes(query)) continue;
770 const stripped = lines[i].trim();
771 if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
772 if (lines[i].includes('data-impeccable-variant')) continue;
773 const openerLine = findOpenerLine(lines, i, tag);
774 if (openerLine === -1) continue;
775 if (seen.has(openerLine)) continue; // multiple matches inside the same element
776 seen.add(openerLine);
777 const endLine = findClosingLine(lines, openerLine);
778 out.push({ startLine: openerLine, endLine });
779 }
780 return out;
781 }
782
783 /**
784 * Narrow a candidate set to those whose source body matches a meaningful
785 * prefix of the picked element's textContent. The compare strips tags and
786 * JSX expressions, then checks two whitespace normalizations side-by-side:
787 *
788 * - single-space ("hero two second card body")
789 * - no-whitespace ("herotwosecondcardbody")
790 *
791 * Both are needed because `el.textContent` concatenates sibling text without
792 * inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as
793 * `"Hero TwoSecond…"`), while the source has whitespace between tags. If
794 * EITHER normalization matches, the candidate keeps. A snippet shorter than
795 * 8 chars after stripping is too weak to disambiguate — the caller falls
796 * back to first-match.
797 */
798 function filterByText(candidates, lines, text) {
799 const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
800 // Too short to disambiguate. Return [] so the caller's `filtered.length
801 // === 0` branch fires (fall back to first-match) — the previous
802 // `candidates.slice()` return forced `filtered.length > 1` and surfaced
803 // a spurious `element_ambiguous` error on every short-text picker event
804 // with multiple candidates.
805 if (trimmed.length < 8) return [];
806 const targetSpaced = trimmed;
807 const targetCompact = trimmed.replace(/\s+/g, '');
808
809 return candidates.filter((c) => {
810 const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
811 const inner = body
812 .replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
813 .replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
814 .toLowerCase();
815 const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
816 const sourceCompact = inner.replace(/\s+/g, '');
817 return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
818 });
819 }
820
821 /**
822 * Resolve a match line to the real tag opener. If the match line itself opens
823 * a tag, return it. Otherwise walk up to 10 lines backward looking for the
824 * first tag opener. If `tag` is specified, the opener must match that tag
825 * name; an opener with a different tag name aborts the backward walk for this
826 * match (we don't jump across element boundaries).
827 *
828 * Returns the line index of the opener, or -1 if none can be resolved.
829 */
830 function findOpenerLine(lines, matchLine, tag) {
831 const self = lines[matchLine].match(OPENER_RE);
832 if (self) {
833 if (!tag || self[1] === tag) return matchLine;
834 return -1;
835 }
836 const MAX_BACKWALK = 10;
837 for (let i = matchLine - 1; i >= Math.max(0, matchLine - MAX_BACKWALK); i--) {
838 const opener = lines[i].match(OPENER_RE);
839 if (!opener) continue;
840 if (!tag || opener[1] === tag) return i;
841 // Different tag name than requested — abort; we're inside a non-target opener.
842 return -1;
843 }
844 return -1;
845 }
846
847 /**
848 * Starting from a line with an opening tag, find the line with the matching
849 * closing tag by counting tag nesting depth.
850 */
851 function findClosingLine(lines, start) {
852 const openMatch = lines[start].match(OPENER_RE);
853 if (!openMatch) return start; // caller passed a non-opener; nothing to span
854
855 const tagName = openMatch[1];
856 let depth = 0;
857 const openRe = new RegExp('<' + tagName + '(?=[\\s/>]|$)', 'g');
858 const selfCloseRe = new RegExp('<' + tagName + '[^>]*/>', 'g');
859 const closeRe = new RegExp('</' + tagName + '\\s*>', 'g');
860
861 for (let i = start; i < lines.length; i++) {
862 const line = lines[i];
863 const opens = (line.match(openRe) || []).length;
864 const selfCloses = (line.match(selfCloseRe) || []).length;
865 const closes = (line.match(closeRe) || []).length;
866
867 depth += opens - selfCloses - closes;
868
869 if (depth <= 0) return i;
870 }
871
872 // If we can't find the close, return a reasonable guess
873 return Math.min(start + 50, lines.length - 1);
874 }
875
876 // Auto-execute when run directly (node live-wrap.mjs ...)
877 const _running = process.argv[1];
878 if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) {
879 wrapCli();
880 }
881
882 // Test exports (used by tests/live-wrap.test.mjs)
883 export {
884 buildSearchQueries,
885 findElement,
886 findClosingLine,
887 detectCommentSyntax,
888 findAllElements,
889 filterByText,
890 findFileWithQuery,
891 detectStyleMode,
892 buildCssAuthoring,
893 buildCssSelectorPrefixExamples,
894 };
895
895 lines Plain Text