| 1 | /** |
| 2 | * CLI helper: deterministic accept/discard of variant sessions. |
| 3 | * |
| 4 | * Usage: |
| 5 | * node live-accept.mjs --id SESSION_ID --discard |
| 6 | * node live-accept.mjs --id SESSION_ID --variant N |
| 7 | * |
| 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 the |
| 10 | * session had a colocated <style> block, it's preserved with carbonize markers |
| 11 | * for a background agent to integrate into the project's CSS. |
| 12 | * |
| 13 | * Output: JSON to stdout. |
| 14 | */ |
| 15 | |
| 16 | import fs from 'node:fs'; |
| 17 | import path from 'node:path'; |
| 18 | import { isGeneratedFile } from './is-generated.mjs'; |
| 19 | import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; |
| 20 | import { |
| 21 | applyDeferredSvelteComponentAccepts, |
| 22 | findSvelteComponentManifest, |
| 23 | inlineSvelteComponentAccept, |
| 24 | removeSvelteComponentSession, |
| 25 | } from './live-svelte-component.mjs'; |
| 26 | |
| 27 | const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; |
| 28 | |
| 29 | // --------------------------------------------------------------------------- |
| 30 | // CLI |
| 31 | // --------------------------------------------------------------------------- |
| 32 | |
| 33 | export async function acceptCli() { |
| 34 | const args = process.argv.slice(2); |
| 35 | |
| 36 | if (args.includes('--help') || args.includes('-h')) { |
| 37 | console.log(`Usage: node live-accept.mjs [options] |
| 38 | |
| 39 | Deterministic accept/discard for live variant sessions. |
| 40 | |
| 41 | Modes: |
| 42 | --discard Remove variants, restore original |
| 43 | --variant N Accept variant N, discard the rest |
| 44 | |
| 45 | Required: |
| 46 | --id SESSION_ID Session ID of the variant wrapper |
| 47 | |
| 48 | Options: |
| 49 | --page-url URL Current browser page URL; scopes staged copy-edit cleanup |
| 50 | --defer-source-write |
| 51 | Deprecated compatibility flag. Svelte component accepts |
| 52 | now write the real source immediately. |
| 53 | |
| 54 | Output (JSON): |
| 55 | { handled, file, carbonize }`); |
| 56 | process.exit(0); |
| 57 | } |
| 58 | |
| 59 | const id = argVal(args, '--id'); |
| 60 | const variantNum = argVal(args, '--variant'); |
| 61 | const paramValuesRaw = argVal(args, '--param-values'); |
| 62 | const pageUrl = argVal(args, '--page-url'); |
| 63 | const isDiscard = args.includes('--discard'); |
| 64 | |
| 65 | if (!id) { console.error('Missing --id'); process.exit(1); } |
| 66 | if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); } |
| 67 | |
| 68 | let paramValues = null; |
| 69 | if (paramValuesRaw) { |
| 70 | try { paramValues = JSON.parse(paramValuesRaw); } |
| 71 | catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept |
| 72 | } |
| 73 | |
| 74 | // Find the file containing this session's markers |
| 75 | const found = findSessionFile(id, process.cwd()); |
| 76 | const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd()); |
| 77 | |
| 78 | if (!found && !svelteComponentManifest) { |
| 79 | console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id })); |
| 80 | process.exit(0); |
| 81 | } |
| 82 | |
| 83 | if (svelteComponentManifest) { |
| 84 | if (isDiscard) { |
| 85 | removeSvelteComponentSession(id, process.cwd()); |
| 86 | console.log(JSON.stringify({ |
| 87 | handled: true, |
| 88 | file: svelteComponentManifest.sourceFile, |
| 89 | carbonize: false, |
| 90 | previewMode: 'svelte-component', |
| 91 | componentDir: svelteComponentManifest.componentDir, |
| 92 | })); |
| 93 | return; |
| 94 | } |
| 95 | |
| 96 | let result; |
| 97 | try { |
| 98 | result = inlineSvelteComponentAccept( |
| 99 | svelteComponentManifest, |
| 100 | variantNum, |
| 101 | paramValues, |
| 102 | process.cwd(), |
| 103 | ); |
| 104 | } catch (err) { |
| 105 | result = { |
| 106 | handled: false, |
| 107 | error: err.message, |
| 108 | file: svelteComponentManifest.sourceFile, |
| 109 | sourceFile: svelteComponentManifest.sourceFile, |
| 110 | previewMode: 'svelte-component', |
| 111 | componentDir: svelteComponentManifest.componentDir, |
| 112 | }; |
| 113 | } |
| 114 | if (result.carbonize) { |
| 115 | result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".'; |
| 116 | } |
| 117 | console.log(JSON.stringify({ handled: result.handled !== false, ...result })); |
| 118 | return; |
| 119 | } |
| 120 | |
| 121 | const { file: targetFile, content, lines } = found; |
| 122 | const relFile = path.relative(process.cwd(), targetFile); |
| 123 | const previewBlock = findMarkerBlock(id, lines); |
| 124 | const sourceShadowPreview = previewBlock |
| 125 | ? readSourceShadowPreviewMeta(content, id) |
| 126 | : null; |
| 127 | |
| 128 | if (sourceShadowPreview) { |
| 129 | console.log(JSON.stringify({ |
| 130 | handled: false, |
| 131 | error: 'source_shadow_preview_deprecated', |
| 132 | hint: 'Svelte live mode now uses svelte-component injection. Re-wrap the element and regenerate variants.', |
| 133 | })); |
| 134 | process.exit(0); |
| 135 | } |
| 136 | |
| 137 | if (isGeneratedFile(targetFile, { cwd: process.cwd() })) { |
| 138 | console.log(JSON.stringify({ |
| 139 | handled: false, |
| 140 | mode: 'fallback', |
| 141 | file: relFile, |
| 142 | hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.', |
| 143 | })); |
| 144 | process.exit(0); |
| 145 | } |
| 146 | |
| 147 | if (isDiscard) { |
| 148 | const result = handleDiscard(id, lines, targetFile); |
| 149 | console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result })); |
| 150 | } else { |
| 151 | const result = handleAccept(id, variantNum, lines, targetFile, paramValues); |
| 152 | const acceptedOriginalText = result.acceptedOriginalText || ''; |
| 153 | delete result.acceptedOriginalText; |
| 154 | // Single-line attention-grabber when cleanup is required. The full |
| 155 | // five-step checklist lives in reference/live.md (loaded once per |
| 156 | // session); repeating it per-event would waste tokens. |
| 157 | if (result.carbonize) { |
| 158 | result.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-replaced |
| 161 | // original wrap block. The accept embodies those manual edits (wrap was |
| 162 | // buffer-aware), so only those scoped ops are redundant. |
| 163 | if (result.handled !== false) { |
| 164 | try { |
| 165 | scrubManualEditsAgainstOriginalBlock(acceptedOriginalText, process.cwd(), pageUrl); |
| 166 | } catch { |
| 167 | // Non-fatal; the buffer stays as-is and the user can discard later. |
| 168 | } |
| 169 | } |
| 170 | console.log(JSON.stringify({ handled: true, file: relFile, ...result })); |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | /** |
| 175 | * After a variant accept rewrites one wrapper, drop only buffer ops whose |
| 176 | * text appeared inside that wrapper's original block. The previous file-wide |
| 177 | * scrub dropped unrelated staged edits from other components/files whenever |
| 178 | * their originalText wasn't present in the just-accepted file. |
| 179 | * |
| 180 | * Match both originalText and newText because live-wrap rewrites the original |
| 181 | * preview block to reflect pending manual edits before variants are generated. |
| 182 | */ |
| 183 | function scrubManualEditsAgainstOriginalBlock(originalBlockText, cwd = process.cwd(), pageUrl = null) { |
| 184 | const originalBlock = String(originalBlockText || ''); |
| 185 | if (!originalBlock) return; |
| 186 | if (!pageUrl) return; |
| 187 | const buffer = readManualEditsBuffer(cwd); |
| 188 | if (buffer.entries.length === 0) return; |
| 189 | let mutated = false; |
| 190 | for (const entry of buffer.entries) { |
| 191 | if (entry.pageUrl !== pageUrl) continue; |
| 192 | const before = entry.ops.length; |
| 193 | entry.ops = entry.ops.filter((op) => { |
| 194 | return !manualEditOpAppearsInBlock(op, originalBlock); |
| 195 | }); |
| 196 | if (entry.ops.length !== before) mutated = true; |
| 197 | } |
| 198 | buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0); |
| 199 | if (mutated) writeManualEditsBuffer(cwd, buffer); |
| 200 | } |
| 201 | |
| 202 | function manualEditOpAppearsInBlock(op, originalBlock) { |
| 203 | const candidates = [op?.newText, op?.originalText] |
| 204 | .filter((text) => typeof text === 'string' && text.length > 0); |
| 205 | return candidates.some((text) => originalBlockHasExactManualText(originalBlock, text)); |
| 206 | } |
| 207 | |
| 208 | function originalBlockHasExactManualText(originalBlock, text) { |
| 209 | const needle = normalizeManualEditText(text); |
| 210 | if (!needle) return false; |
| 211 | return manualEditTextSegments(originalBlock).some((segment) => segment === needle); |
| 212 | } |
| 213 | |
| 214 | function manualEditTextSegments(source) { |
| 215 | return 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 | } |
| 223 | |
| 224 | function normalizeManualEditText(text) { |
| 225 | return String(text || '').replace(/\s+/g, ' ').trim(); |
| 226 | } |
| 227 | |
| 228 | // Compatibility export for older tests/callers. The unsafe file-wide scrub was |
| 229 | // removed; callers must pass accepted original-block text for scoped cleanup. |
| 230 | function scrubManualEditsAgainstFile(_targetFile, cwd = process.cwd(), originalBlockText = '', pageUrl = null) { |
| 231 | return scrubManualEditsAgainstOriginalBlock(originalBlockText, cwd, pageUrl); |
| 232 | } |
| 233 | |
| 234 | // --------------------------------------------------------------------------- |
| 235 | // Discard |
| 236 | // --------------------------------------------------------------------------- |
| 237 | |
| 238 | function handleDiscard(id, lines, targetFile) { |
| 239 | const block = findMarkerBlock(id, lines); |
| 240 | if (!block) return { handled: false, error: 'Markers not found' }; |
| 241 | |
| 242 | const original = extractOriginal(lines, block); |
| 243 | const isJsx = detectCommentSyntax(targetFile).open === '{/*'; |
| 244 | const replaceRange = expandReplaceRange(block, lines, isJsx); |
| 245 | |
| 246 | // 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>`, so |
| 248 | // `block.start` sits 2 spaces deeper than the original element. Using that |
| 249 | // as the deindent base would push the restored content 2 spaces too far |
| 250 | // right on every JSX/TSX session. `replaceRange.start` is the outer wrapper |
| 251 | // line, which is at the original element's indent for both HTML and JSX. |
| 252 | const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; |
| 253 | const restored = deindentContent(original, indent); |
| 254 | |
| 255 | const newLines = [ |
| 256 | ...lines.slice(0, replaceRange.start), |
| 257 | ...restored, |
| 258 | ...lines.slice(replaceRange.end + 1), |
| 259 | ]; |
| 260 | fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); |
| 261 | return {}; |
| 262 | } |
| 263 | |
| 264 | // --------------------------------------------------------------------------- |
| 265 | // Accept |
| 266 | // --------------------------------------------------------------------------- |
| 267 | |
| 268 | /** |
| 269 | * Build carbonize stitch-in lines. JSX targets occupy a single child slot |
| 270 | * (ternary branch, return value, etc.) — the same constraint as live-wrap. |
| 271 | * When isJsx, tuck markers + <style> + variant wrapper inside one outer |
| 272 | * <div data-impeccable-carbonize> so the slot keeps a single root node. |
| 273 | */ |
| 274 | function buildCarbonizeReplacement({ |
| 275 | indent, |
| 276 | commentSyntax, |
| 277 | isJsx, |
| 278 | id, |
| 279 | variantNum, |
| 280 | cssContent, |
| 281 | paramValues, |
| 282 | restored, |
| 283 | }) { |
| 284 | const lines = []; |
| 285 | if (!cssContent) { |
| 286 | lines.push(...restored); |
| 287 | return lines; |
| 288 | } |
| 289 | |
| 290 | const variantStyleAttr = isJsx |
| 291 | ? "style={{ display: 'contents' }}" |
| 292 | : 'style="display: contents"'; |
| 293 | |
| 294 | const pushCarbonizeBody = (bodyIndent) => { |
| 295 | const bodyRestored = reindentContent(restored, indent, bodyIndent + ' '); |
| 296 | lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); |
| 297 | lines.push(bodyIndent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : '')); |
| 298 | for (const cssLine of cssContent) { |
| 299 | lines.push(bodyIndent + cssLine.trimStart()); |
| 300 | } |
| 301 | lines.push(bodyIndent + (isJsx ? '`}</style>' : '</style>')); |
| 302 | if (paramValues && Object.keys(paramValues).length > 0) { |
| 303 | lines.push( |
| 304 | bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close, |
| 305 | ); |
| 306 | } |
| 307 | lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); |
| 308 | lines.push(bodyIndent + '<div data-impeccable-variant="' + variantNum + '" ' + variantStyleAttr + '>'); |
| 309 | lines.push(...bodyRestored); |
| 310 | lines.push(bodyIndent + '</div>'); |
| 311 | }; |
| 312 | |
| 313 | if (isJsx) { |
| 314 | const wrapperStyle = 'style={{ display: "contents" }}'; |
| 315 | lines.push(indent + '<div data-impeccable-carbonize="' + id + '" ' + wrapperStyle + '>'); |
| 316 | pushCarbonizeBody(indent + ' '); |
| 317 | lines.push(indent + '</div>'); |
| 318 | } else { |
| 319 | pushCarbonizeBody(indent); |
| 320 | } |
| 321 | |
| 322 | return lines; |
| 323 | } |
| 324 | |
| 325 | function reindentContent(contentLines, fromIndent, toIndent) { |
| 326 | return contentLines.map((line) => { |
| 327 | if (line.trim() === '') return ''; |
| 328 | if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length); |
| 329 | return toIndent + line.trimStart(); |
| 330 | }); |
| 331 | } |
| 332 | |
| 333 | function handleAccept(id, variantNum, lines, targetFile, paramValues) { |
| 334 | const block = findMarkerBlock(id, lines); |
| 335 | if (!block) return { handled: false, error: 'Markers not found' }; |
| 336 | |
| 337 | const commentSyntax = detectCommentSyntax(targetFile); |
| 338 | const 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 spaces |
| 341 | // deeper than the original element. See handleDiscard for the full |
| 342 | // rationale. |
| 343 | const replaceRange = expandReplaceRange(block, lines, isJsx); |
| 344 | const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; |
| 345 | |
| 346 | // Extract the chosen variant's inner content |
| 347 | const variantContent = extractVariant(lines, block, variantNum); |
| 348 | if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' }; |
| 349 | const originalContent = extractOriginal(lines, block); |
| 350 | |
| 351 | // Extract CSS block if present |
| 352 | const cssContent = extractCss(lines, block, id); |
| 353 | |
| 354 | // Check if carbonizing is needed: |
| 355 | // - CSS block exists, OR |
| 356 | // - variant HTML contains helper classes/attributes that need cleanup |
| 357 | const variantText = variantContent.join('\n'); |
| 358 | const hasHelperAttrs = variantText.includes('data-impeccable-variant'); |
| 359 | const needsCarbonize = !!(cssContent || hasHelperAttrs); |
| 360 | |
| 361 | const restored = deindentContent(variantContent, indent); |
| 362 | const replacement = buildCarbonizeReplacement({ |
| 363 | indent, |
| 364 | commentSyntax, |
| 365 | isJsx, |
| 366 | id, |
| 367 | variantNum, |
| 368 | cssContent, |
| 369 | paramValues, |
| 370 | restored, |
| 371 | }); |
| 372 | |
| 373 | const newLines = [ |
| 374 | ...lines.slice(0, replaceRange.start), |
| 375 | ...replacement, |
| 376 | ...lines.slice(replaceRange.end + 1), |
| 377 | ]; |
| 378 | fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); |
| 379 | |
| 380 | return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; |
| 381 | } |
| 382 | |
| 383 | function readSourceShadowPreviewMeta(content, id) { |
| 384 | const escaped = escapeRegExp(id); |
| 385 | const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>'); |
| 386 | const match = String(content || '').match(wrapperRe); |
| 387 | if (!match) return null; |
| 388 | const tag = match[0]; |
| 389 | if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null; |
| 390 | const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file'); |
| 391 | const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start')); |
| 392 | const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end')); |
| 393 | if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null; |
| 394 | return { sourceFile, sourceStartLine, sourceEndLine }; |
| 395 | } |
| 396 | |
| 397 | function readHtmlAttr(tag, name) { |
| 398 | const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1')); |
| 399 | if (!match) return null; |
| 400 | return decodeHtmlAttr(match[2]); |
| 401 | } |
| 402 | |
| 403 | function decodeHtmlAttr(value) { |
| 404 | return String(value || '') |
| 405 | .replace(/"/g, '"') |
| 406 | .replace(/</g, '<') |
| 407 | .replace(/>/g, '>') |
| 408 | .replace(/&/g, '&'); |
| 409 | } |
| 410 | |
| 411 | // --------------------------------------------------------------------------- |
| 412 | // Parsing helpers |
| 413 | // --------------------------------------------------------------------------- |
| 414 | |
| 415 | /** |
| 416 | * Find the start/end marker lines for a session. |
| 417 | * Returns { start, end } (0-indexed line numbers) or null. |
| 418 | */ |
| 419 | function findMarkerBlock(id, lines) { |
| 420 | let start = -1; |
| 421 | let end = -1; |
| 422 | const startPattern = 'impeccable-variants-start ' + id; |
| 423 | const endPattern = 'impeccable-variants-end ' + id; |
| 424 | |
| 425 | for (let i = 0; i < lines.length; i++) { |
| 426 | if (start === -1 && lines[i].includes(startPattern)) start = i; |
| 427 | if (lines[i].includes(endPattern)) { end = i; break; } |
| 428 | } |
| 429 | |
| 430 | return (start !== -1 && end !== -1) ? { start, end, id } : null; |
| 431 | } |
| 432 | |
| 433 | /** |
| 434 | * Compute the line range to REPLACE (vs. just the marker range to extract |
| 435 | * from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE |
| 436 | * the `<div data-impeccable-variants="ID">` outer wrapper so the picked |
| 437 | * element's JSX slot keeps a single child — a Fragment `<></>` would have |
| 438 | * solved the multi-sibling case but failed inside `asChild` / cloneElement |
| 439 | * parents with "Invalid prop supplied to React.Fragment". |
| 440 | * |
| 441 | * That means the marker block is enclosed by the wrapper `<div>` opener |
| 442 | * (with `data-impeccable-variants="ID"`) and its matching `</div>`. We |
| 443 | * walk back to the opener and forward to the closer so accept/discard |
| 444 | * 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 | */ |
| 449 | function expandReplaceRange(block, lines, isJsx) { |
| 450 | if (!isJsx) return { start: block.start, end: block.end }; |
| 451 | |
| 452 | let { start, end } = block; |
| 453 | |
| 454 | // 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, so |
| 456 | // also walk to the line that actually contains `<div`. |
| 457 | for (let i = start - 1; i >= 0; i--) { |
| 458 | if (isVariantEndMarkerLine(lines[i], block.id)) break; |
| 459 | if (hasVariantWrapperAttr(lines[i], block.id)) { |
| 460 | let opener = i; |
| 461 | while (opener > 0 && !/<div\b/.test(lines[opener]) && !isVariantEndMarkerLine(lines[opener], block.id)) { |
| 462 | opener--; |
| 463 | } |
| 464 | if (/<div\b/.test(lines[opener])) start = opener; |
| 465 | break; |
| 466 | } |
| 467 | } |
| 468 | |
| 469 | // Walk forward to the matching `</div>` by div-depth tracking from the |
| 470 | // wrapper opener. Operate on JOINED text instead of per-line: a |
| 471 | // multi-line self-closing JSX `<div\n className="spacer"\n/>` would |
| 472 | // fool per-line regex tracking (the `<div` line matches openRe but the |
| 473 | // `/>` line never matches selfCloseRe since it needs `<div` on the same |
| 474 | // line). That left depth permanently over-counted and the wrapper's |
| 475 | // outer `</div>` orphaned after accept/discard. Single regex with |
| 476 | // `[^>]*?` (which spans newlines in JS) handles either form correctly. |
| 477 | const joined = lines.slice(start).join('\n'); |
| 478 | // Match either `<div … />` (self-close, group 1 is `/`), `<div … >` |
| 479 | // (open, group 1 is empty), or `</div>`. |
| 480 | const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g; |
| 481 | let depth = 0; |
| 482 | let m; |
| 483 | while ((m = tagRe.exec(joined)) !== null) { |
| 484 | const isClose = m[0].startsWith('</'); |
| 485 | const isSelfClose = !isClose && m[1] === '/'; |
| 486 | if (isClose) depth--; |
| 487 | else if (!isSelfClose) depth++; |
| 488 | if (depth <= 0) { |
| 489 | // m.index is offset within `joined`; convert back to a file line. |
| 490 | const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1; |
| 491 | const candidateEnd = start + linesBefore; |
| 492 | if (candidateEnd >= end) { |
| 493 | end = candidateEnd; |
| 494 | break; |
| 495 | } |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | return { start, end }; |
| 500 | } |
| 501 | |
| 502 | function escapeRegExp(value) { |
| 503 | return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| 504 | } |
| 505 | |
| 506 | function isVariantEndMarkerLine(line, id) { |
| 507 | return new RegExp('impeccable-variants-end\\s+' + escapeRegExp(id) + '(?:\\s|--|\\*/|$)').test(line); |
| 508 | } |
| 509 | |
| 510 | function hasVariantWrapperAttr(line, id) { |
| 511 | const escaped = escapeRegExp(id); |
| 512 | return new RegExp(`data-impeccable-variants\\s*=\\s*(?:"${escaped}"|'${escaped}'|\\{["']${escaped}["']\\})`).test(line); |
| 513 | } |
| 514 | |
| 515 | /** |
| 516 | * Join wrapper lines into a single string with `<style>` elements removed so |
| 517 | * marker matching and div-depth tracking aren't confused by: |
| 518 | * - CSS `@scope ([data-impeccable-variant="N"])` strings that look like the |
| 519 | * HTML marker we're searching for |
| 520 | * - JSX self-closing `<style ... />` (no separate `</style>` to close on) |
| 521 | * - Same-line `<style>…</style>` blocks |
| 522 | * - Multi-line `<style>\n…\n</style>` blocks |
| 523 | */ |
| 524 | function stripStyleAndJoin(lines, block) { |
| 525 | const out = []; |
| 526 | let inStyle = false; |
| 527 | for (let i = block.start; i <= block.end; i++) { |
| 528 | let line = lines[i]; |
| 529 | |
| 530 | if (!inStyle) { |
| 531 | // Strip any complete <style> elements on this line (self-closed or |
| 532 | // same-line-closed), including their body content. |
| 533 | line = line |
| 534 | .replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/g, '') |
| 535 | .replace(/<style\b[^>]*\/\s*>/g, ''); |
| 536 | |
| 537 | // If a <style> opener remains (multi-line body starts here), strip from |
| 538 | // the opener to end-of-line and flip into skip mode. |
| 539 | const openerIdx = line.search(/<style\b/); |
| 540 | if (openerIdx !== -1) { |
| 541 | line = line.slice(0, openerIdx); |
| 542 | inStyle = true; |
| 543 | } |
| 544 | out.push(line); |
| 545 | } else { |
| 546 | // In multi-line style body; drop everything until we see </style>. |
| 547 | const closeIdx = line.search(/<\/style\s*>/); |
| 548 | if (closeIdx !== -1) { |
| 549 | inStyle = false; |
| 550 | out.push(line.slice(closeIdx).replace(/<\/style\s*>/, '')); |
| 551 | } |
| 552 | // else: skip line entirely |
| 553 | } |
| 554 | } |
| 555 | return out.join('\n'); |
| 556 | } |
| 557 | |
| 558 | /** |
| 559 | * Find the inner content of `<TAG ...attrMatch...>…</TAG>` inside `text`, |
| 560 | * handling nested same-tag elements via depth counting. `attrMatch` is a |
| 561 | * regex source fragment that must appear inside the opener tag. |
| 562 | * Returns the inner string (may be empty), or null if not found. |
| 563 | */ |
| 564 | function extractInnerByAttr(text, attrMatch) { |
| 565 | const openerRe = new RegExp('<([A-Za-z][A-Za-z0-9]*)\\b[^>]*' + attrMatch + '[^>]*>'); |
| 566 | const openMatch = text.match(openerRe); |
| 567 | if (!openMatch) return null; |
| 568 | |
| 569 | const tagName = openMatch[1]; |
| 570 | const innerStart = openMatch.index + openMatch[0].length; |
| 571 | |
| 572 | // Match any opener or closer of this tag name after innerStart. |
| 573 | // (Does not match self-closing <TAG … />, which doesn't contribute to depth.) |
| 574 | const tagRe = new RegExp('<(?:/)?' + tagName + '\\b[^>]*>', 'g'); |
| 575 | tagRe.lastIndex = innerStart; |
| 576 | |
| 577 | let depth = 1; |
| 578 | let m; |
| 579 | while ((m = tagRe.exec(text))) { |
| 580 | const isClose = m[0].startsWith('</'); |
| 581 | const isSelfClose = !isClose && /\/\s*>$/.test(m[0]); |
| 582 | if (isClose) { |
| 583 | depth--; |
| 584 | if (depth === 0) return text.slice(innerStart, m.index); |
| 585 | } else if (!isSelfClose) { |
| 586 | depth++; |
| 587 | } |
| 588 | } |
| 589 | return null; |
| 590 | } |
| 591 | |
| 592 | /** |
| 593 | * Extract the original element content from within the variant wrapper. |
| 594 | * Returns an array of lines. |
| 595 | */ |
| 596 | function extractOriginal(lines, block) { |
| 597 | const text = stripStyleAndJoin(lines, block); |
| 598 | const inner = extractInnerByAttr(text, 'data-impeccable-variant="original"'); |
| 599 | if (inner === null) return []; |
| 600 | return inner.split('\n'); |
| 601 | } |
| 602 | |
| 603 | /** |
| 604 | * Extract a specific variant's inner content (stripping the wrapper div). |
| 605 | * Returns an array of lines, or null if not found. |
| 606 | */ |
| 607 | function extractVariant(lines, block, variantNum) { |
| 608 | const text = stripStyleAndJoin(lines, block); |
| 609 | const inner = extractInnerByAttr(text, 'data-impeccable-variant="' + variantNum + '"'); |
| 610 | if (inner === null) return null; |
| 611 | const result = inner.split('\n'); |
| 612 | // Collapse a lone empty leading/trailing line (common after string splice). |
| 613 | while (result.length > 1 && result[0].trim() === '') result.shift(); |
| 614 | while (result.length > 1 && result[result.length - 1].trim() === '') result.pop(); |
| 615 | return result.length > 0 ? result : null; |
| 616 | } |
| 617 | |
| 618 | /** |
| 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 — return |
| 626 | * the lines between them. |
| 627 | */ |
| 628 | function extractCss(lines, block, id) { |
| 629 | const styleAttr = 'data-impeccable-css="' + id + '"'; |
| 630 | let inStyle = false; |
| 631 | const content = []; |
| 632 | |
| 633 | for (let i = block.start; i <= block.end; i++) { |
| 634 | const line = lines[i]; |
| 635 | |
| 636 | if (!inStyle && line.includes(styleAttr)) { |
| 637 | // Self-closing: nothing to carbonize. |
| 638 | if (/<style\b[^>]*\/\s*>/.test(line)) return null; |
| 639 | // Same-line open + close: extract inner text. |
| 640 | const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/); |
| 641 | if (sameLine) { |
| 642 | const inner = stripJsxTemplateWrap(sameLine[1]); |
| 643 | return inner.length > 0 ? inner.split('\n') : null; |
| 644 | } |
| 645 | inStyle = true; |
| 646 | continue; // skip the <style> opening tag |
| 647 | } |
| 648 | |
| 649 | if (inStyle) { |
| 650 | // Detect </style> anywhere on the line — JSX template-literal closes |
| 651 | // (`}</style>`) put the close mid-line, and we don't want to absorb the |
| 652 | // template-literal punctuation as CSS content. |
| 653 | const closeIdx = line.indexOf('</style>'); |
| 654 | if (closeIdx !== -1) break; |
| 655 | content.push(line); |
| 656 | } |
| 657 | } |
| 658 | |
| 659 | if (content.length === 0) return null; |
| 660 | return stripJsxTemplateLines(content); |
| 661 | } |
| 662 | |
| 663 | /** |
| 664 | * Strip a JSX template-literal wrap (`{` … `}`) from CSS extracted out of a |
| 665 | * `<style>` element in a JSX/TSX file. The agent may write the wrap with |
| 666 | * `{` 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 when |
| 670 | * carbonizing. Without this, two consecutive accepts (or a previously- |
| 671 | * accepted variants block being carbonized) would produce nested |
| 672 | * `{` `{` … `}` `}`, which oxc rejects with "Expected `}` but found `@`". |
| 673 | */ |
| 674 | function stripJsxTemplateLines(content) { |
| 675 | const out = content.slice(); |
| 676 | |
| 677 | // Drop any leading blank lines so we don't miss a `{` line buried below |
| 678 | // them; same for trailing. |
| 679 | while (out.length > 0 && out[0].trim() === '') out.shift(); |
| 680 | while (out.length > 0 && out[out.length - 1].trim() === '') out.pop(); |
| 681 | if (out.length === 0) return null; |
| 682 | |
| 683 | // Leading `{`: own line, or attached to the first CSS line. |
| 684 | const firstTrim = out[0].trimStart(); |
| 685 | if (firstTrim === '{`') { |
| 686 | out.shift(); |
| 687 | } else if (firstTrim.startsWith('{`')) { |
| 688 | const idx = out[0].indexOf('{`'); |
| 689 | out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2); |
| 690 | if (out[0].trim() === '') out.shift(); |
| 691 | } |
| 692 | if (out.length === 0) return null; |
| 693 | |
| 694 | // Trailing `` ` `` `}`: own line, or attached to the last CSS line. |
| 695 | const lastIdx = out.length - 1; |
| 696 | const lastTrim = out[lastIdx].trimEnd(); |
| 697 | if (lastTrim === '`}') { |
| 698 | out.pop(); |
| 699 | } else if (lastTrim.endsWith('`}')) { |
| 700 | const text = out[lastIdx]; |
| 701 | const idx = text.lastIndexOf('`}'); |
| 702 | out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2); |
| 703 | if (out[lastIdx].trim() === '') out.pop(); |
| 704 | } |
| 705 | |
| 706 | return out.length > 0 ? out : null; |
| 707 | } |
| 708 | |
| 709 | function stripJsxTemplateWrap(text) { |
| 710 | const lines = text.split('\n'); |
| 711 | const stripped = stripJsxTemplateLines(lines); |
| 712 | return stripped ? stripped.join('\n') : ''; |
| 713 | } |
| 714 | |
| 715 | /** |
| 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 | */ |
| 720 | function deindentContent(contentLines, baseIndent) { |
| 721 | // Find the minimum indentation in the content to determine how much was added |
| 722 | let minIndent = Infinity; |
| 723 | for (const line of contentLines) { |
| 724 | if (line.trim() === '') continue; |
| 725 | const leadingSpaces = line.match(/^(\s*)/)[1].length; |
| 726 | minIndent = Math.min(minIndent, leadingSpaces); |
| 727 | } |
| 728 | if (minIndent === Infinity) minIndent = 0; |
| 729 | |
| 730 | // Strip the extra indentation and re-add base indent |
| 731 | return contentLines.map(line => { |
| 732 | if (line.trim() === '') return ''; |
| 733 | return baseIndent + line.slice(minIndent); |
| 734 | }); |
| 735 | } |
| 736 | |
| 737 | function detectCommentSyntax(filePath) { |
| 738 | const ext = path.extname(filePath).toLowerCase(); |
| 739 | if (ext === '.jsx' || ext === '.tsx') { |
| 740 | return { open: '{/*', close: '*/}' }; |
| 741 | } |
| 742 | return { open: '<!--', close: '-->' }; |
| 743 | } |
| 744 | |
| 745 | // --------------------------------------------------------------------------- |
| 746 | // File search (find the file containing session markers) |
| 747 | // --------------------------------------------------------------------------- |
| 748 | |
| 749 | function findSessionFile(id, cwd) { |
| 750 | const marker = 'impeccable-variants-start ' + id; |
| 751 | const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; |
| 752 | const seen = new Set(); |
| 753 | |
| 754 | for (const dir of searchDirs) { |
| 755 | const absDir = path.join(cwd, dir); |
| 756 | if (!fs.existsSync(absDir)) continue; |
| 757 | const result = searchDir(absDir, marker, seen, 0); |
| 758 | if (result) { |
| 759 | const content = fs.readFileSync(result, 'utf-8'); |
| 760 | return { file: result, content, lines: content.split('\n') }; |
| 761 | } |
| 762 | } |
| 763 | return null; |
| 764 | } |
| 765 | |
| 766 | function searchDir(dir, query, seen, depth) { |
| 767 | if (depth > 5) return null; |
| 768 | let realDir; |
| 769 | try { realDir = fs.realpathSync(dir); } catch { return null; } |
| 770 | if (seen.has(realDir)) return null; |
| 771 | seen.add(realDir); |
| 772 | |
| 773 | let entries; |
| 774 | try { entries = fs.readdirSync(dir, { withFileTypes: true }); } |
| 775 | catch { return null; } |
| 776 | |
| 777 | for (const entry of entries) { |
| 778 | if (!entry.isFile()) continue; |
| 779 | if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue; |
| 780 | const filePath = path.join(dir, entry.name); |
| 781 | try { |
| 782 | const content = fs.readFileSync(filePath, 'utf-8'); |
| 783 | if (content.includes(query)) return filePath; |
| 784 | } catch { /* skip */ } |
| 785 | } |
| 786 | |
| 787 | for (const entry of entries) { |
| 788 | if (!entry.isDirectory()) continue; |
| 789 | if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue; |
| 790 | const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1); |
| 791 | if (result) return result; |
| 792 | } |
| 793 | |
| 794 | return null; |
| 795 | } |
| 796 | |
| 797 | // --------------------------------------------------------------------------- |
| 798 | // Utilities |
| 799 | // --------------------------------------------------------------------------- |
| 800 | |
| 801 | function argVal(args, flag) { |
| 802 | const idx = args.indexOf(flag); |
| 803 | return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; |
| 804 | } |
| 805 | |
| 806 | // Auto-execute when run directly |
| 807 | const _running = process.argv[1]; |
| 808 | if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) { |
| 809 | acceptCli(); |
| 810 | } |
| 811 | |
| 812 | export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts }; |
| 813 |