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
14import fs from 'node:fs';
15import path from 'node:path';
16import { isGeneratedFile } from './is-generated.mjs';
17
18const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
19
20export async function wrapCli() {
21 const args = process.argv.slice(2);
22
23 if (args.includes('--help') || args.includes('-h')) {
24 console.log(`Usage: impeccable wrap [options]
25
26Find an element in source and wrap it in a variant container.
27
28Required:
29 --id ID Session ID for the variant wrapper
30 --count N Number of expected variants (1-8)
31
32Element identification (at least one required):
33 --element-id ID HTML id attribute of the element
34 --classes A,B,C Comma-separated CSS class names
35 --tag TAG Tag name (div, section, etc.)
36 --query TEXT Fallback: raw text to search for
37
38Optional:
39 --file PATH Source file to search in (skips auto-detection)
40 --text TEXT Picked element's textContent. Used to disambiguate when
41 classes/tag match multiple sibling elements (e.g. a list
42 of <Card>s with the same className). Pass the first ~80
43 chars of event.element.textContent.
44 --help Show this help message
45
46Output (JSON):
47 { file, startLine, endLine, insertLine, commentSyntax }
48
49The agent should insert variant HTML at insertLine.`);
50 process.exit(0);
51 }
52
53 const id = argVal(args, '--id');
54 const count = parseInt(argVal(args, '--count') || '3');
55 const elementId = argVal(args, '--element-id');
56 const classes = argVal(args, '--classes');
57 const tag = argVal(args, '--tag');
58 const query = argVal(args, '--query');
59 const filePath = argVal(args, '--file');
60 const text = argVal(args, '--text');
61
62 if (!id) { console.error('Missing --id'); process.exit(1); }
63 if (!elementId && !classes && !query) {
64 console.error('Need at least one of: --element-id, --classes, --query');
65 process.exit(1);
66 }
67
68 // Build search queries in priority order (most specific first)
69 const queries = buildSearchQueries(elementId, classes, tag, query);
70
71 const genOpts = { cwd: process.cwd() };
72
73 // Find the source file. Generated files are excluded from auto-search so we
74 // don't silently write variants into a file the next build will wipe.
75 let targetFile = filePath;
76 let matchedQuery = null;
77 if (!targetFile) {
78 for (const q of queries) {
79 targetFile = findFileWithQuery(q, process.cwd(), genOpts);
80 if (targetFile) { matchedQuery = q; break; }
81 }
82 if (!targetFile) {
83 // Nothing in source. Did the element show up in a generated file? That
84 // tells the agent "fall back to the agent-driven flow" vs "element just
85 // doesn't exist in this project."
86 let generatedHit = null;
87 for (const q of queries) {
88 generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
89 if (generatedHit) break;
90 }
91 if (generatedHit) {
92 console.error(JSON.stringify({
93 error: 'element_not_in_source',
94 fallback: 'agent-driven',
95 generatedMatch: path.relative(process.cwd(), generatedHit),
96 hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',
97 }));
98 } else {
99 console.error(JSON.stringify({
100 error: 'element_not_found',
101 fallback: 'agent-driven',
102 hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',
103 }));
104 }
105 process.exit(1);
106 }
107 } else {
108 if (isGeneratedFile(targetFile, genOpts)) {
109 console.error(JSON.stringify({
110 error: 'file_is_generated',
111 fallback: 'agent-driven',
112 file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
113 hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',
114 }));
115 process.exit(1);
116 }
117 matchedQuery = queries[0];
118 }
119
120 const content = fs.readFileSync(targetFile, 'utf-8');
121 const lines = content.split('\n');
122
123 // Find the element, trying each query in priority order. When `--text` is
124 // supplied, collect every candidate the queries surface and disambiguate
125 // by the picked element's textContent. Without `--text`, fall back to the
126 // legacy first-match behavior so unmodified callers keep working.
127 let match = null;
128 if (text) {
129 const candidates = [];
130 for (const q of queries) {
131 const all = findAllElements(lines, q, tag);
132 for (const c of all) {
133 if (!candidates.some((x) => x.startLine === c.startLine)) {
134 candidates.push(c);
135 }
136 }
137 // Once a more-specific query (ID, full className combo) yielded a unique
138 // result, stop — falling through to the loose tag+single-class query
139 // would readmit the siblings we just disambiguated past.
140 if (candidates.length === 1) break;
141 }
142 if (candidates.length === 0) {
143 console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
144 process.exit(1);
145 }
146 if (candidates.length === 1) {
147 match = candidates[0];
148 } else {
149 const filtered = filterByText(candidates, lines, text);
150 if (filtered.length === 1) {
151 match = filtered[0];
152 } else if (filtered.length === 0) {
153 // Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
154 // browser-side textContent doesn't appear literally in source. Fall
155 // back to first-match rather than refusing — this is the same
156 // behavior unmodified callers see, just preserved.
157 match = candidates[0];
158 } else {
159 // Multiple candidates ALSO match the text. Truly ambiguous — refuse
160 // rather than pick wrong, and hand the agent the candidate locations
161 // so it can disambiguate by reading the file.
162 console.error(JSON.stringify({
163 error: 'element_ambiguous',
164 fallback: 'agent-driven',
165 file: path.relative(process.cwd(), targetFile),
166 candidates: filtered.map((c) => ({
167 startLine: c.startLine + 1,
168 endLine: c.endLine + 1,
169 })),
170 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.',
171 }));
172 process.exit(1);
173 }
174 }
175 } else {
176 for (const q of queries) {
177 match = findElement(lines, q, tag);
178 if (match) break;
179 }
180 if (!match) {
181 console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
182 process.exit(1);
183 }
184 }
185
186 const { startLine, endLine } = match;
187 const commentSyntax = detectCommentSyntax(targetFile);
188 const styleMode = detectStyleMode(targetFile);
189 const isJsx = commentSyntax.open === '{/*';
190 const indent = lines[startLine].match(/^(\s*)/)[1];
191
192 // Extract the original element. Reindent under the wrapper while preserving
193 // the relative depth between lines — `l.trimStart()` would strip ALL leading
194 // whitespace and collapse e.g. `<aside>`/` <h1>`/`</aside>` (6/8/6 spaces)
195 // to a single uniform indent, so on accept/discard the round-trip restores
196 // the inner element at its parent's depth instead of nested inside it.
197 // Strip only the COMMON minimum leading whitespace across the picked lines;
198 // `deindentContent` on the accept side already mirrors this convention.
199 const originalLines = lines.slice(startLine, endLine + 1);
200 const originalBaseIndent = minLeadingSpaces(originalLines);
201 const reindentOriginal = (extra) => originalLines
202 .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
203 .join('\n');
204 const originalIndented = reindentOriginal(' ');
205
206 // Wrapper attributes differ by syntax. HTML allows plain string attrs;
207 // JSX requires object-literal style and parses string attrs as HTML (which
208 // either type-errors or renders a literal CSS string).
209 const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
210
211 // JSX/TSX guard: the picked element occupies a single JSX child slot
212 // (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
213 // any other expression position). Replacing it with `comment + <div> +
214 // comment` yields three adjacent siblings — invalid JSX. We can't use a
215 // Fragment `<></>` either: parents that clone children (Radix `asChild`,
216 // Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
217 // they try to pass an `id` through.
218 //
219 // Solution: keep the wrapper `<div>` as the single JSX-slot child and
220 // tuck both marker comments INSIDE it. accept/discard then expands its
221 // replacement range to include the wrapper's `<div>` open / close lines
222 // so the entire scaffold gets removed cleanly.
223 const wrapperLines = isJsx ? [
224 indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
225 indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
226 indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
227 indent + ' <div data-impeccable-variant="original">',
228 reindentOriginal(' '),
229 indent + ' </div>',
230 indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
231 indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
232 indent + '</div>',
233 ] : [
234 indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
235 indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
236 indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
237 indent + ' <div data-impeccable-variant="original">',
238 originalIndented,
239 indent + ' </div>',
240 indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
241 indent + '</div>',
242 indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
243 ];
244
245 // Replace the original element with the wrapper
246 const newLines = [
247 ...lines.slice(0, startLine),
248 ...wrapperLines,
249 ...lines.slice(endLine + 1),
250 ];
251 fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
252
253 // Calculate insert line (the "insert below this line" comment).
254 // 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
255 // the insert marker (HTML: start-comment + outer-div + Original-comment +
256 // original-div + content + close-original-div; JSX: outer-div +
257 // start-comment + Original-comment + original-div + content +
258 // close-original-div). Multi-line originals push the marker by their
259 // extra line count.
260 const insertLine = startLine + 6 + (originalLines.length - 1);
261
262 console.log(JSON.stringify({
263 file: path.relative(process.cwd(), targetFile),
264 startLine: startLine + 1, // 1-indexed for the agent
265 // wrapperLines is an array but one element (the original-content slot)
266 // is a `\n`-joined multi-line string, so the actual file-row count is
267 // wrapperLines.length + (originalLines.length - 1). Without the offset,
268 // endLine pointed inside the wrapper for any picked element that
269 // spanned more than one source line.
270 endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
271 insertLine: insertLine + 1, // 1-indexed: where variants go
272 commentSyntax: commentSyntax,
273 styleMode: styleMode.mode,
274 styleTag: styleMode.styleTag,
275 cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count),
276 cssAuthoring: buildCssAuthoring(styleMode, count),
277 originalLineCount: originalLines.length,
278 }));
279}
280
281// ---------------------------------------------------------------------------
282// Helpers
283// ---------------------------------------------------------------------------
284
285function argVal(args, flag) {
286 const idx = args.indexOf(flag);
287 return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
288}
289
290/**
291 * Build search query strings in priority order (most specific first).
292 * ID is most reliable, then specific class combos, then single classes, then raw query.
293 */
294function buildSearchQueries(elementId, classes, tag, query) {
295 const queries = [];
296
297 // 1. ID is the most specific
298 if (elementId) {
299 queries.push('id="' + elementId + '"');
300 }
301
302 // 2. Full class attribute match (for elements with distinctive multi-class combos).
303 // Emit both class="..." (HTML) and className="..." (React/JSX) so whichever
304 // convention the file uses will match.
305 if (classes) {
306 const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
307 if (classList.length > 1) {
308 const joined = classList.join(' ');
309 const sorted = [...classList].sort((a, b) => b.length - a.length);
310 queries.push('class="' + joined + '"');
311 queries.push('className="' + joined + '"');
312 queries.push(sorted[0]); // most distinctive single class, fallback
313 } else if (classList.length === 1) {
314 queries.push(classList[0]);
315 }
316 }
317
318 // 3. Tag + class combo (e.g., <section class="hero">).
319 // Same dual-emit for JSX compatibility.
320 if (tag && classes) {
321 const firstClass = classes.split(',')[0].trim();
322 queries.push('<' + tag + ' class="' + firstClass);
323 queries.push('<' + tag + ' className="' + firstClass);
324 }
325
326 // 4. Raw fallback query
327 if (query) {
328 queries.push(query);
329 }
330
331 return queries;
332}
333
334function detectCommentSyntax(filePath) {
335 const ext = path.extname(filePath).toLowerCase();
336 if (ext === '.jsx' || ext === '.tsx') {
337 return { open: '{/*', close: '*/}' };
338 }
339 // HTML, Vue, Svelte, Astro all use HTML comments
340 return { open: '<!--', close: '-->' };
341}
342
343function detectStyleMode(filePath) {
344 const ext = path.extname(filePath).toLowerCase();
345 if (ext === '.astro') {
346 return {
347 mode: 'astro-global-prefixed',
348 styleTag: '<style is:inline data-impeccable-css="SESSION_ID">',
349 };
350 }
351 return {
352 mode: 'scoped',
353 styleTag: '<style data-impeccable-css="SESSION_ID">',
354 };
355}
356
357function buildCssSelectorPrefixExamples(styleMode, count) {
358 if (styleMode !== 'astro-global-prefixed') return [];
359 return Array.from({ length: count }, (_, i) => `[data-impeccable-variant="${i + 1}"]`);
360}
361
362function buildCssAuthoring(styleMode, count) {
363 const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
364 if (styleMode.mode === 'astro-global-prefixed') {
365 return {
366 mode: styleMode.mode,
367 styleTag: styleMode.styleTag,
368 strategy: 'global-prefixed',
369 rulePattern: '[data-impeccable-variant="N"] > .variant-class { ... }',
370 selectorExamples: variantNumbers.map((n) => `[data-impeccable-variant="${n}"] > .variant-class`),
371 requirements: [
372 'Use the styleTag exactly; the is:inline attribute is required for this file.',
373 'Prefix every preview selector with the matching [data-impeccable-variant="N"] selector.',
374 'Keep selectors anchored to the generated variant wrapper; do not rely on component CSS scoping for preview rules.',
375 ],
376 forbidden: [
377 'Do not use @scope for this styleMode.',
378 ],
379 };
380 }
381 return {
382 mode: styleMode.mode,
383 styleTag: styleMode.styleTag,
384 strategy: 'scope-rule',
385 rulePattern: '@scope ([data-impeccable-variant="N"]) { :scope > .variant-class { ... } }',
386 selectorExamples: variantNumbers.map((n) => `@scope ([data-impeccable-variant="${n}"]) { :scope > .variant-class { ... } }`),
387 requirements: [
388 'Use @scope blocks keyed to each [data-impeccable-variant="N"] wrapper.',
389 'Inside each @scope block, make :scope rules step into the replacement element with a descendant combinator.',
390 'Use the styleTag exactly; do not add framework-specific style attributes unless this object says to.',
391 ],
392 forbidden: [
393 'Do not use global [data-impeccable-variant="N"] selector prefixes for this styleMode.',
394 'Do not add is:inline to the style tag for this styleMode.',
395 ],
396 };
397}
398
399/**
400 * Search project files for the query string (class name, ID, etc.)
401 * Returns the first matching file path, or null.
402 */
403function findFileWithQuery(query, cwd, genOpts = {}) {
404 const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
405 const seen = new Set();
406
407 for (const dir of searchDirs) {
408 const absDir = path.join(cwd, dir);
409 if (!fs.existsSync(absDir)) continue;
410 const result = searchDir(absDir, query, seen, 0, genOpts);
411 if (result) return result;
412 }
413 return null;
414}
415
416function searchDir(dir, query, seen, depth, genOpts) {
417 if (depth > 5) return null; // don't go too deep
418 const realDir = fs.realpathSync(dir);
419 if (seen.has(realDir)) return null;
420 seen.add(realDir);
421
422 let entries;
423 try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
424 catch { return null; }
425
426 // Check files first
427 for (const entry of entries) {
428 if (!entry.isFile()) continue;
429 const ext = path.extname(entry.name).toLowerCase();
430 if (!EXTENSIONS.includes(ext)) continue;
431
432 const filePath = path.join(dir, entry.name);
433 if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue;
434 try {
435 const content = fs.readFileSync(filePath, 'utf-8');
436 if (content.includes(query)) return filePath;
437 } catch { /* skip unreadable files */ }
438 }
439
440 // Then recurse into directories. Always skip node_modules and .git (never
441 // project content). dist/build/out are left to the isGeneratedFile guard so
442 // the includeGenerated second-pass can still find the element there and
443 // report `generatedMatch`.
444 for (const entry of entries) {
445 if (!entry.isDirectory()) continue;
446 if (entry.name === 'node_modules' || entry.name === '.git') continue;
447 const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts);
448 if (result) return result;
449 }
450
451 return null;
452}
453
454/**
455 * Regex that matches a tag opener on a line. Allows the tag name to be
456 * followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX
457 * openers (e.g. `<section\n className="..."\n>`) are recognised.
458 */
459const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
460
461/**
462 * Find the element's start and end line in the file.
463 *
464 * `query` is a class name, attribute fragment (`class="..."`, `className="..."`,
465 * `id="..."`), or a raw text snippet. Because a query can appear on a
466 * continuation line of a multi-line tag (e.g. the `className="..."` row of a
467 * `<section\n className="..."\n>` JSX tag), we walk backward from the match
468 * line to find the actual tag opener. When `tag` is provided, opener candidates
469 * must match that tag name.
470 */
471/**
472 * Return the smallest leading-whitespace count across a set of lines,
473 * ignoring blank lines (whose indent isn't load-bearing). Used to compute
474 * the common base indent of a multi-line picked element so reindenting
475 * under the wrapper preserves the relative depth between lines.
476 */
477function minLeadingSpaces(lines) {
478 let min = Infinity;
479 for (const l of lines) {
480 if (l.trim() === '') continue;
481 const m = l.match(/^(\s*)/);
482 if (m && m[1].length < min) min = m[1].length;
483 }
484 return min === Infinity ? 0 : min;
485}
486
487function findElement(lines, query, tag = null) {
488 // Iterate all matches — the first substring hit isn't always the right one.
489 for (let i = 0; i < lines.length; i++) {
490 if (!lines[i].includes(query)) continue;
491
492 const stripped = lines[i].trim();
493 if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
494 // Skip lines already inside a variant wrapper
495 if (lines[i].includes('data-impeccable-variant')) continue;
496
497 const openerLine = findOpenerLine(lines, i, tag);
498 if (openerLine === -1) continue;
499
500 const endLine = findClosingLine(lines, openerLine);
501 return { startLine: openerLine, endLine };
502 }
503
504 return null;
505}
506
507/**
508 * Like findElement, but returns every match. Used for ambiguity detection
509 * when the agent passes --text: when the same className appears on multiple
510 * sibling elements (a list of cards, repeated section variants, etc.),
511 * first-match silently lands on the wrong branch. Returning all matches lets
512 * the caller narrow by textContent or fail with a structured ambiguity error.
513 */
514function findAllElements(lines, query, tag = null) {
515 const out = [];
516 const seen = new Set();
517 for (let i = 0; i < lines.length; i++) {
518 if (!lines[i].includes(query)) continue;
519 const stripped = lines[i].trim();
520 if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
521 if (lines[i].includes('data-impeccable-variant')) continue;
522 const openerLine = findOpenerLine(lines, i, tag);
523 if (openerLine === -1) continue;
524 if (seen.has(openerLine)) continue; // multiple matches inside the same element
525 seen.add(openerLine);
526 const endLine = findClosingLine(lines, openerLine);
527 out.push({ startLine: openerLine, endLine });
528 }
529 return out;
530}
531
532/**
533 * Narrow a candidate set to those whose source body matches a meaningful
534 * prefix of the picked element's textContent. The compare strips tags and
535 * JSX expressions, then checks two whitespace normalizations side-by-side:
536 *
537 * - single-space ("hero two second card body")
538 * - no-whitespace ("herotwosecondcardbody")
539 *
540 * Both are needed because `el.textContent` concatenates sibling text without
541 * inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as
542 * `"Hero TwoSecond…"`), while the source has whitespace between tags. If
543 * EITHER normalization matches, the candidate keeps. A snippet shorter than
544 * 8 chars after stripping is too weak to disambiguate — the caller falls
545 * back to first-match.
546 */
547function filterByText(candidates, lines, text) {
548 const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
549 // Too short to disambiguate. Return [] so the caller's `filtered.length
550 // === 0` branch fires (fall back to first-match) — the previous
551 // `candidates.slice()` return forced `filtered.length > 1` and surfaced
552 // a spurious `element_ambiguous` error on every short-text picker event
553 // with multiple candidates.
554 if (trimmed.length < 8) return [];
555 const targetSpaced = trimmed;
556 const targetCompact = trimmed.replace(/\s+/g, '');
557
558 return candidates.filter((c) => {
559 const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
560 const inner = body
561 .replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
562 .replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
563 .toLowerCase();
564 const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
565 const sourceCompact = inner.replace(/\s+/g, '');
566 return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
567 });
568}
569
570/**
571 * Resolve a match line to the real tag opener. If the match line itself opens
572 * a tag, return it. Otherwise walk up to 10 lines backward looking for the
573 * first tag opener. If `tag` is specified, the opener must match that tag
574 * name; an opener with a different tag name aborts the backward walk for this
575 * match (we don't jump across element boundaries).
576 *
577 * Returns the line index of the opener, or -1 if none can be resolved.
578 */
579function findOpenerLine(lines, matchLine, tag) {
580 const self = lines[matchLine].match(OPENER_RE);
581 if (self) {
582 if (!tag || self[1] === tag) return matchLine;
583 return -1;
584 }
585 const MAX_BACKWALK = 10;
586 for (let i = matchLine - 1; i >= Math.max(0, matchLine - MAX_BACKWALK); i--) {
587 const opener = lines[i].match(OPENER_RE);
588 if (!opener) continue;
589 if (!tag || opener[1] === tag) return i;
590 // Different tag name than requested — abort; we're inside a non-target opener.
591 return -1;
592 }
593 return -1;
594}
595
596/**
597 * Starting from a line with an opening tag, find the line with the matching
598 * closing tag by counting tag nesting depth.
599 */
600function findClosingLine(lines, start) {
601 const openMatch = lines[start].match(OPENER_RE);
602 if (!openMatch) return start; // caller passed a non-opener; nothing to span
603
604 const tagName = openMatch[1];
605 let depth = 0;
606 const openRe = new RegExp('<' + tagName + '(?=[\\s/>]|$)', 'g');
607 const selfCloseRe = new RegExp('<' + tagName + '[^>]*/>', 'g');
608 const closeRe = new RegExp('</' + tagName + '\\s*>', 'g');
609
610 for (let i = start; i < lines.length; i++) {
611 const line = lines[i];
612 const opens = (line.match(openRe) || []).length;
613 const selfCloses = (line.match(selfCloseRe) || []).length;
614 const closes = (line.match(closeRe) || []).length;
615
616 depth += opens - selfCloses - closes;
617
618 if (depth <= 0) return i;
619 }
620
621 // If we can't find the close, return a reasonable guess
622 return Math.min(start + 50, lines.length - 1);
623}
624
625// Auto-execute when run directly (node live-wrap.mjs ...)
626const _running = process.argv[1];
627if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) {
628 wrapCli();
629}
630
631// Test exports (used by tests/live-wrap.test.mjs)
632export { buildSearchQueries, findElement, findClosingLine, detectCommentSyntax };