s in the same variant where only one had an inline style cannot
// both pick up the hoisted declarations.
const lines = declarations.map(({ prop, value }) => ` ${prop}: ${value};`);
const target = `[${HOIST_ATTR}="${hoistId}"]`;
if (styleMode === 'astro-global-prefixed') {
return [
`[data-impeccable-variant="${variantId}"] ${target} {`,
...lines.map((line) => line.slice(2)),
'}',
].join('\n');
}
return [
`@scope ([data-impeccable-variant="${variantId}"]) {`,
` :scope ${target} {`,
...lines,
' }',
'}',
].join('\n');
}
/**
* Render the variants block in either HTML or JSX, depending on commentSyntax.
* In JSX:
* - comments use {/* ... */} (already what commentSyntax.open is)
* - wraps CSS in a template literal so JSX
* doesn't choke on the {} in CSS
* - non-default visible variants use style={{display: 'none'}}
* - inner element class= becomes className=, style="..." becomes JSX style={{ ... }}
* - data-impeccable-params stays a single-quoted JSON string (JSX-legal)
*/
function renderVariantsBlock({ sessionId, indent, output, commentSyntax, file, styleMode }) {
const isJsx = commentSyntax.open === '{/*';
const isSvelte = !!file && file.endsWith('.svelte');
const isAstroGlobalCss = styleMode === 'astro-global-prefixed';
const styleLines = isJsx
? [
indent + ' ',
]
: [
indent + ' ',
];
const variantBlocks = output.variants.map((v, i) => {
const idx = i + 1;
const paramsAttr = v.params && v.params.length
? " data-impeccable-params='" + attrEscape(JSON.stringify(v.params), { svelte: isSvelte }) + "'"
: '';
let styleAttr = '';
if (i !== 0) styleAttr = isJsx ? " style={{display: 'none'}}" : ' style="display: none"';
const inner = isJsx ? htmlToJsx(v.innerHtml) : v.innerHtml;
return [
indent + ' ' + commentSyntax.open + ' Variant ' + idx + ' ' + commentSyntax.close,
indent + ' ',
indent + ' ' + inner,
indent + '
',
].join('\n');
});
return [...styleLines, ...variantBlocks].join('\n');
}
/**
* Splice the rendered variants block into an array of wrapper lines at the
* "insert below this line" marker. Pure: returns the new lines array. Used
* both against a whole file (wrapper already in source) and against a
* standalone wrapper block (deferred source write, agent writes it now).
*/
function spliceVariantsIntoLines(lines, { sessionId, output, commentSyntax, file, styleMode }) {
// Find the "Variants: insert below this line" comment line — definitive
// marker, robust to any indentation off-by-one. Matches in any comment
// style (HTML / JSX / Astro).
const markerIdx = lines.findIndex((l) =>
l.includes('Variants: insert below this line'),
);
if (markerIdx === -1) {
throw new Error('insert marker not found in ' + file);
}
const indent = (lines[markerIdx].match(/^\s*/) || [''])[0];
// Indent INSIDE the wrapper is one level shallower (the marker is indented
// 2 spaces relative to the wrapper opening). Remove the 2-space comment
// indent to get the wrapper indent.
const wrapperIndent = indent.replace(/ $/, '');
const block = renderVariantsBlock({
sessionId,
indent: wrapperIndent,
output,
commentSyntax,
file,
styleMode,
});
const endMarkerIdx = lines.findIndex((line, index) =>
index > markerIdx && line.includes('impeccable-variants-end ' + sessionId),
);
if (endMarkerIdx === -1) {
throw new Error('end marker not found in ' + file);
}
const tailIdx = commentSyntax.open === '{/*'
? endMarkerIdx
: endMarkerIdx - 1;
return [
...lines.slice(0, markerIdx + 1),
block,
...lines.slice(tailIdx),
];
}
/**
* Read the wrapped file, find the "insert below this line" marker, splice in
* the rendered variants block, write back. Used when the wrapper is already
* present in source (agent's own wrap fallback, no preflight).
*/
async function spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId, output }) {
const filePath = path.join(tmp, wrapInfo.file);
const src = await fs.readFile(filePath, 'utf-8');
const lines = src.split('\n');
const next = spliceVariantsIntoLines(lines, {
sessionId,
output,
commentSyntax: wrapInfo.commentSyntax,
file: wrapInfo.file,
styleMode: wrapInfo.styleMode,
});
await fs.writeFile(filePath, next.join('\n'), 'utf-8');
}
/**
* Deferred source write (preflight computed the scaffold but left source
* untouched). Splice the variants into the scaffold's `wrapperBlock`, then
* replace the picked element's source range with the result in ONE write —
* the 3.5 atomic single-edit semantics. `replaceEndLine < replaceStartLine`
* expresses a pure insertion (insert mode).
*/
async function writeDeferredWrapperWithVariants({ tmp, wrapInfo, sessionId, output }) {
const filePath = path.join(tmp, wrapInfo.file);
const src = await fs.readFile(filePath, 'utf-8');
const lines = src.split('\n');
const wrapperLines = String(wrapInfo.wrapperBlock).split('\n');
const splicedWrapper = spliceVariantsIntoLines(wrapperLines, {
sessionId,
output,
commentSyntax: wrapInfo.commentSyntax,
file: wrapInfo.file,
styleMode: wrapInfo.styleMode,
});
const startIdx = wrapInfo.replaceStartLine - 1;
const endIdx = wrapInfo.replaceEndLine - 1; // may be startIdx-1 for insertion
const next = [
...lines.slice(0, startIdx),
...splicedWrapper,
...lines.slice(endIdx + 1),
];
await fs.writeFile(filePath, next.join('\n'), 'utf-8');
}
async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams = true }) {
const manifestPath = path.join(tmp, wrapInfo.file);
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
const componentDir = path.join(tmp, manifest.componentDir);
const isInsert = manifest.mode === 'insert';
const contract = Array.isArray(manifest.propContract) ? manifest.propContract : [];
const propNames = contract.map((entry) => entry.prop);
const baseMarkup = isInsert ? '' : substituteSvelteExprsWithProps(manifest.originalMarkup || '', contract).trim();
const textValues = isInsert ? [] : extractTextPieces(event.element?.outerHTML || event.element?.textContent || '');
const paramsByVariant = {};
for (let i = 0; i < output.variants.length; i++) {
const variantId = i + 1;
const variant = output.variants[i];
// Contract-v2 path: keep the scaffolded stub (control flow + prop
// references) and swap only its ',
'',
].join('\n');
await fs.writeFile(path.join(componentDir, `v${variantId}.svelte`), component, 'utf-8');
paramsByVariant[String(variantId)] = Array.isArray(variant.params) ? variant.params : [];
}
if (writeParams) {
await fs.writeFile(path.join(componentDir, 'params.json'), JSON.stringify(paramsByVariant, null, 2) + '\n', 'utf-8');
}
manifest.arrivedVariants = output.variants.length;
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
}
function variantMarkupHasVisibleContent(markup) {
const text = String(markup || '')
.replace(/';
return ``;
}
function substituteSvelteExprsWithProps(markup, contract) {
let out = String(markup || '');
for (const entry of contract) {
out = out.split(`{${entry.expr}}`).join(`{${entry.prop}}`);
}
return out;
}
function substituteLiveTextWithProps(markup, contract, textValues) {
let out = String(markup || '');
for (let i = 0; i < contract.length; i++) {
const value = textValues[i];
if (!value) continue;
out = out.split(htmlEscape(value)).join(`{${contract[i].prop}}`);
out = out.split(value).join(`{${contract[i].prop}}`);
}
return out;
}
function extractTextPieces(html) {
return String(html || '')
.replace(/