Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/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, or8* text snippet), wraps it with the variant scaffolding, and prints the file path9* + 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*/1314import fs from 'node:fs';15import path from 'node:path';16import { isGeneratedFile } from './lib/is-generated.mjs';17import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';18import {19buildSvelteComponentCssAuthoring,20scaffoldSvelteComponentSession,21shouldUseSvelteComponentInjection,22} from './live/svelte-component.mjs';2324const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];2526export async function wrapCli() {27const args = process.argv.slice(2);2829if (args.includes('--help') || args.includes('-h')) {30console.log(`Usage: impeccable wrap [options]3132Find an element in source and wrap it in a variant container.3334Required:35--id ID Session ID for the variant wrapper36--count N Number of expected variants (1-8)3738Element identification (at least one required):39--element-id ID HTML id attribute of the element40--classes A,B,C Comma- or space-separated CSS class names41--tag TAG Tag name (div, section, etc.)42--query TEXT Fallback: raw text to search for4344Optional:45--file PATH Source file to search in (skips auto-detection)46--text TEXT Picked element's textContent. Used to disambiguate when47classes/tag match multiple sibling elements (e.g. a list48of <Card>s with the same className). Pass the first ~8049chars of event.element.textContent.50--page-url URL Current page URL. Required when pending manual edits may51affect the picked source block. Pending edits are filtered52to this page so an edit on /a doesn't bleed into /b.53--help Show this help message5455Output (JSON):56{ file, startLine, endLine, insertLine, commentSyntax }5758The agent should insert variant HTML at insertLine.`);59process.exit(0);60}6162const id = argVal(args, '--id');63const count = parseInt(argVal(args, '--count') || '3');64const elementId = argVal(args, '--element-id');65const classes = argVal(args, '--classes');66const tag = argVal(args, '--tag');67const query = argVal(args, '--query');68const filePath = argVal(args, '--file');69const text = argVal(args, '--text');70const pageUrl = argVal(args, '--page-url');7172if (!id) { console.error('Missing --id'); process.exit(1); }73if (!elementId && !classes && !query) {74console.error('Need at least one of: --element-id, --classes, --query');75process.exit(1);76}7778// Build search queries in priority order (most specific first)79const queries = buildSearchQueries(elementId, classes, tag, query);8081const genOpts = { cwd: process.cwd() };8283// Find the source file. Generated files are excluded from auto-search so we84// don't silently write variants into a file the next build will wipe.85let targetFile = filePath;86let matchedQuery = null;87if (!targetFile) {88for (const q of queries) {89targetFile = findFileWithQuery(q, process.cwd(), genOpts);90if (targetFile) { matchedQuery = q; break; }91}92if (!targetFile) {93// Nothing in source. Did the element show up in a generated file? That94// tells the agent "fall back to the agent-driven flow" vs "element just95// doesn't exist in this project."96let generatedHit = null;97for (const q of queries) {98generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });99if (generatedHit) break;100}101if (generatedHit) {102console.error(JSON.stringify({103error: 'element_not_in_source',104fallback: 'agent-driven',105generatedMatch: path.relative(process.cwd(), generatedHit),106hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',107}));108} else {109console.error(JSON.stringify({110error: 'element_not_found',111fallback: 'agent-driven',112hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',113}));114}115process.exit(1);116}117} else {118if (isGeneratedFile(targetFile, genOpts)) {119console.error(JSON.stringify({120error: 'file_is_generated',121fallback: 'agent-driven',122file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),123hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',124}));125process.exit(1);126}127matchedQuery = queries[0];128}129130const content = fs.readFileSync(targetFile, 'utf-8');131const lines = content.split('\n');132133// Find the element, trying each query in priority order. When `--text` is134// supplied, collect every candidate the queries surface and disambiguate135// by the picked element's textContent. Without `--text`, fall back to the136// legacy first-match behavior so unmodified callers keep working.137let match = null;138if (text) {139const candidates = [];140for (const q of queries) {141const all = findAllElements(lines, q, tag);142for (const c of all) {143if (!candidates.some((x) => x.startLine === c.startLine)) {144candidates.push(c);145}146}147// Once a more-specific query (ID, full className combo) yielded a unique148// result, stop — falling through to the loose tag+single-class query149// would readmit the siblings we just disambiguated past.150if (candidates.length === 1) break;151}152if (candidates.length === 0) {153console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));154process.exit(1);155}156if (candidates.length === 1) {157match = candidates[0];158} else {159const filtered = filterByText(candidates, lines, text);160if (filtered.length === 1) {161match = filtered[0];162} else if (filtered.length === 0) {163// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the164// browser-side textContent doesn't appear literally in source. Fall165// back to first-match rather than refusing — this is the same166// behavior unmodified callers see, just preserved.167match = candidates[0];168} else {169// Multiple candidates ALSO match the text. Truly ambiguous — refuse170// rather than pick wrong, and hand the agent the candidate locations171// so it can disambiguate by reading the file.172console.error(JSON.stringify({173error: 'element_ambiguous',174fallback: 'agent-driven',175file: path.relative(process.cwd(), targetFile),176candidates: filtered.map((c) => ({177startLine: c.startLine + 1,178endLine: c.endLine + 1,179})),180hint: '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}));182process.exit(1);183}184}185} else {186for (const q of queries) {187match = findElement(lines, q, tag);188if (match) break;189}190if (!match) {191console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));192process.exit(1);193}194}195196const { startLine, endLine } = match;197const commentSyntax = detectCommentSyntax(targetFile);198const styleMode = detectStyleMode(targetFile);199const isJsx = commentSyntax.open === '{/*';200const indent = lines[startLine].match(/^(\s*)/)[1];201202// Extract the original element. Reindent under the wrapper while preserving203// the relative depth between lines — `l.trimStart()` would strip ALL leading204// 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 restores206// 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.209let originalLines = lines.slice(startLine, endLine + 1);210211// Buffer-aware "original" content: if the user has pending manual edits for212// this page whose originalText appears in the picked source range, apply213// them so the wrap block's "original" variant reflects what the user was214// looking at (their edited DOM), not the raw source. Source itself stays215// untouched here — only the wrap block's embedded "original" copy is216// 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 page221// do not block normal wrap work.222let pendingBuffer = { entries: [] };223try { pendingBuffer = readManualEditsBuffer(process.cwd()); } catch {}224const pendingEntriesForTarget = pageUrl225? []226: pendingEntriesThatMayAffectWrap(pendingBuffer.entries, targetFile, originalLines, startLine, process.cwd());227if (pendingEntriesForTarget.length > 0) {228console.error(JSON.stringify({229error: 'missing_page_url_with_pending_edits',230pendingEntries: pendingEntriesForTarget.length,231hint: '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}));233process.exit(1);234}235if (pageUrl) {236const failedBufferedOps = [];237for (const entry of pendingBuffer.entries || []) {238if (entry.pageUrl !== pageUrl) continue;239for (const op of entry.ops || []) {240const mayAffectWrap = manualEditMayAffectWrap(op, targetFile, originalLines, startLine, process.cwd());241const result = applyBufferedManualEditToLines(originalLines, startLine, op);242if (result.changed) {243originalLines = result.lines;244continue;245}246if (!mayAffectWrap) continue;247failedBufferedOps.push({248entryId: entry.id,249ref: op?.ref || null,250originalText: op?.originalText || null,251reason: 'ambiguous_or_unmatched_pending_edit',252});253}254}255if (failedBufferedOps.length > 0) {256console.error(JSON.stringify({257error: 'manual_edit_buffer_apply_failed',258pendingOps: failedBufferedOps,259hint: '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}));261process.exit(1);262}263}264265const originalBaseIndent = minLeadingSpaces(originalLines);266const reindentOriginal = (extra) => originalLines267.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))268.join('\n');269const originalIndented = reindentOriginal(' ');270const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');271const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);272273// Wrapper attributes differ by syntax. HTML allows plain string attrs;274// JSX requires object-literal style and parses string attrs as HTML (which275// either type-errors or renders a literal CSS string).276const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';277278// JSX/TSX guard: the picked element occupies a single JSX child slot279// (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or280// any other expression position). Replacing it with `comment + <div> +281// comment` yields three adjacent siblings — invalid JSX. We can't use a282// Fragment `<></>` either: parents that clone children (Radix `asChild`,283// Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when284// they try to pass an `id` through.285//286// Solution: keep the wrapper `<div>` as the single JSX-slot child and287// tuck both marker comments INSIDE it. accept/discard then expands its288// replacement range to include the wrapper's `<div>` open / close lines289// so the entire scaffold gets removed cleanly.290const wrapperLines = isJsx ? [291indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',292indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,293indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,294indent + ' <div data-impeccable-variant="original">',295reindentOriginal(' '),296indent + ' </div>',297indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,298indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,299indent + '</div>',300] : [301indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,302indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',303indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,304indent + ' <div data-impeccable-variant="original">',305originalIndented,306indent + ' </div>',307indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,308indent + '</div>',309indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,310];311312let outputFile = targetFile;313let outputLines;314let outputStartLine = startLine + 1;315let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);316let insertLine;317let svelteSession = null;318319if (useSvelteComponent) {320// Svelte/SvelteKit resets component-local state on markup HMR updates.321// Keep generation source-neutral: agents write real variant components322// under the generated componentDir, the browser mounts them into the live323// DOM, and live-accept.mjs inlines the accepted variant back into the route.324svelteSession = scaffoldSvelteComponentSession({325id,326count,327sourceFile: relTargetFile,328sourceStartLine: startLine + 1,329sourceEndLine: endLine + 1,330originalLines,331cwd: process.cwd(),332});333outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);334outputStartLine = 1;335outputEndLine = 1;336insertLine = 1;337} else {338// Replace the original element with the wrapper339const newLines = [340...lines.slice(0, startLine),341...wrapperLines,342...lines.slice(endLine + 1),343];344fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');345346// Calculate insert line (the "insert below this line" comment).347// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above348// 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 their352// extra line count.353insertLine = startLine + 6 + (originalLines.length - 1) + 1;354}355356const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');357358const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;359360console.log(JSON.stringify({361file: outputRelFile,362sourceFile: useSvelteComponent ? relTargetFile : undefined,363previewMode: useSvelteComponent ? 'svelte-component' : undefined,364componentDir: svelteSession?.componentDir,365propContract: svelteSession?.propContract,366sourceStartLine: useSvelteComponent ? startLine + 1 : undefined,367sourceEndLine: useSvelteComponent ? endLine + 1 : undefined,368startLine: outputStartLine, // 1-indexed for the agent369// 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 is371// wrapperLines.length + (originalLines.length - 1). Without the offset,372// endLine pointed inside the wrapper for any picked element that373// spanned more than one source line.374endLine: outputEndLine, // 1-indexed375insertLine, // 1-indexed: where variants go376commentSyntax: commentSyntax,377styleMode: useSvelteComponent ? 'svelte-component' : styleMode.mode,378styleTag: useSvelteComponent ? null : styleMode.styleTag,379cssSelectorPrefixExamples: useSvelteComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),380cssAuthoring: useSvelteComponent ? svelteComponentAuthoring : buildCssAuthoring(styleMode, count),381originalLineCount: originalLines.length,382}));383}384385// ---------------------------------------------------------------------------386// Helpers387// ---------------------------------------------------------------------------388389function argVal(args, flag) {390const prefix = flag + '=';391for (const arg of args) {392if (arg.startsWith(prefix)) return arg.slice(prefix.length);393}394const idx = args.indexOf(flag);395return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;396}397398function pendingEntriesThatMayAffectWrap(entries, targetFile, originalLines, selectionStartLine, cwd) {399const targetAbs = path.resolve(cwd, targetFile);400return (entries || []).filter((entry) => {401return (entry.ops || []).some((op) => {402return manualEditMayAffectWrap(op, targetAbs, originalLines, selectionStartLine, cwd);403});404});405}406407function manualEditMayAffectWrap(op, targetFile, originalLines, selectionStartLine, cwd) {408const targetAbs = path.resolve(cwd, targetFile);409if (manualEditHintFallsInsideSelection(op, targetAbs, originalLines, selectionStartLine, cwd)) return true;410if (manualEditLocatorMatchesSelection(op, originalLines)) return true;411if (typeof op?.originalText === 'string' && op.originalText.length > 0) {412return originalLines.join('\n').includes(op.originalText);413}414return false;415}416417function manualEditHintFallsInsideSelection(op, targetAbs, originalLines, selectionStartLine, cwd) {418const hintFile = op?.sourceHint?.file;419const hintedLine = Number(op?.sourceHint?.line);420if (!hintFile || !Number.isFinite(hintedLine)) return false;421const hintAbs = path.isAbsolute(hintFile) ? hintFile : path.resolve(cwd, hintFile);422if (path.resolve(hintAbs) !== targetAbs) return false;423const hintedIndex = hintedLine - 1 - selectionStartLine;424return hintedIndex >= 0425&& hintedIndex < originalLines.length426&& typeof op?.originalText === 'string'427&& originalLines[hintedIndex].includes(op.originalText);428}429430function manualEditLocatorMatchesSelection(op, originalLines) {431if (!op || typeof op.originalText !== 'string' || op.originalText.length === 0) return false;432return originalLines.some((line) => (433line.includes(op.originalText) && lineMatchesManualEditLocator(line, op)434));435}436437function applyBufferedManualEditToLines(originalLines, selectionStartLine, op) {438if (439!op440|| typeof op.originalText !== 'string'441|| op.originalText.length === 0442|| typeof op.newText !== 'string'443) {444return { lines: originalLines, changed: false };445}446447const replaceLine = (lineIndex) => ({448lines: originalLines.map((line, index) => (449index === lineIndex ? replaceOnce(line, op.originalText, op.newText) : line450)),451changed: true,452});453454const hintedLine = Number(op.sourceHint?.line);455if (Number.isFinite(hintedLine)) {456const hintedIndex = hintedLine - 1 - selectionStartLine;457if (hintedIndex >= 0 && hintedIndex < originalLines.length && originalLines[hintedIndex].includes(op.originalText)) {458return replaceLine(hintedIndex);459}460}461462const locatorMatches = [];463for (let index = 0; index < originalLines.length; index += 1) {464const line = originalLines[index];465if (!line.includes(op.originalText)) continue;466if (!lineMatchesManualEditLocator(line, op)) continue;467locatorMatches.push(index);468}469if (locatorMatches.length === 1) return replaceLine(locatorMatches[0]);470471const originalBlock = originalLines.join('\n');472if (countOccurrences(originalBlock, op.originalText) === 1) {473return {474lines: replaceOnce(originalBlock, op.originalText, op.newText).split('\n'),475changed: true,476};477}478479return { lines: originalLines, changed: false };480}481482function lineMatchesManualEditLocator(line, op) {483if (op.tag) {484const tagRe = new RegExp('<\\s*' + escapeRegExp(op.tag) + '(?=[\\s>/]|$)', 'i');485if (!tagRe.test(line)) return false;486}487488if (op.elementId) {489const id = escapeRegExp(op.elementId);490const idRe = new RegExp('\\bid\\s*=\\s*["\']' + id + '["\']');491if (!idRe.test(line)) return false;492}493494const classes = Array.isArray(op.classes) ? op.classes.filter(Boolean) : [];495for (const className of classes) {496if (!line.includes(className)) return false;497}498499return true;500}501502function replaceOnce(value, needle, replacement) {503const index = value.indexOf(needle);504if (index === -1) return value;505return value.slice(0, index) + replacement + value.slice(index + needle.length);506}507508function countOccurrences(value, needle) {509if (!needle) return 0;510let count = 0;511let index = 0;512while (true) {513index = value.indexOf(needle, index);514if (index === -1) return count;515count += 1;516index += needle.length;517}518}519520function escapeRegExp(value) {521return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');522}523524/**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*/528function buildSearchQueries(elementId, classes, tag, query) {529const queries = [];530531// 1. ID is the most specific532if (elementId) {533queries.push('id="' + elementId + '"');534}535536// 2. Full class attribute match (for elements with distinctive multi-class combos).537// Emit both class="..." (HTML) and className="..." (React/JSX) so whichever538// convention the file uses will match.539if (classes) {540const classList = splitClassList(classes);541if (classList.length > 1) {542const joined = classList.join(' ');543const sorted = [...classList].sort((a, b) => b.length - a.length);544queries.push('class="' + joined + '"');545queries.push('className="' + joined + '"');546for (const className of sorted) {547queries.push(className);548}549} else if (classList.length === 1) {550queries.push(classList[0]);551}552}553554// 3. Tag + class combo (e.g., <section class="hero">).555// Same dual-emit for JSX compatibility.556if (tag && classes) {557const firstClass = splitClassList(classes)[0];558queries.push('<' + tag + ' class="' + firstClass);559queries.push('<' + tag + ' className="' + firstClass);560}561562// 4. Raw fallback query563if (query) {564queries.push(query);565}566567return queries;568}569570function splitClassList(classes) {571return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean);572}573574function attrEscapeDouble(str) {575return String(str)576.replace(/&/g, '&')577.replace(/"/g, '"')578.replace(/</g, '<')579.replace(/>/g, '>');580}581582function detectCommentSyntax(filePath) {583const ext = path.extname(filePath).toLowerCase();584if (ext === '.jsx' || ext === '.tsx') {585return { open: '{/*', close: '*/}' };586}587// HTML, Vue, Svelte, Astro all use HTML comments588return { open: '<!--', close: '-->' };589}590591function detectStyleMode(filePath) {592const ext = path.extname(filePath).toLowerCase();593if (ext === '.astro') {594return {595mode: 'astro-global-prefixed',596styleTag: '<style is:inline data-impeccable-css="SESSION_ID">',597};598}599return {600mode: 'scoped',601styleTag: '<style data-impeccable-css="SESSION_ID">',602};603}604605function buildCssSelectorPrefixExamples(styleMode, count) {606if (styleMode !== 'astro-global-prefixed') return [];607return Array.from({ length: count }, (_, i) => `[data-impeccable-variant="${i + 1}"]`);608}609610function buildCssAuthoring(styleMode, count) {611const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);612if (styleMode.mode === 'astro-global-prefixed') {613return {614mode: styleMode.mode,615styleTag: styleMode.styleTag,616strategy: 'global-prefixed',617rulePattern: '[data-impeccable-variant="N"] > .variant-class { ... }',618selectorExamples: variantNumbers.map((n) => `[data-impeccable-variant="${n}"] > .variant-class`),619requirements: [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],625forbidden: [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}632return {633mode: styleMode.mode,634styleTag: styleMode.styleTag,635strategy: 'scope-rule',636rulePattern: '@scope ([data-impeccable-variant="N"]) { :scope > .variant-class { ... } }',637selectorExamples: variantNumbers.map((n) => `@scope ([data-impeccable-variant="${n}"]) { :scope > .variant-class { ... } }`),638requirements: [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],643forbidden: [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}649650/**651* Search project files for the query string (class name, ID, etc.)652* Returns the first matching file path, or null.653*/654function findFileWithQuery(query, cwd, genOpts = {}) {655const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];656const seen = new Set();657658for (const dir of searchDirs) {659const absDir = path.join(cwd, dir);660if (!fs.existsSync(absDir)) continue;661const result = searchDir(absDir, query, seen, 0, genOpts);662if (result) return result;663}664return null;665}666667function searchDir(dir, query, seen, depth, genOpts) {668if (depth > 5) return null; // don't go too deep669const realDir = fs.realpathSync(dir);670if (seen.has(realDir)) return null;671seen.add(realDir);672673let entries;674try { entries = fs.readdirSync(dir, { withFileTypes: true }); }675catch { return null; }676677// Check files first678for (const entry of entries) {679if (!entry.isFile()) continue;680const ext = path.extname(entry.name).toLowerCase();681if (!EXTENSIONS.includes(ext)) continue;682683const filePath = path.join(dir, entry.name);684if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue;685try {686const content = fs.readFileSync(filePath, 'utf-8');687if (content.includes(query)) return filePath;688} catch { /* skip unreadable files */ }689}690691// Then recurse into directories. Always skip node_modules and .git (never692// project content). dist/build/out are left to the isGeneratedFile guard so693// the includeGenerated second-pass can still find the element there and694// report `generatedMatch`.695for (const entry of entries) {696if (!entry.isDirectory()) continue;697if (entry.name === 'node_modules' || entry.name === '.git') continue;698const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts);699if (result) return result;700}701702return null;703}704705/**706* Regex that matches a tag opener on a line. Allows the tag name to be707* followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX708* openers (e.g. `<section\n className="..."\n>`) are recognised.709*/710const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;711712/**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 a717* continuation line of a multi-line tag (e.g. the `className="..."` row of a718* `<section\n className="..."\n>` JSX tag), we walk backward from the match719* line to find the actual tag opener. When `tag` is provided, opener candidates720* 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 compute725* the common base indent of a multi-line picked element so reindenting726* under the wrapper preserves the relative depth between lines.727*/728function minLeadingSpaces(lines) {729let min = Infinity;730for (const l of lines) {731if (l.trim() === '') continue;732const m = l.match(/^(\s*)/);733if (m && m[1].length < min) min = m[1].length;734}735return min === Infinity ? 0 : min;736}737738function findElement(lines, query, tag = null) {739// Iterate all matches — the first substring hit isn't always the right one.740for (let i = 0; i < lines.length; i++) {741if (!lines[i].includes(query)) continue;742743const stripped = lines[i].trim();744if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;745// Skip lines already inside a variant wrapper746if (lines[i].includes('data-impeccable-variant')) continue;747748const openerLine = findOpenerLine(lines, i, tag);749if (openerLine === -1) continue;750751const endLine = findClosingLine(lines, openerLine);752return { startLine: openerLine, endLine };753}754755return null;756}757758/**759* Like findElement, but returns every match. Used for ambiguity detection760* when the agent passes --text: when the same className appears on multiple761* sibling elements (a list of cards, repeated section variants, etc.),762* first-match silently lands on the wrong branch. Returning all matches lets763* the caller narrow by textContent or fail with a structured ambiguity error.764*/765function findAllElements(lines, query, tag = null) {766const out = [];767const seen = new Set();768for (let i = 0; i < lines.length; i++) {769if (!lines[i].includes(query)) continue;770const stripped = lines[i].trim();771if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;772if (lines[i].includes('data-impeccable-variant')) continue;773const openerLine = findOpenerLine(lines, i, tag);774if (openerLine === -1) continue;775if (seen.has(openerLine)) continue; // multiple matches inside the same element776seen.add(openerLine);777const endLine = findClosingLine(lines, openerLine);778out.push({ startLine: openerLine, endLine });779}780return out;781}782783/**784* Narrow a candidate set to those whose source body matches a meaningful785* prefix of the picked element's textContent. The compare strips tags and786* 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 without792* inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as793* `"Hero TwoSecond…"`), while the source has whitespace between tags. If794* EITHER normalization matches, the candidate keeps. A snippet shorter than795* 8 chars after stripping is too weak to disambiguate — the caller falls796* back to first-match.797*/798function filterByText(candidates, lines, text) {799const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);800// Too short to disambiguate. Return [] so the caller's `filtered.length801// === 0` branch fires (fall back to first-match) — the previous802// `candidates.slice()` return forced `filtered.length > 1` and surfaced803// a spurious `element_ambiguous` error on every short-text picker event804// with multiple candidates.805if (trimmed.length < 8) return [];806const targetSpaced = trimmed;807const targetCompact = trimmed.replace(/\s+/g, '');808809return candidates.filter((c) => {810const body = lines.slice(c.startLine, c.endLine + 1).join(' ');811const inner = body812.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags813.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions814.toLowerCase();815const sourceSpaced = inner.replace(/\s+/g, ' ').trim();816const sourceCompact = inner.replace(/\s+/g, '');817return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);818});819}820821/**822* Resolve a match line to the real tag opener. If the match line itself opens823* a tag, return it. Otherwise walk up to 10 lines backward looking for the824* first tag opener. If `tag` is specified, the opener must match that tag825* name; an opener with a different tag name aborts the backward walk for this826* 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*/830function findOpenerLine(lines, matchLine, tag) {831const self = lines[matchLine].match(OPENER_RE);832if (self) {833if (!tag || self[1] === tag) return matchLine;834return -1;835}836const MAX_BACKWALK = 10;837for (let i = matchLine - 1; i >= Math.max(0, matchLine - MAX_BACKWALK); i--) {838const opener = lines[i].match(OPENER_RE);839if (!opener) continue;840if (!tag || opener[1] === tag) return i;841// Different tag name than requested — abort; we're inside a non-target opener.842return -1;843}844return -1;845}846847/**848* Starting from a line with an opening tag, find the line with the matching849* closing tag by counting tag nesting depth.850*/851function findClosingLine(lines, start) {852const openMatch = lines[start].match(OPENER_RE);853if (!openMatch) return start; // caller passed a non-opener; nothing to span854855const tagName = openMatch[1];856let depth = 0;857const openRe = new RegExp('<' + tagName + '(?=[\\s/>]|$)', 'g');858const selfCloseRe = new RegExp('<' + tagName + '[^>]*/>', 'g');859const closeRe = new RegExp('</' + tagName + '\\s*>', 'g');860861for (let i = start; i < lines.length; i++) {862const line = lines[i];863const opens = (line.match(openRe) || []).length;864const selfCloses = (line.match(selfCloseRe) || []).length;865const closes = (line.match(closeRe) || []).length;866867depth += opens - selfCloses - closes;868869if (depth <= 0) return i;870}871872// If we can't find the close, return a reasonable guess873return Math.min(start + 50, lines.length - 1);874}875876// Auto-execute when run directly (node live-wrap.mjs ...)877const _running = process.argv[1];878if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) {879wrapCli();880}881882// Test exports (used by tests/live-wrap.test.mjs)883export {884buildSearchQueries,885findElement,886findClosingLine,887detectCommentSyntax,888findAllElements,889filterByText,890findFileWithQuery,891detectStyleMode,892buildCssAuthoring,893buildCssSelectorPrefixExamples,894};895