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 './is-generated.mjs';1718const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];1920export async function wrapCli() {21const args = process.argv.slice(2);2223if (args.includes('--help') || args.includes('-h')) {24console.log(`Usage: impeccable wrap [options]2526Find an element in source and wrap it in a variant container.2728Required:29--id ID Session ID for the variant wrapper30--count N Number of expected variants (1-8)3132Element identification (at least one required):33--element-id ID HTML id attribute of the element34--classes A,B,C Comma-separated CSS class names35--tag TAG Tag name (div, section, etc.)36--query TEXT Fallback: raw text to search for3738Optional:39--file PATH Source file to search in (skips auto-detection)40--text TEXT Picked element's textContent. Used to disambiguate when41classes/tag match multiple sibling elements (e.g. a list42of <Card>s with the same className). Pass the first ~8043chars of event.element.textContent.44--help Show this help message4546Output (JSON):47{ file, startLine, endLine, insertLine, commentSyntax }4849The agent should insert variant HTML at insertLine.`);50process.exit(0);51}5253const id = argVal(args, '--id');54const count = parseInt(argVal(args, '--count') || '3');55const elementId = argVal(args, '--element-id');56const classes = argVal(args, '--classes');57const tag = argVal(args, '--tag');58const query = argVal(args, '--query');59const filePath = argVal(args, '--file');60const text = argVal(args, '--text');6162if (!id) { console.error('Missing --id'); process.exit(1); }63if (!elementId && !classes && !query) {64console.error('Need at least one of: --element-id, --classes, --query');65process.exit(1);66}6768// Build search queries in priority order (most specific first)69const queries = buildSearchQueries(elementId, classes, tag, query);7071const genOpts = { cwd: process.cwd() };7273// Find the source file. Generated files are excluded from auto-search so we74// don't silently write variants into a file the next build will wipe.75let targetFile = filePath;76let matchedQuery = null;77if (!targetFile) {78for (const q of queries) {79targetFile = findFileWithQuery(q, process.cwd(), genOpts);80if (targetFile) { matchedQuery = q; break; }81}82if (!targetFile) {83// Nothing in source. Did the element show up in a generated file? That84// tells the agent "fall back to the agent-driven flow" vs "element just85// doesn't exist in this project."86let generatedHit = null;87for (const q of queries) {88generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });89if (generatedHit) break;90}91if (generatedHit) {92console.error(JSON.stringify({93error: 'element_not_in_source',94fallback: 'agent-driven',95generatedMatch: path.relative(process.cwd(), generatedHit),96hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',97}));98} else {99console.error(JSON.stringify({100error: 'element_not_found',101fallback: 'agent-driven',102hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',103}));104}105process.exit(1);106}107} else {108if (isGeneratedFile(targetFile, genOpts)) {109console.error(JSON.stringify({110error: 'file_is_generated',111fallback: 'agent-driven',112file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),113hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',114}));115process.exit(1);116}117matchedQuery = queries[0];118}119120const content = fs.readFileSync(targetFile, 'utf-8');121const lines = content.split('\n');122123// Find the element, trying each query in priority order. When `--text` is124// supplied, collect every candidate the queries surface and disambiguate125// by the picked element's textContent. Without `--text`, fall back to the126// legacy first-match behavior so unmodified callers keep working.127let match = null;128if (text) {129const candidates = [];130for (const q of queries) {131const all = findAllElements(lines, q, tag);132for (const c of all) {133if (!candidates.some((x) => x.startLine === c.startLine)) {134candidates.push(c);135}136}137// Once a more-specific query (ID, full className combo) yielded a unique138// result, stop — falling through to the loose tag+single-class query139// would readmit the siblings we just disambiguated past.140if (candidates.length === 1) break;141}142if (candidates.length === 0) {143console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));144process.exit(1);145}146if (candidates.length === 1) {147match = candidates[0];148} else {149const filtered = filterByText(candidates, lines, text);150if (filtered.length === 1) {151match = filtered[0];152} else if (filtered.length === 0) {153// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the154// browser-side textContent doesn't appear literally in source. Fall155// back to first-match rather than refusing — this is the same156// behavior unmodified callers see, just preserved.157match = candidates[0];158} else {159// Multiple candidates ALSO match the text. Truly ambiguous — refuse160// rather than pick wrong, and hand the agent the candidate locations161// so it can disambiguate by reading the file.162console.error(JSON.stringify({163error: 'element_ambiguous',164fallback: 'agent-driven',165file: path.relative(process.cwd(), targetFile),166candidates: filtered.map((c) => ({167startLine: c.startLine + 1,168endLine: c.endLine + 1,169})),170hint: '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}));172process.exit(1);173}174}175} else {176for (const q of queries) {177match = findElement(lines, q, tag);178if (match) break;179}180if (!match) {181console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));182process.exit(1);183}184}185186const { startLine, endLine } = match;187const commentSyntax = detectCommentSyntax(targetFile);188const styleMode = detectStyleMode(targetFile);189const isJsx = commentSyntax.open === '{/*';190const indent = lines[startLine].match(/^(\s*)/)[1];191192// Extract the original element. Reindent under the wrapper while preserving193// the relative depth between lines — `l.trimStart()` would strip ALL leading194// 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 restores196// 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.199const originalLines = lines.slice(startLine, endLine + 1);200const originalBaseIndent = minLeadingSpaces(originalLines);201const reindentOriginal = (extra) => originalLines202.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))203.join('\n');204const originalIndented = reindentOriginal(' ');205206// Wrapper attributes differ by syntax. HTML allows plain string attrs;207// JSX requires object-literal style and parses string attrs as HTML (which208// either type-errors or renders a literal CSS string).209const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';210211// JSX/TSX guard: the picked element occupies a single JSX child slot212// (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or213// any other expression position). Replacing it with `comment + <div> +214// comment` yields three adjacent siblings — invalid JSX. We can't use a215// Fragment `<></>` either: parents that clone children (Radix `asChild`,216// Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when217// they try to pass an `id` through.218//219// Solution: keep the wrapper `<div>` as the single JSX-slot child and220// tuck both marker comments INSIDE it. accept/discard then expands its221// replacement range to include the wrapper's `<div>` open / close lines222// so the entire scaffold gets removed cleanly.223const wrapperLines = isJsx ? [224indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',225indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,226indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,227indent + ' <div data-impeccable-variant="original">',228reindentOriginal(' '),229indent + ' </div>',230indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,231indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,232indent + '</div>',233] : [234indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,235indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',236indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,237indent + ' <div data-impeccable-variant="original">',238originalIndented,239indent + ' </div>',240indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,241indent + '</div>',242indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,243];244245// Replace the original element with the wrapper246const newLines = [247...lines.slice(0, startLine),248...wrapperLines,249...lines.slice(endLine + 1),250];251fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');252253// Calculate insert line (the "insert below this line" comment).254// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above255// 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 their259// extra line count.260const insertLine = startLine + 6 + (originalLines.length - 1);261262console.log(JSON.stringify({263file: path.relative(process.cwd(), targetFile),264startLine: startLine + 1, // 1-indexed for the agent265// 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 is267// wrapperLines.length + (originalLines.length - 1). Without the offset,268// endLine pointed inside the wrapper for any picked element that269// spanned more than one source line.270endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed271insertLine: insertLine + 1, // 1-indexed: where variants go272commentSyntax: commentSyntax,273styleMode: styleMode.mode,274styleTag: styleMode.styleTag,275cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count),276cssAuthoring: buildCssAuthoring(styleMode, count),277originalLineCount: originalLines.length,278}));279}280281// ---------------------------------------------------------------------------282// Helpers283// ---------------------------------------------------------------------------284285function argVal(args, flag) {286const idx = args.indexOf(flag);287return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;288}289290/**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) {295const queries = [];296297// 1. ID is the most specific298if (elementId) {299queries.push('id="' + elementId + '"');300}301302// 2. Full class attribute match (for elements with distinctive multi-class combos).303// Emit both class="..." (HTML) and className="..." (React/JSX) so whichever304// convention the file uses will match.305if (classes) {306const classList = classes.split(',').map(c => c.trim()).filter(Boolean);307if (classList.length > 1) {308const joined = classList.join(' ');309const sorted = [...classList].sort((a, b) => b.length - a.length);310queries.push('class="' + joined + '"');311queries.push('className="' + joined + '"');312queries.push(sorted[0]); // most distinctive single class, fallback313} else if (classList.length === 1) {314queries.push(classList[0]);315}316}317318// 3. Tag + class combo (e.g., <section class="hero">).319// Same dual-emit for JSX compatibility.320if (tag && classes) {321const firstClass = classes.split(',')[0].trim();322queries.push('<' + tag + ' class="' + firstClass);323queries.push('<' + tag + ' className="' + firstClass);324}325326// 4. Raw fallback query327if (query) {328queries.push(query);329}330331return queries;332}333334function detectCommentSyntax(filePath) {335const ext = path.extname(filePath).toLowerCase();336if (ext === '.jsx' || ext === '.tsx') {337return { open: '{/*', close: '*/}' };338}339// HTML, Vue, Svelte, Astro all use HTML comments340return { open: '<!--', close: '-->' };341}342343function detectStyleMode(filePath) {344const ext = path.extname(filePath).toLowerCase();345if (ext === '.astro') {346return {347mode: 'astro-global-prefixed',348styleTag: '<style is:inline data-impeccable-css="SESSION_ID">',349};350}351return {352mode: 'scoped',353styleTag: '<style data-impeccable-css="SESSION_ID">',354};355}356357function buildCssSelectorPrefixExamples(styleMode, count) {358if (styleMode !== 'astro-global-prefixed') return [];359return Array.from({ length: count }, (_, i) => `[data-impeccable-variant="${i + 1}"]`);360}361362function buildCssAuthoring(styleMode, count) {363const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);364if (styleMode.mode === 'astro-global-prefixed') {365return {366mode: styleMode.mode,367styleTag: styleMode.styleTag,368strategy: 'global-prefixed',369rulePattern: '[data-impeccable-variant="N"] > .variant-class { ... }',370selectorExamples: variantNumbers.map((n) => `[data-impeccable-variant="${n}"] > .variant-class`),371requirements: [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],376forbidden: [377'Do not use @scope for this styleMode.',378],379};380}381return {382mode: styleMode.mode,383styleTag: styleMode.styleTag,384strategy: 'scope-rule',385rulePattern: '@scope ([data-impeccable-variant="N"]) { :scope > .variant-class { ... } }',386selectorExamples: variantNumbers.map((n) => `@scope ([data-impeccable-variant="${n}"]) { :scope > .variant-class { ... } }`),387requirements: [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],392forbidden: [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}398399/**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 = {}) {404const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];405const seen = new Set();406407for (const dir of searchDirs) {408const absDir = path.join(cwd, dir);409if (!fs.existsSync(absDir)) continue;410const result = searchDir(absDir, query, seen, 0, genOpts);411if (result) return result;412}413return null;414}415416function searchDir(dir, query, seen, depth, genOpts) {417if (depth > 5) return null; // don't go too deep418const realDir = fs.realpathSync(dir);419if (seen.has(realDir)) return null;420seen.add(realDir);421422let entries;423try { entries = fs.readdirSync(dir, { withFileTypes: true }); }424catch { return null; }425426// Check files first427for (const entry of entries) {428if (!entry.isFile()) continue;429const ext = path.extname(entry.name).toLowerCase();430if (!EXTENSIONS.includes(ext)) continue;431432const filePath = path.join(dir, entry.name);433if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue;434try {435const content = fs.readFileSync(filePath, 'utf-8');436if (content.includes(query)) return filePath;437} catch { /* skip unreadable files */ }438}439440// Then recurse into directories. Always skip node_modules and .git (never441// project content). dist/build/out are left to the isGeneratedFile guard so442// the includeGenerated second-pass can still find the element there and443// report `generatedMatch`.444for (const entry of entries) {445if (!entry.isDirectory()) continue;446if (entry.name === 'node_modules' || entry.name === '.git') continue;447const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts);448if (result) return result;449}450451return null;452}453454/**455* Regex that matches a tag opener on a line. Allows the tag name to be456* followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX457* openers (e.g. `<section\n className="..."\n>`) are recognised.458*/459const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;460461/**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 a466* continuation line of a multi-line tag (e.g. the `className="..."` row of a467* `<section\n className="..."\n>` JSX tag), we walk backward from the match468* line to find the actual tag opener. When `tag` is provided, opener candidates469* 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 compute474* the common base indent of a multi-line picked element so reindenting475* under the wrapper preserves the relative depth between lines.476*/477function minLeadingSpaces(lines) {478let min = Infinity;479for (const l of lines) {480if (l.trim() === '') continue;481const m = l.match(/^(\s*)/);482if (m && m[1].length < min) min = m[1].length;483}484return min === Infinity ? 0 : min;485}486487function findElement(lines, query, tag = null) {488// Iterate all matches — the first substring hit isn't always the right one.489for (let i = 0; i < lines.length; i++) {490if (!lines[i].includes(query)) continue;491492const stripped = lines[i].trim();493if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;494// Skip lines already inside a variant wrapper495if (lines[i].includes('data-impeccable-variant')) continue;496497const openerLine = findOpenerLine(lines, i, tag);498if (openerLine === -1) continue;499500const endLine = findClosingLine(lines, openerLine);501return { startLine: openerLine, endLine };502}503504return null;505}506507/**508* Like findElement, but returns every match. Used for ambiguity detection509* when the agent passes --text: when the same className appears on multiple510* sibling elements (a list of cards, repeated section variants, etc.),511* first-match silently lands on the wrong branch. Returning all matches lets512* the caller narrow by textContent or fail with a structured ambiguity error.513*/514function findAllElements(lines, query, tag = null) {515const out = [];516const seen = new Set();517for (let i = 0; i < lines.length; i++) {518if (!lines[i].includes(query)) continue;519const stripped = lines[i].trim();520if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;521if (lines[i].includes('data-impeccable-variant')) continue;522const openerLine = findOpenerLine(lines, i, tag);523if (openerLine === -1) continue;524if (seen.has(openerLine)) continue; // multiple matches inside the same element525seen.add(openerLine);526const endLine = findClosingLine(lines, openerLine);527out.push({ startLine: openerLine, endLine });528}529return out;530}531532/**533* Narrow a candidate set to those whose source body matches a meaningful534* prefix of the picked element's textContent. The compare strips tags and535* 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 without541* inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as542* `"Hero TwoSecond…"`), while the source has whitespace between tags. If543* EITHER normalization matches, the candidate keeps. A snippet shorter than544* 8 chars after stripping is too weak to disambiguate — the caller falls545* back to first-match.546*/547function filterByText(candidates, lines, text) {548const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);549// Too short to disambiguate. Return [] so the caller's `filtered.length550// === 0` branch fires (fall back to first-match) — the previous551// `candidates.slice()` return forced `filtered.length > 1` and surfaced552// a spurious `element_ambiguous` error on every short-text picker event553// with multiple candidates.554if (trimmed.length < 8) return [];555const targetSpaced = trimmed;556const targetCompact = trimmed.replace(/\s+/g, '');557558return candidates.filter((c) => {559const body = lines.slice(c.startLine, c.endLine + 1).join(' ');560const inner = body561.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags562.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions563.toLowerCase();564const sourceSpaced = inner.replace(/\s+/g, ' ').trim();565const sourceCompact = inner.replace(/\s+/g, '');566return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);567});568}569570/**571* Resolve a match line to the real tag opener. If the match line itself opens572* a tag, return it. Otherwise walk up to 10 lines backward looking for the573* first tag opener. If `tag` is specified, the opener must match that tag574* name; an opener with a different tag name aborts the backward walk for this575* 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) {580const self = lines[matchLine].match(OPENER_RE);581if (self) {582if (!tag || self[1] === tag) return matchLine;583return -1;584}585const MAX_BACKWALK = 10;586for (let i = matchLine - 1; i >= Math.max(0, matchLine - MAX_BACKWALK); i--) {587const opener = lines[i].match(OPENER_RE);588if (!opener) continue;589if (!tag || opener[1] === tag) return i;590// Different tag name than requested — abort; we're inside a non-target opener.591return -1;592}593return -1;594}595596/**597* Starting from a line with an opening tag, find the line with the matching598* closing tag by counting tag nesting depth.599*/600function findClosingLine(lines, start) {601const openMatch = lines[start].match(OPENER_RE);602if (!openMatch) return start; // caller passed a non-opener; nothing to span603604const tagName = openMatch[1];605let depth = 0;606const openRe = new RegExp('<' + tagName + '(?=[\\s/>]|$)', 'g');607const selfCloseRe = new RegExp('<' + tagName + '[^>]*/>', 'g');608const closeRe = new RegExp('</' + tagName + '\\s*>', 'g');609610for (let i = start; i < lines.length; i++) {611const line = lines[i];612const opens = (line.match(openRe) || []).length;613const selfCloses = (line.match(selfCloseRe) || []).length;614const closes = (line.match(closeRe) || []).length;615616depth += opens - selfCloses - closes;617618if (depth <= 0) return i;619}620621// If we can't find the close, return a reasonable guess622return Math.min(start + 50, lines.length - 1);623}624625// 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/')) {628wrapCli();629}630631// Test exports (used by tests/live-wrap.test.mjs)632export { buildSearchQueries, findElement, findClosingLine, detectCommentSyntax };633