Author: Mike Swanson Machine: DESKTOP-0O8A1RL Timestamp: 2026-05-22 11:07:55
596 lines
22 KiB
JavaScript
596 lines
22 KiB
JavaScript
/**
|
|
* CLI helper: deterministic accept/discard of variant sessions.
|
|
*
|
|
* Usage:
|
|
* node live-accept.mjs --id SESSION_ID --discard
|
|
* node live-accept.mjs --id SESSION_ID --variant N
|
|
*
|
|
* For discard: removes the entire variant wrapper and restores the original.
|
|
* For accept: replaces the wrapper with the chosen variant's content. If the
|
|
* session had a colocated <style> block, it's preserved with carbonize markers
|
|
* for a background agent to integrate into the project's CSS.
|
|
*
|
|
* Output: JSON to stdout.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { isGeneratedFile } from './is-generated.mjs';
|
|
|
|
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function acceptCli() {
|
|
const args = process.argv.slice(2);
|
|
|
|
if (args.includes('--help') || args.includes('-h')) {
|
|
console.log(`Usage: node live-accept.mjs [options]
|
|
|
|
Deterministic accept/discard for live variant sessions.
|
|
|
|
Modes:
|
|
--discard Remove variants, restore original
|
|
--variant N Accept variant N, discard the rest
|
|
|
|
Required:
|
|
--id SESSION_ID Session ID of the variant wrapper
|
|
|
|
Output (JSON):
|
|
{ handled, file, carbonize }`);
|
|
process.exit(0);
|
|
}
|
|
|
|
const id = argVal(args, '--id');
|
|
const variantNum = argVal(args, '--variant');
|
|
const paramValuesRaw = argVal(args, '--param-values');
|
|
const isDiscard = args.includes('--discard');
|
|
|
|
if (!id) { console.error('Missing --id'); process.exit(1); }
|
|
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
|
|
|
|
let paramValues = null;
|
|
if (paramValuesRaw) {
|
|
try { paramValues = JSON.parse(paramValuesRaw); }
|
|
catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept
|
|
}
|
|
|
|
// Find the file containing this session's markers
|
|
const found = findSessionFile(id, process.cwd());
|
|
if (!found) {
|
|
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
|
|
process.exit(0);
|
|
}
|
|
|
|
const { file: targetFile, content, lines } = found;
|
|
const relFile = path.relative(process.cwd(), targetFile);
|
|
|
|
// Bail if the session lives in a generated file. The agent manually wrote
|
|
// the wrapper there for preview, and is responsible for writing the
|
|
// accepted variant to true source (or cleaning up on discard). See
|
|
// "Handle fallback" in live.md.
|
|
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
|
|
console.log(JSON.stringify({
|
|
handled: false,
|
|
mode: 'fallback',
|
|
file: relFile,
|
|
hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',
|
|
}));
|
|
process.exit(0);
|
|
}
|
|
|
|
if (isDiscard) {
|
|
const result = handleDiscard(id, lines, targetFile);
|
|
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
|
|
} else {
|
|
const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
|
|
// Single-line attention-grabber when cleanup is required. The full
|
|
// five-step checklist lives in reference/live.md (loaded once per
|
|
// session); repeating it per-event would waste tokens.
|
|
if (result.carbonize) {
|
|
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".';
|
|
}
|
|
console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Discard
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function handleDiscard(id, lines, targetFile) {
|
|
const block = findMarkerBlock(id, lines);
|
|
if (!block) return { handled: false, error: 'Markers not found' };
|
|
|
|
const original = extractOriginal(lines, block);
|
|
const isJsx = detectCommentSyntax(targetFile).open === '{/*';
|
|
const replaceRange = expandReplaceRange(block, lines, isJsx);
|
|
|
|
// Restore at the line we're actually replacing FROM, not the marker line.
|
|
// For JSX wrappers the marker comments live INSIDE the outer `<div>`, so
|
|
// `block.start` sits 2 spaces deeper than the original element. Using that
|
|
// as the deindent base would push the restored content 2 spaces too far
|
|
// right on every JSX/TSX session. `replaceRange.start` is the outer wrapper
|
|
// line, which is at the original element's indent for both HTML and JSX.
|
|
const indent = lines[replaceRange.start].match(/^(\s*)/)[1];
|
|
const restored = deindentContent(original, indent);
|
|
|
|
const newLines = [
|
|
...lines.slice(0, replaceRange.start),
|
|
...restored,
|
|
...lines.slice(replaceRange.end + 1),
|
|
];
|
|
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
|
return {};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Accept
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
|
const block = findMarkerBlock(id, lines);
|
|
if (!block) return { handled: false, error: 'Markers not found' };
|
|
|
|
const commentSyntax = detectCommentSyntax(targetFile);
|
|
const isJsx = commentSyntax.open === '{/*';
|
|
// Anchor indent on the line we're replacing FROM (the outer wrapper),
|
|
// not on `block.start` — for JSX that's the marker comment 2 spaces
|
|
// deeper than the original element. See handleDiscard for the full
|
|
// rationale.
|
|
const replaceRange = expandReplaceRange(block, lines, isJsx);
|
|
const indent = lines[replaceRange.start].match(/^(\s*)/)[1];
|
|
|
|
// Extract the chosen variant's inner content
|
|
const variantContent = extractVariant(lines, block, variantNum);
|
|
if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
|
|
|
|
// Extract CSS block if present
|
|
const cssContent = extractCss(lines, block, id);
|
|
|
|
// Check if carbonizing is needed:
|
|
// - CSS block exists, OR
|
|
// - variant HTML contains helper classes/attributes that need cleanup
|
|
const variantText = variantContent.join('\n');
|
|
const hasHelperAttrs = variantText.includes('data-impeccable-variant');
|
|
const needsCarbonize = !!(cssContent || hasHelperAttrs);
|
|
|
|
// Build the replacement
|
|
const restored = deindentContent(variantContent, indent);
|
|
const replacement = [];
|
|
|
|
if (cssContent) {
|
|
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
|
|
// JSX targets need the CSS body wrapped in a template literal so that the
|
|
// `{` and `}` in CSS rules don't get parsed as JSX expressions.
|
|
replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
|
|
// Re-indent CSS content to match
|
|
for (const cssLine of cssContent) {
|
|
replacement.push(indent + cssLine.trimStart());
|
|
}
|
|
replacement.push(indent + (isJsx ? '`}</style>' : '</style>'));
|
|
if (paramValues && Object.keys(paramValues).length > 0) {
|
|
// Preserve the user's knob positions for the carbonize-cleanup agent
|
|
// to bake into the final CSS when it collapses scoped rules.
|
|
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
|
|
}
|
|
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
|
|
}
|
|
|
|
// Keep the `@scope ([data-impeccable-variant="N"])` selectors in the
|
|
// carbonize CSS block working visually by re-wrapping the accepted content
|
|
// in a data-impeccable-variant="N" div with `display: contents` (so layout
|
|
// isn't affected). The carbonize agent strips this attribute + wrapper when
|
|
// it moves the CSS to a proper stylesheet.
|
|
//
|
|
// Style attribute syntax has to follow the host file's flavor — JSX files
|
|
// need the object form, otherwise React 19 throws "Failed to set indexed
|
|
// property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
|
|
if (cssContent) {
|
|
const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
|
|
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
|
|
replacement.push(...restored);
|
|
replacement.push(indent + '</div>');
|
|
} else {
|
|
replacement.push(...restored);
|
|
}
|
|
|
|
const newLines = [
|
|
...lines.slice(0, replaceRange.start),
|
|
...replacement,
|
|
...lines.slice(replaceRange.end + 1),
|
|
];
|
|
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
|
|
|
return { carbonize: needsCarbonize };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Parsing helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Find the start/end marker lines for a session.
|
|
* Returns { start, end } (0-indexed line numbers) or null.
|
|
*/
|
|
function findMarkerBlock(id, lines) {
|
|
let start = -1;
|
|
let end = -1;
|
|
const startPattern = 'impeccable-variants-start ' + id;
|
|
const endPattern = 'impeccable-variants-end ' + id;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (start === -1 && lines[i].includes(startPattern)) start = i;
|
|
if (lines[i].includes(endPattern)) { end = i; break; }
|
|
}
|
|
|
|
return (start !== -1 && end !== -1) ? { start, end } : null;
|
|
}
|
|
|
|
/**
|
|
* Compute the line range to REPLACE (vs. just the marker range to extract
|
|
* from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE
|
|
* the `<div data-impeccable-variants="ID">` outer wrapper so the picked
|
|
* element's JSX slot keeps a single child — a Fragment `<></>` would have
|
|
* solved the multi-sibling case but failed inside `asChild` / cloneElement
|
|
* parents with "Invalid prop supplied to React.Fragment".
|
|
*
|
|
* That means the marker block is enclosed by the wrapper `<div>` opener
|
|
* (with `data-impeccable-variants="ID"`) and its matching `</div>`. We
|
|
* walk back to the opener and forward to the closer so accept/discard
|
|
* remove the entire scaffold, not just the inner markers.
|
|
*
|
|
* Marker lines themselves stay where they were so extractOriginal /
|
|
* extractVariant / extractCss continue to walk the same range.
|
|
*/
|
|
function expandReplaceRange(block, lines, isJsx) {
|
|
if (!isJsx) return { start: block.start, end: block.end };
|
|
|
|
let { start, end } = block;
|
|
|
|
// Walk back for the wrapper `<div data-impeccable-variants="..."` opener.
|
|
// The attr may sit on a continuation line of a multi-line opening tag, so
|
|
// also walk to the line that actually contains `<div`.
|
|
for (let i = start - 1; i >= Math.max(0, start - 12); i--) {
|
|
if (/data-impeccable-variants=/.test(lines[i])) {
|
|
let opener = i;
|
|
while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;
|
|
start = opener;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Walk forward to the matching `</div>` by div-depth tracking from the
|
|
// wrapper opener. Operate on JOINED text instead of per-line: a
|
|
// multi-line self-closing JSX `<div\n className="spacer"\n/>` would
|
|
// fool per-line regex tracking (the `<div` line matches openRe but the
|
|
// `/>` line never matches selfCloseRe since it needs `<div` on the same
|
|
// line). That left depth permanently over-counted and the wrapper's
|
|
// outer `</div>` orphaned after accept/discard. Single regex with
|
|
// `[^>]*?` (which spans newlines in JS) handles either form correctly.
|
|
const joined = lines.slice(start).join('\n');
|
|
// Match either `<div … />` (self-close, group 1 is `/`), `<div … >`
|
|
// (open, group 1 is empty), or `</div>`.
|
|
const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
|
|
let depth = 0;
|
|
let m;
|
|
while ((m = tagRe.exec(joined)) !== null) {
|
|
const isClose = m[0].startsWith('</');
|
|
const isSelfClose = !isClose && m[1] === '/';
|
|
if (isClose) depth--;
|
|
else if (!isSelfClose) depth++;
|
|
if (depth <= 0) {
|
|
// m.index is offset within `joined`; convert back to a file line.
|
|
const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
|
|
const candidateEnd = start + linesBefore;
|
|
if (candidateEnd >= end) {
|
|
end = candidateEnd;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { start, end };
|
|
}
|
|
|
|
/**
|
|
* Join wrapper lines into a single string with `<style>` elements removed so
|
|
* marker matching and div-depth tracking aren't confused by:
|
|
* - CSS `@scope ([data-impeccable-variant="N"])` strings that look like the
|
|
* HTML marker we're searching for
|
|
* - JSX self-closing `<style ... />` (no separate `</style>` to close on)
|
|
* - Same-line `<style>…</style>` blocks
|
|
* - Multi-line `<style>\n…\n</style>` blocks
|
|
*/
|
|
function stripStyleAndJoin(lines, block) {
|
|
const out = [];
|
|
let inStyle = false;
|
|
for (let i = block.start; i <= block.end; i++) {
|
|
let line = lines[i];
|
|
|
|
if (!inStyle) {
|
|
// Strip any complete <style> elements on this line (self-closed or
|
|
// same-line-closed), including their body content.
|
|
line = line
|
|
.replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/g, '')
|
|
.replace(/<style\b[^>]*\/\s*>/g, '');
|
|
|
|
// If a <style> opener remains (multi-line body starts here), strip from
|
|
// the opener to end-of-line and flip into skip mode.
|
|
const openerIdx = line.search(/<style\b/);
|
|
if (openerIdx !== -1) {
|
|
line = line.slice(0, openerIdx);
|
|
inStyle = true;
|
|
}
|
|
out.push(line);
|
|
} else {
|
|
// In multi-line style body; drop everything until we see </style>.
|
|
const closeIdx = line.search(/<\/style\s*>/);
|
|
if (closeIdx !== -1) {
|
|
inStyle = false;
|
|
out.push(line.slice(closeIdx).replace(/<\/style\s*>/, ''));
|
|
}
|
|
// else: skip line entirely
|
|
}
|
|
}
|
|
return out.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Find the inner content of `<TAG ...attrMatch...>…</TAG>` inside `text`,
|
|
* handling nested same-tag elements via depth counting. `attrMatch` is a
|
|
* regex source fragment that must appear inside the opener tag.
|
|
* Returns the inner string (may be empty), or null if not found.
|
|
*/
|
|
function extractInnerByAttr(text, attrMatch) {
|
|
const openerRe = new RegExp('<([A-Za-z][A-Za-z0-9]*)\\b[^>]*' + attrMatch + '[^>]*>');
|
|
const openMatch = text.match(openerRe);
|
|
if (!openMatch) return null;
|
|
|
|
const tagName = openMatch[1];
|
|
const innerStart = openMatch.index + openMatch[0].length;
|
|
|
|
// Match any opener or closer of this tag name after innerStart.
|
|
// (Does not match self-closing <TAG … />, which doesn't contribute to depth.)
|
|
const tagRe = new RegExp('<(?:/)?' + tagName + '\\b[^>]*>', 'g');
|
|
tagRe.lastIndex = innerStart;
|
|
|
|
let depth = 1;
|
|
let m;
|
|
while ((m = tagRe.exec(text))) {
|
|
const isClose = m[0].startsWith('</');
|
|
const isSelfClose = !isClose && /\/\s*>$/.test(m[0]);
|
|
if (isClose) {
|
|
depth--;
|
|
if (depth === 0) return text.slice(innerStart, m.index);
|
|
} else if (!isSelfClose) {
|
|
depth++;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extract the original element content from within the variant wrapper.
|
|
* Returns an array of lines.
|
|
*/
|
|
function extractOriginal(lines, block) {
|
|
const text = stripStyleAndJoin(lines, block);
|
|
const inner = extractInnerByAttr(text, 'data-impeccable-variant="original"');
|
|
if (inner === null) return [];
|
|
return inner.split('\n');
|
|
}
|
|
|
|
/**
|
|
* Extract a specific variant's inner content (stripping the wrapper div).
|
|
* Returns an array of lines, or null if not found.
|
|
*/
|
|
function extractVariant(lines, block, variantNum) {
|
|
const text = stripStyleAndJoin(lines, block);
|
|
const inner = extractInnerByAttr(text, 'data-impeccable-variant="' + variantNum + '"');
|
|
if (inner === null) return null;
|
|
const result = inner.split('\n');
|
|
// Collapse a lone empty leading/trailing line (common after string splice).
|
|
while (result.length > 1 && result[0].trim() === '') result.shift();
|
|
while (result.length > 1 && result[result.length - 1].trim() === '') result.pop();
|
|
return result.length > 0 ? result : null;
|
|
}
|
|
|
|
/**
|
|
* Extract the colocated <style> block content (between the style tags).
|
|
* Returns an array of CSS lines, or null if no style block found.
|
|
*
|
|
* Handles three shapes of `<style data-impeccable-css="ID" ...>`:
|
|
* 1. Self-closing: `<style ... />` — no body; return null (nothing to carbonize).
|
|
* 2. Same-line open+close: `<style>...</style>` — return the inner content.
|
|
* 3. Multi-line: `<style>` on one line, `</style>` on a later line — return
|
|
* the lines between them.
|
|
*/
|
|
function extractCss(lines, block, id) {
|
|
const styleAttr = 'data-impeccable-css="' + id + '"';
|
|
let inStyle = false;
|
|
const content = [];
|
|
|
|
for (let i = block.start; i <= block.end; i++) {
|
|
const line = lines[i];
|
|
|
|
if (!inStyle && line.includes(styleAttr)) {
|
|
// Self-closing: nothing to carbonize.
|
|
if (/<style\b[^>]*\/\s*>/.test(line)) return null;
|
|
// Same-line open + close: extract inner text.
|
|
const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
|
|
if (sameLine) {
|
|
const inner = stripJsxTemplateWrap(sameLine[1]);
|
|
return inner.length > 0 ? inner.split('\n') : null;
|
|
}
|
|
inStyle = true;
|
|
continue; // skip the <style> opening tag
|
|
}
|
|
|
|
if (inStyle) {
|
|
// Detect </style> anywhere on the line — JSX template-literal closes
|
|
// (`}</style>`) put the close mid-line, and we don't want to absorb the
|
|
// template-literal punctuation as CSS content.
|
|
const closeIdx = line.indexOf('</style>');
|
|
if (closeIdx !== -1) break;
|
|
content.push(line);
|
|
}
|
|
}
|
|
|
|
if (content.length === 0) return null;
|
|
return stripJsxTemplateLines(content);
|
|
}
|
|
|
|
/**
|
|
* Strip a JSX template-literal wrap (`{` … `}`) from CSS extracted out of a
|
|
* `<style>` element in a JSX/TSX file. The agent may write the wrap with
|
|
* `{` and `}` directly attached to the `<style>` tags, on their own lines,
|
|
* or attached to the first/last CSS lines — all three are JSX-legal.
|
|
*
|
|
* Stripping is required because handleAccept re-wraps the CSS itself when
|
|
* carbonizing. Without this, two consecutive accepts (or a previously-
|
|
* accepted variants block being carbonized) would produce nested
|
|
* `{` `{` … `}` `}`, which oxc rejects with "Expected `}` but found `@`".
|
|
*/
|
|
function stripJsxTemplateLines(content) {
|
|
const out = content.slice();
|
|
|
|
// Drop any leading blank lines so we don't miss a `{` line buried below
|
|
// them; same for trailing.
|
|
while (out.length > 0 && out[0].trim() === '') out.shift();
|
|
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
|
|
if (out.length === 0) return null;
|
|
|
|
// Leading `{`: own line, or attached to the first CSS line.
|
|
const firstTrim = out[0].trimStart();
|
|
if (firstTrim === '{`') {
|
|
out.shift();
|
|
} else if (firstTrim.startsWith('{`')) {
|
|
const idx = out[0].indexOf('{`');
|
|
out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
|
|
if (out[0].trim() === '') out.shift();
|
|
}
|
|
if (out.length === 0) return null;
|
|
|
|
// Trailing `` ` `` `}`: own line, or attached to the last CSS line.
|
|
const lastIdx = out.length - 1;
|
|
const lastTrim = out[lastIdx].trimEnd();
|
|
if (lastTrim === '`}') {
|
|
out.pop();
|
|
} else if (lastTrim.endsWith('`}')) {
|
|
const text = out[lastIdx];
|
|
const idx = text.lastIndexOf('`}');
|
|
out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
|
|
if (out[lastIdx].trim() === '') out.pop();
|
|
}
|
|
|
|
return out.length > 0 ? out : null;
|
|
}
|
|
|
|
function stripJsxTemplateWrap(text) {
|
|
const lines = text.split('\n');
|
|
const stripped = stripJsxTemplateLines(lines);
|
|
return stripped ? stripped.join('\n') : '';
|
|
}
|
|
|
|
/**
|
|
* De-indent content that was indented by live-wrap.mjs.
|
|
* The wrap script adds `indent + ' '` (4 extra spaces) to each line.
|
|
* We restore to just `indent` level.
|
|
*/
|
|
function deindentContent(contentLines, baseIndent) {
|
|
// Find the minimum indentation in the content to determine how much was added
|
|
let minIndent = Infinity;
|
|
for (const line of contentLines) {
|
|
if (line.trim() === '') continue;
|
|
const leadingSpaces = line.match(/^(\s*)/)[1].length;
|
|
minIndent = Math.min(minIndent, leadingSpaces);
|
|
}
|
|
if (minIndent === Infinity) minIndent = 0;
|
|
|
|
// Strip the extra indentation and re-add base indent
|
|
return contentLines.map(line => {
|
|
if (line.trim() === '') return '';
|
|
return baseIndent + line.slice(minIndent);
|
|
});
|
|
}
|
|
|
|
function detectCommentSyntax(filePath) {
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
if (ext === '.jsx' || ext === '.tsx') {
|
|
return { open: '{/*', close: '*/}' };
|
|
}
|
|
return { open: '<!--', close: '-->' };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// File search (find the file containing session markers)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function findSessionFile(id, cwd) {
|
|
const marker = 'impeccable-variants-start ' + id;
|
|
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
|
|
const seen = new Set();
|
|
|
|
for (const dir of searchDirs) {
|
|
const absDir = path.join(cwd, dir);
|
|
if (!fs.existsSync(absDir)) continue;
|
|
const result = searchDir(absDir, marker, seen, 0);
|
|
if (result) {
|
|
const content = fs.readFileSync(result, 'utf-8');
|
|
return { file: result, content, lines: content.split('\n') };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function searchDir(dir, query, seen, depth) {
|
|
if (depth > 5) return null;
|
|
let realDir;
|
|
try { realDir = fs.realpathSync(dir); } catch { return null; }
|
|
if (seen.has(realDir)) return null;
|
|
seen.add(realDir);
|
|
|
|
let entries;
|
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
catch { return null; }
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isFile()) continue;
|
|
if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
|
|
const filePath = path.join(dir, entry.name);
|
|
try {
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
if (content.includes(query)) return filePath;
|
|
} catch { /* skip */ }
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
|
|
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
|
|
if (result) return result;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Utilities
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function argVal(args, flag) {
|
|
const idx = args.indexOf(flag);
|
|
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
|
|
}
|
|
|
|
// Auto-execute when run directly
|
|
const _running = process.argv[1];
|
|
if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
|
|
acceptCli();
|
|
}
|
|
|
|
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax };
|