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-accept.mjs
1/**2* CLI helper: deterministic accept/discard of variant sessions.3*4* Usage:5* node live-accept.mjs --id SESSION_ID --discard6* node live-accept.mjs --id SESSION_ID --variant N7*8* For discard: removes the entire variant wrapper and restores the original.9* For accept: replaces the wrapper with the chosen variant's content. If the10* session had a colocated <style> block, it's preserved with carbonize markers11* for a background agent to integrate into the project's CSS.12*13* Output: JSON to stdout.14*/1516import fs from 'node:fs';17import path from 'node:path';18import { isGeneratedFile } from './lib/is-generated.mjs';19import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';20import {21applyDeferredSvelteComponentAccepts,22findSvelteComponentManifest,23inlineSvelteComponentAccept,24removeSvelteComponentSession,25} from './live/svelte-component.mjs';2627const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];2829// ---------------------------------------------------------------------------30// CLI31// ---------------------------------------------------------------------------3233export async function acceptCli() {34const args = process.argv.slice(2);3536if (args.includes('--help') || args.includes('-h')) {37console.log(`Usage: node live-accept.mjs [options]3839Deterministic accept/discard for live variant sessions.4041Modes:42--discard Remove variants, restore original43--variant N Accept variant N, discard the rest4445Required:46--id SESSION_ID Session ID of the variant wrapper4748Options:49--page-url URL Current browser page URL; scopes staged copy-edit cleanup50--defer-source-write51Deprecated compatibility flag. Svelte component accepts52now write the real source immediately.5354Output (JSON):55{ handled, file, carbonize }`);56process.exit(0);57}5859const id = argVal(args, '--id');60const variantNum = argVal(args, '--variant');61const paramValuesRaw = argVal(args, '--param-values');62const pageUrl = argVal(args, '--page-url');63const isDiscard = args.includes('--discard');6465if (!id) { console.error('Missing --id'); process.exit(1); }66if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }6768let paramValues = null;69if (paramValuesRaw) {70try { paramValues = JSON.parse(paramValuesRaw); }71catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept72}7374// Find the file containing this session's markers75const found = findSessionFile(id, process.cwd());76const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());7778if (!found && !svelteComponentManifest) {79console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));80process.exit(0);81}8283if (svelteComponentManifest) {84if (isDiscard) {85removeSvelteComponentSession(id, process.cwd());86console.log(JSON.stringify({87handled: true,88file: svelteComponentManifest.sourceFile,89carbonize: false,90previewMode: 'svelte-component',91componentDir: svelteComponentManifest.componentDir,92}));93return;94}9596let result;97try {98result = inlineSvelteComponentAccept(99svelteComponentManifest,100variantNum,101paramValues,102process.cwd(),103);104} catch (err) {105result = {106handled: false,107error: err.message,108file: svelteComponentManifest.sourceFile,109sourceFile: svelteComponentManifest.sourceFile,110previewMode: 'svelte-component',111componentDir: svelteComponentManifest.componentDir,112};113}114if (result.carbonize) {115result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';116}117console.log(JSON.stringify({ handled: result.handled !== false, ...result }));118return;119}120121const { file: targetFile, content, lines } = found;122const relFile = path.relative(process.cwd(), targetFile);123const previewBlock = findMarkerBlock(id, lines);124const sourceShadowPreview = previewBlock125? readSourceShadowPreviewMeta(content, id)126: null;127128if (sourceShadowPreview) {129console.log(JSON.stringify({130handled: false,131error: 'source_shadow_preview_deprecated',132hint: 'Svelte live mode now uses svelte-component injection. Re-wrap the element and regenerate variants.',133}));134process.exit(0);135}136137if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {138console.log(JSON.stringify({139handled: false,140mode: 'fallback',141file: relFile,142hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',143}));144process.exit(0);145}146147if (isDiscard) {148const result = handleDiscard(id, lines, targetFile);149console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));150} else {151const result = handleAccept(id, variantNum, lines, targetFile, paramValues);152const acceptedOriginalText = result.acceptedOriginalText || '';153delete result.acceptedOriginalText;154// Single-line attention-grabber when cleanup is required. The full155// five-step checklist lives in reference/live.md (loaded once per156// session); repeating it per-event would waste tokens.157if (result.carbonize) {158result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".';159}160// Scrub stash entries whose text appeared inside the just-replaced161// original wrap block. The accept embodies those manual edits (wrap was162// buffer-aware), so only those scoped ops are redundant.163if (result.handled !== false) {164try {165scrubManualEditsAgainstOriginalBlock(acceptedOriginalText, process.cwd(), pageUrl);166} catch {167// Non-fatal; the buffer stays as-is and the user can discard later.168}169}170console.log(JSON.stringify({ handled: true, file: relFile, ...result }));171}172}173174/**175* After a variant accept rewrites one wrapper, drop only buffer ops whose176* text appeared inside that wrapper's original block. The previous file-wide177* scrub dropped unrelated staged edits from other components/files whenever178* their originalText wasn't present in the just-accepted file.179*180* Match both originalText and newText because live-wrap rewrites the original181* preview block to reflect pending manual edits before variants are generated.182*/183function scrubManualEditsAgainstOriginalBlock(originalBlockText, cwd = process.cwd(), pageUrl = null) {184const originalBlock = String(originalBlockText || '');185if (!originalBlock) return;186if (!pageUrl) return;187const buffer = readManualEditsBuffer(cwd);188if (buffer.entries.length === 0) return;189let mutated = false;190for (const entry of buffer.entries) {191if (entry.pageUrl !== pageUrl) continue;192const before = entry.ops.length;193entry.ops = entry.ops.filter((op) => {194return !manualEditOpAppearsInBlock(op, originalBlock);195});196if (entry.ops.length !== before) mutated = true;197}198buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0);199if (mutated) writeManualEditsBuffer(cwd, buffer);200}201202function manualEditOpAppearsInBlock(op, originalBlock) {203const candidates = [op?.newText, op?.originalText]204.filter((text) => typeof text === 'string' && text.length > 0);205return candidates.some((text) => originalBlockHasExactManualText(originalBlock, text));206}207208function originalBlockHasExactManualText(originalBlock, text) {209const needle = normalizeManualEditText(text);210if (!needle) return false;211return manualEditTextSegments(originalBlock).some((segment) => segment === needle);212}213214function manualEditTextSegments(source) {215return String(source || '')216.replace(/<[^>]*>/g, '\n')217.replace(/\{\/\*[\s\S]*?\*\/\}/g, '\n')218.replace(/<!--[\s\S]*?-->/g, '\n')219.split(/\n+/)220.map(normalizeManualEditText)221.filter(Boolean);222}223224function normalizeManualEditText(text) {225return String(text || '').replace(/\s+/g, ' ').trim();226}227228// Compatibility export for older tests/callers. The unsafe file-wide scrub was229// removed; callers must pass accepted original-block text for scoped cleanup.230function scrubManualEditsAgainstFile(_targetFile, cwd = process.cwd(), originalBlockText = '', pageUrl = null) {231return scrubManualEditsAgainstOriginalBlock(originalBlockText, cwd, pageUrl);232}233234// ---------------------------------------------------------------------------235// Discard236// ---------------------------------------------------------------------------237238function handleDiscard(id, lines, targetFile) {239const block = findMarkerBlock(id, lines);240if (!block) return { handled: false, error: 'Markers not found' };241242const original = extractOriginal(lines, block);243const isJsx = detectCommentSyntax(targetFile).open === '{/*';244const replaceRange = expandReplaceRange(block, lines, isJsx);245246// Restore at the line we're actually replacing FROM, not the marker line.247// For JSX wrappers the marker comments live INSIDE the outer `<div>`, so248// `block.start` sits 2 spaces deeper than the original element. Using that249// as the deindent base would push the restored content 2 spaces too far250// right on every JSX/TSX session. `replaceRange.start` is the outer wrapper251// line, which is at the original element's indent for both HTML and JSX.252const indent = lines[replaceRange.start].match(/^(\s*)/)[1];253const restored = deindentContent(original, indent);254255const newLines = [256...lines.slice(0, replaceRange.start),257...restored,258...lines.slice(replaceRange.end + 1),259];260fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');261return {};262}263264// ---------------------------------------------------------------------------265// Accept266// ---------------------------------------------------------------------------267268/**269* Build carbonize stitch-in lines. JSX targets occupy a single child slot270* (ternary branch, return value, etc.) — the same constraint as live-wrap.271* When isJsx, tuck markers + <style> + variant wrapper inside one outer272* <div data-impeccable-carbonize> so the slot keeps a single root node.273*/274function buildCarbonizeReplacement({275indent,276commentSyntax,277isJsx,278id,279variantNum,280cssContent,281paramValues,282restored,283}) {284const lines = [];285if (!cssContent) {286lines.push(...restored);287return lines;288}289290const variantStyleAttr = isJsx291? "style={{ display: 'contents' }}"292: 'style="display: contents"';293294const pushCarbonizeBody = (bodyIndent) => {295const bodyRestored = reindentContent(restored, indent, bodyIndent + ' ');296lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);297lines.push(bodyIndent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));298for (const cssLine of cssContent) {299lines.push(bodyIndent + cssLine.trimStart());300}301lines.push(bodyIndent + (isJsx ? '`}</style>' : '</style>'));302if (paramValues && Object.keys(paramValues).length > 0) {303lines.push(304bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close,305);306}307lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);308lines.push(bodyIndent + '<div data-impeccable-variant="' + variantNum + '" ' + variantStyleAttr + '>');309lines.push(...bodyRestored);310lines.push(bodyIndent + '</div>');311};312313if (isJsx) {314const wrapperStyle = 'style={{ display: "contents" }}';315lines.push(indent + '<div data-impeccable-carbonize="' + id + '" ' + wrapperStyle + '>');316pushCarbonizeBody(indent + ' ');317lines.push(indent + '</div>');318} else {319pushCarbonizeBody(indent);320}321322return lines;323}324325function reindentContent(contentLines, fromIndent, toIndent) {326return contentLines.map((line) => {327if (line.trim() === '') return '';328if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length);329return toIndent + line.trimStart();330});331}332333function handleAccept(id, variantNum, lines, targetFile, paramValues) {334const block = findMarkerBlock(id, lines);335if (!block) return { handled: false, error: 'Markers not found' };336337const commentSyntax = detectCommentSyntax(targetFile);338const isJsx = commentSyntax.open === '{/*';339// Anchor indent on the line we're replacing FROM (the outer wrapper),340// not on `block.start` — for JSX that's the marker comment 2 spaces341// deeper than the original element. See handleDiscard for the full342// rationale.343const replaceRange = expandReplaceRange(block, lines, isJsx);344const indent = lines[replaceRange.start].match(/^(\s*)/)[1];345346// Extract the chosen variant's inner content347const variantContent = extractVariant(lines, block, variantNum);348if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };349const originalContent = extractOriginal(lines, block);350351// Extract CSS block if present352const cssContent = extractCss(lines, block, id);353354// Check if carbonizing is needed:355// - CSS block exists, OR356// - variant HTML contains helper classes/attributes that need cleanup357const variantText = variantContent.join('\n');358const hasHelperAttrs = variantText.includes('data-impeccable-variant');359const needsCarbonize = !!(cssContent || hasHelperAttrs);360361const restored = deindentContent(variantContent, indent);362const replacement = buildCarbonizeReplacement({363indent,364commentSyntax,365isJsx,366id,367variantNum,368cssContent,369paramValues,370restored,371});372373const newLines = [374...lines.slice(0, replaceRange.start),375...replacement,376...lines.slice(replaceRange.end + 1),377];378fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');379380return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };381}382383function readSourceShadowPreviewMeta(content, id) {384const escaped = escapeRegExp(id);385const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');386const match = String(content || '').match(wrapperRe);387if (!match) return null;388const tag = match[0];389if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null;390const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file');391const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start'));392const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end'));393if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null;394return { sourceFile, sourceStartLine, sourceEndLine };395}396397function readHtmlAttr(tag, name) {398const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1'));399if (!match) return null;400return decodeHtmlAttr(match[2]);401}402403function decodeHtmlAttr(value) {404return String(value || '')405.replace(/"/g, '"')406.replace(/</g, '<')407.replace(/>/g, '>')408.replace(/&/g, '&');409}410411// ---------------------------------------------------------------------------412// Parsing helpers413// ---------------------------------------------------------------------------414415/**416* Find the start/end marker lines for a session.417* Returns { start, end } (0-indexed line numbers) or null.418*/419function findMarkerBlock(id, lines) {420let start = -1;421let end = -1;422const startPattern = 'impeccable-variants-start ' + id;423const endPattern = 'impeccable-variants-end ' + id;424425for (let i = 0; i < lines.length; i++) {426if (start === -1 && lines[i].includes(startPattern)) start = i;427if (lines[i].includes(endPattern)) { end = i; break; }428}429430return (start !== -1 && end !== -1) ? { start, end, id } : null;431}432433/**434* Compute the line range to REPLACE (vs. just the marker range to extract435* from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE436* the `<div data-impeccable-variants="ID">` outer wrapper so the picked437* element's JSX slot keeps a single child — a Fragment `<></>` would have438* solved the multi-sibling case but failed inside `asChild` / cloneElement439* parents with "Invalid prop supplied to React.Fragment".440*441* That means the marker block is enclosed by the wrapper `<div>` opener442* (with `data-impeccable-variants="ID"`) and its matching `</div>`. We443* walk back to the opener and forward to the closer so accept/discard444* remove the entire scaffold, not just the inner markers.445*446* Marker lines themselves stay where they were so extractOriginal /447* extractVariant / extractCss continue to walk the same range.448*/449function expandReplaceRange(block, lines, isJsx) {450if (!isJsx) return { start: block.start, end: block.end };451452let { start, end } = block;453454// Walk back for the wrapper `<div data-impeccable-variants="..."` opener.455// The attr may sit on a continuation line of a multi-line opening tag, so456// also walk to the line that actually contains `<div`.457for (let i = start - 1; i >= 0; i--) {458if (isVariantEndMarkerLine(lines[i], block.id)) break;459if (hasVariantWrapperAttr(lines[i], block.id)) {460let opener = i;461while (opener > 0 && !/<div\b/.test(lines[opener]) && !isVariantEndMarkerLine(lines[opener], block.id)) {462opener--;463}464if (/<div\b/.test(lines[opener])) start = opener;465break;466}467}468469// Walk forward to the matching `</div>` by div-depth tracking from the470// wrapper opener. Operate on JOINED text instead of per-line: a471// multi-line self-closing JSX `<div\n className="spacer"\n/>` would472// fool per-line regex tracking (the `<div` line matches openRe but the473// `/>` line never matches selfCloseRe since it needs `<div` on the same474// line). That left depth permanently over-counted and the wrapper's475// outer `</div>` orphaned after accept/discard. Single regex with476// `[^>]*?` (which spans newlines in JS) handles either form correctly.477const joined = lines.slice(start).join('\n');478// Match either `<div … />` (self-close, group 1 is `/`), `<div … >`479// (open, group 1 is empty), or `</div>`.480const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;481let depth = 0;482let m;483while ((m = tagRe.exec(joined)) !== null) {484const isClose = m[0].startsWith('</');485const isSelfClose = !isClose && m[1] === '/';486if (isClose) depth--;487else if (!isSelfClose) depth++;488if (depth <= 0) {489// m.index is offset within `joined`; convert back to a file line.490const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;491const candidateEnd = start + linesBefore;492if (candidateEnd >= end) {493end = candidateEnd;494break;495}496}497}498499return { start, end };500}501502function escapeRegExp(value) {503return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');504}505506function isVariantEndMarkerLine(line, id) {507return new RegExp('impeccable-variants-end\\s+' + escapeRegExp(id) + '(?:\\s|--|\\*/|$)').test(line);508}509510function hasVariantWrapperAttr(line, id) {511const escaped = escapeRegExp(id);512return new RegExp(`data-impeccable-variants\\s*=\\s*(?:"${escaped}"|'${escaped}'|\\{["']${escaped}["']\\})`).test(line);513}514515/**516* Join wrapper lines into a single string with `<style>` elements removed so517* marker matching and div-depth tracking aren't confused by:518* - CSS `@scope ([data-impeccable-variant="N"])` strings that look like the519* HTML marker we're searching for520* - JSX self-closing `<style ... />` (no separate `</style>` to close on)521* - Same-line `<style>…</style>` blocks522* - Multi-line `<style>\n…\n</style>` blocks523*/524function stripStyleAndJoin(lines, block) {525const out = [];526let inStyle = false;527for (let i = block.start; i <= block.end; i++) {528let line = lines[i];529530if (!inStyle) {531// Strip any complete <style> elements on this line (self-closed or532// same-line-closed), including their body content.533line = line534.replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/g, '')535.replace(/<style\b[^>]*\/\s*>/g, '');536537// If a <style> opener remains (multi-line body starts here), strip from538// the opener to end-of-line and flip into skip mode.539const openerIdx = line.search(/<style\b/);540if (openerIdx !== -1) {541line = line.slice(0, openerIdx);542inStyle = true;543}544out.push(line);545} else {546// In multi-line style body; drop everything until we see </style>.547const closeIdx = line.search(/<\/style\s*>/);548if (closeIdx !== -1) {549inStyle = false;550out.push(line.slice(closeIdx).replace(/<\/style\s*>/, ''));551}552// else: skip line entirely553}554}555return out.join('\n');556}557558/**559* Find the inner content of `<TAG ...attrMatch...>…</TAG>` inside `text`,560* handling nested same-tag elements via depth counting. `attrMatch` is a561* regex source fragment that must appear inside the opener tag.562* Returns the inner string (may be empty), or null if not found.563*/564function extractInnerByAttr(text, attrMatch) {565const openerRe = new RegExp('<([A-Za-z][A-Za-z0-9]*)\\b[^>]*' + attrMatch + '[^>]*>');566const openMatch = text.match(openerRe);567if (!openMatch) return null;568569const tagName = openMatch[1];570const innerStart = openMatch.index + openMatch[0].length;571572// Match any opener or closer of this tag name after innerStart.573// (Does not match self-closing <TAG … />, which doesn't contribute to depth.)574const tagRe = new RegExp('<(?:/)?' + tagName + '\\b[^>]*>', 'g');575tagRe.lastIndex = innerStart;576577let depth = 1;578let m;579while ((m = tagRe.exec(text))) {580const isClose = m[0].startsWith('</');581const isSelfClose = !isClose && /\/\s*>$/.test(m[0]);582if (isClose) {583depth--;584if (depth === 0) return text.slice(innerStart, m.index);585} else if (!isSelfClose) {586depth++;587}588}589return null;590}591592/**593* Extract the original element content from within the variant wrapper.594* Returns an array of lines.595*/596function extractOriginal(lines, block) {597const text = stripStyleAndJoin(lines, block);598const inner = extractInnerByAttr(text, 'data-impeccable-variant="original"');599if (inner === null) return [];600return inner.split('\n');601}602603/**604* Extract a specific variant's inner content (stripping the wrapper div).605* Returns an array of lines, or null if not found.606*/607function extractVariant(lines, block, variantNum) {608const text = stripStyleAndJoin(lines, block);609const inner = extractInnerByAttr(text, 'data-impeccable-variant="' + variantNum + '"');610if (inner === null) return null;611const result = inner.split('\n');612// Collapse a lone empty leading/trailing line (common after string splice).613while (result.length > 1 && result[0].trim() === '') result.shift();614while (result.length > 1 && result[result.length - 1].trim() === '') result.pop();615return result.length > 0 ? result : null;616}617618/**619* Extract the colocated <style> block content (between the style tags).620* Returns an array of CSS lines, or null if no style block found.621*622* Handles three shapes of `<style data-impeccable-css="ID" ...>`:623* 1. Self-closing: `<style ... />` — no body; return null (nothing to carbonize).624* 2. Same-line open+close: `<style>...</style>` — return the inner content.625* 3. Multi-line: `<style>` on one line, `</style>` on a later line — return626* the lines between them.627*/628function extractCss(lines, block, id) {629const styleAttr = 'data-impeccable-css="' + id + '"';630let inStyle = false;631const content = [];632633for (let i = block.start; i <= block.end; i++) {634const line = lines[i];635636if (!inStyle && line.includes(styleAttr)) {637// Self-closing: nothing to carbonize.638if (/<style\b[^>]*\/\s*>/.test(line)) return null;639// Same-line open + close: extract inner text.640const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);641if (sameLine) {642const inner = stripJsxTemplateWrap(sameLine[1]);643return inner.length > 0 ? inner.split('\n') : null;644}645inStyle = true;646continue; // skip the <style> opening tag647}648649if (inStyle) {650// Detect </style> anywhere on the line — JSX template-literal closes651// (`}</style>`) put the close mid-line, and we don't want to absorb the652// template-literal punctuation as CSS content.653const closeIdx = line.indexOf('</style>');654if (closeIdx !== -1) break;655content.push(line);656}657}658659if (content.length === 0) return null;660return stripJsxTemplateLines(content);661}662663/**664* Strip a JSX template-literal wrap (`{` … `}`) from CSS extracted out of a665* `<style>` element in a JSX/TSX file. The agent may write the wrap with666* `{` and `}` directly attached to the `<style>` tags, on their own lines,667* or attached to the first/last CSS lines — all three are JSX-legal.668*669* Stripping is required because handleAccept re-wraps the CSS itself when670* carbonizing. Without this, two consecutive accepts (or a previously-671* accepted variants block being carbonized) would produce nested672* `{` `{` … `}` `}`, which oxc rejects with "Expected `}` but found `@`".673*/674function stripJsxTemplateLines(content) {675const out = content.slice();676677// Drop any leading blank lines so we don't miss a `{` line buried below678// them; same for trailing.679while (out.length > 0 && out[0].trim() === '') out.shift();680while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();681if (out.length === 0) return null;682683// Leading `{`: own line, or attached to the first CSS line.684const firstTrim = out[0].trimStart();685if (firstTrim === '{`') {686out.shift();687} else if (firstTrim.startsWith('{`')) {688const idx = out[0].indexOf('{`');689out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);690if (out[0].trim() === '') out.shift();691}692if (out.length === 0) return null;693694// Trailing `` ` `` `}`: own line, or attached to the last CSS line.695const lastIdx = out.length - 1;696const lastTrim = out[lastIdx].trimEnd();697if (lastTrim === '`}') {698out.pop();699} else if (lastTrim.endsWith('`}')) {700const text = out[lastIdx];701const idx = text.lastIndexOf('`}');702out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);703if (out[lastIdx].trim() === '') out.pop();704}705706return out.length > 0 ? out : null;707}708709function stripJsxTemplateWrap(text) {710const lines = text.split('\n');711const stripped = stripJsxTemplateLines(lines);712return stripped ? stripped.join('\n') : '';713}714715/**716* De-indent content that was indented by live-wrap.mjs.717* The wrap script adds `indent + ' '` (4 extra spaces) to each line.718* We restore to just `indent` level.719*/720function deindentContent(contentLines, baseIndent) {721// Find the minimum indentation in the content to determine how much was added722let minIndent = Infinity;723for (const line of contentLines) {724if (line.trim() === '') continue;725const leadingSpaces = line.match(/^(\s*)/)[1].length;726minIndent = Math.min(minIndent, leadingSpaces);727}728if (minIndent === Infinity) minIndent = 0;729730// Strip the extra indentation and re-add base indent731return contentLines.map(line => {732if (line.trim() === '') return '';733return baseIndent + line.slice(minIndent);734});735}736737function detectCommentSyntax(filePath) {738const ext = path.extname(filePath).toLowerCase();739if (ext === '.jsx' || ext === '.tsx') {740return { open: '{/*', close: '*/}' };741}742return { open: '<!--', close: '-->' };743}744745// ---------------------------------------------------------------------------746// File search (find the file containing session markers)747// ---------------------------------------------------------------------------748749function findSessionFile(id, cwd) {750const marker = 'impeccable-variants-start ' + id;751const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];752const seen = new Set();753754for (const dir of searchDirs) {755const absDir = path.join(cwd, dir);756if (!fs.existsSync(absDir)) continue;757const result = searchDir(absDir, marker, seen, 0);758if (result) {759const content = fs.readFileSync(result, 'utf-8');760return { file: result, content, lines: content.split('\n') };761}762}763return null;764}765766function searchDir(dir, query, seen, depth) {767if (depth > 5) return null;768let realDir;769try { realDir = fs.realpathSync(dir); } catch { return null; }770if (seen.has(realDir)) return null;771seen.add(realDir);772773let entries;774try { entries = fs.readdirSync(dir, { withFileTypes: true }); }775catch { return null; }776777for (const entry of entries) {778if (!entry.isFile()) continue;779if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;780const filePath = path.join(dir, entry.name);781try {782const content = fs.readFileSync(filePath, 'utf-8');783if (content.includes(query)) return filePath;784} catch { /* skip */ }785}786787for (const entry of entries) {788if (!entry.isDirectory()) continue;789if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;790const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);791if (result) return result;792}793794return null;795}796797// ---------------------------------------------------------------------------798// Utilities799// ---------------------------------------------------------------------------800801function argVal(args, flag) {802const idx = args.indexOf(flag);803return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;804}805806// Auto-execute when run directly807const _running = process.argv[1];808if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {809acceptCli();810}811812export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts };813