This commit is contained in:
parent
739a534ac2
commit
f237916291
165 changed files with 79237 additions and 0 deletions
689
.agents/skills/impeccable/scripts/live-accept.mjs
Normal file
689
.agents/skills/impeccable/scripts/live-accept.mjs
Normal file
|
|
@ -0,0 +1,689 @@
|
|||
/**
|
||||
* 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';
|
||||
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.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
|
||||
|
||||
Options:
|
||||
--page-url URL Current browser page URL; scopes staged copy-edit cleanup
|
||||
|
||||
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 pageUrl = argVal(args, '--page-url');
|
||||
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);
|
||||
const acceptedOriginalText = result.acceptedOriginalText || '';
|
||||
delete result.acceptedOriginalText;
|
||||
// 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".';
|
||||
}
|
||||
// Scrub stash entries whose text appeared inside the just-replaced
|
||||
// original wrap block. The accept embodies those manual edits (wrap was
|
||||
// buffer-aware), so only those scoped ops are redundant.
|
||||
if (result.handled !== false) {
|
||||
try {
|
||||
scrubManualEditsAgainstOriginalBlock(acceptedOriginalText, process.cwd(), pageUrl);
|
||||
} catch {
|
||||
// Non-fatal; the buffer stays as-is and the user can discard later.
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a variant accept rewrites one wrapper, drop only buffer ops whose
|
||||
* text appeared inside that wrapper's original block. The previous file-wide
|
||||
* scrub dropped unrelated staged edits from other components/files whenever
|
||||
* their originalText wasn't present in the just-accepted file.
|
||||
*
|
||||
* Match both originalText and newText because live-wrap rewrites the original
|
||||
* preview block to reflect pending manual edits before variants are generated.
|
||||
*/
|
||||
function scrubManualEditsAgainstOriginalBlock(originalBlockText, cwd = process.cwd(), pageUrl = null) {
|
||||
const originalBlock = String(originalBlockText || '');
|
||||
if (!originalBlock) return;
|
||||
if (!pageUrl) return;
|
||||
const buffer = readManualEditsBuffer(cwd);
|
||||
if (buffer.entries.length === 0) return;
|
||||
let mutated = false;
|
||||
for (const entry of buffer.entries) {
|
||||
if (entry.pageUrl !== pageUrl) continue;
|
||||
const before = entry.ops.length;
|
||||
entry.ops = entry.ops.filter((op) => {
|
||||
return !manualEditOpAppearsInBlock(op, originalBlock);
|
||||
});
|
||||
if (entry.ops.length !== before) mutated = true;
|
||||
}
|
||||
buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0);
|
||||
if (mutated) writeManualEditsBuffer(cwd, buffer);
|
||||
}
|
||||
|
||||
function manualEditOpAppearsInBlock(op, originalBlock) {
|
||||
const candidates = [op?.newText, op?.originalText]
|
||||
.filter((text) => typeof text === 'string' && text.length > 0);
|
||||
return candidates.some((text) => originalBlockHasExactManualText(originalBlock, text));
|
||||
}
|
||||
|
||||
function originalBlockHasExactManualText(originalBlock, text) {
|
||||
const needle = normalizeManualEditText(text);
|
||||
if (!needle) return false;
|
||||
return manualEditTextSegments(originalBlock).some((segment) => segment === needle);
|
||||
}
|
||||
|
||||
function manualEditTextSegments(source) {
|
||||
return String(source || '')
|
||||
.replace(/<[^>]*>/g, '\n')
|
||||
.replace(/\{\/\*[\s\S]*?\*\/\}/g, '\n')
|
||||
.replace(/<!--[\s\S]*?-->/g, '\n')
|
||||
.split(/\n+/)
|
||||
.map(normalizeManualEditText)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeManualEditText(text) {
|
||||
return String(text || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
// Compatibility export for older tests/callers. The unsafe file-wide scrub was
|
||||
// removed; callers must pass accepted original-block text for scoped cleanup.
|
||||
function scrubManualEditsAgainstFile(_targetFile, cwd = process.cwd(), originalBlockText = '', pageUrl = null) {
|
||||
return scrubManualEditsAgainstOriginalBlock(originalBlockText, cwd, pageUrl);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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' };
|
||||
const originalContent = extractOriginal(lines, block);
|
||||
|
||||
// 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, acceptedOriginalText: originalContent.join('\n') };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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, id } : 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 >= 0; i--) {
|
||||
if (isVariantEndMarkerLine(lines[i], block.id)) break;
|
||||
if (hasVariantWrapperAttr(lines[i], block.id)) {
|
||||
let opener = i;
|
||||
while (opener > 0 && !/<div\b/.test(lines[opener]) && !isVariantEndMarkerLine(lines[opener], block.id)) {
|
||||
opener--;
|
||||
}
|
||||
if (/<div\b/.test(lines[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 };
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function isVariantEndMarkerLine(line, id) {
|
||||
return new RegExp('impeccable-variants-end\\s+' + escapeRegExp(id) + '(?:\\s|--|\\*/|$)').test(line);
|
||||
}
|
||||
|
||||
function hasVariantWrapperAttr(line, id) {
|
||||
const escaped = escapeRegExp(id);
|
||||
return new RegExp(`data-impeccable-variants\\s*=\\s*(?:"${escaped}"|'${escaped}'|\\{["']${escaped}["']\\})`).test(line);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock };
|
||||
Loading…
Add table
Add a link
Reference in a new issue