| 1 | /** |
| 2 | * CLI helper: find an anchor element in source and splice an insert-variant |
| 3 | * wrapper before or after it (no original variant — net-new content). |
| 4 | * |
| 5 | * Usage: |
| 6 | * node live-insert.mjs --id SESSION_ID --count N --position after \ |
| 7 | * --classes "hero" --tag section [--file path] |
| 8 | */ |
| 9 | |
| 10 | import fs from 'node:fs'; |
| 11 | import path from 'node:path'; |
| 12 | import { isGeneratedFile } from './is-generated.mjs'; |
| 13 | import { |
| 14 | buildSearchQueries, |
| 15 | findElement, |
| 16 | findAllElements, |
| 17 | filterByText, |
| 18 | findFileWithQuery, |
| 19 | detectCommentSyntax, |
| 20 | detectStyleMode, |
| 21 | buildCssAuthoring, |
| 22 | buildCssSelectorPrefixExamples, |
| 23 | } from './live-wrap.mjs'; |
| 24 | import { |
| 25 | buildSvelteComponentCssAuthoring, |
| 26 | scaffoldSvelteComponentInsertSession, |
| 27 | shouldUseSvelteComponentInjection, |
| 28 | } from './live-svelte-component.mjs'; |
| 29 | |
| 30 | const INSERT_POSITIONS = new Set(['before', 'after']); |
| 31 | |
| 32 | export function isInsertPosition(value) { |
| 33 | return INSERT_POSITIONS.has(value); |
| 34 | } |
| 35 | |
| 36 | export function computeInsertLine(startLine, endLine, position) { |
| 37 | return position === 'before' ? startLine : endLine + 1; |
| 38 | } |
| 39 | |
| 40 | export function buildInsertWrapperLines({ id, count, indent, commentSyntax, isJsx }) { |
| 41 | const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"'; |
| 42 | const attrs = |
| 43 | 'data-impeccable-variants="' + id + '" ' + |
| 44 | 'data-impeccable-mode="insert" ' + |
| 45 | 'data-impeccable-variant-count="' + count + '" ' + |
| 46 | styleContents; |
| 47 | |
| 48 | if (isJsx) { |
| 49 | return [ |
| 50 | indent + '<div ' + attrs + '>', |
| 51 | indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close, |
| 52 | indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close, |
| 53 | indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close, |
| 54 | indent + '</div>', |
| 55 | ]; |
| 56 | } |
| 57 | |
| 58 | return [ |
| 59 | indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close, |
| 60 | indent + '<div ' + attrs + '>', |
| 61 | indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close, |
| 62 | indent + '</div>', |
| 63 | indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close, |
| 64 | ]; |
| 65 | } |
| 66 | |
| 67 | function argVal(args, flag) { |
| 68 | const idx = args.indexOf(flag); |
| 69 | return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; |
| 70 | } |
| 71 | |
| 72 | function resolveElementMatch({ lines, queries, tag, text }) { |
| 73 | if (text) { |
| 74 | const candidates = []; |
| 75 | for (const q of queries) { |
| 76 | const all = findAllElements(lines, q, tag); |
| 77 | for (const c of all) { |
| 78 | if (!candidates.some((x) => x.startLine === c.startLine)) candidates.push(c); |
| 79 | } |
| 80 | if (candidates.length === 1) break; |
| 81 | } |
| 82 | if (candidates.length === 0) return { error: 'element_not_found' }; |
| 83 | if (candidates.length === 1) return { match: candidates[0] }; |
| 84 | const filtered = filterByText(candidates, lines, text); |
| 85 | if (filtered.length === 1) return { match: filtered[0] }; |
| 86 | if (filtered.length === 0) return { match: candidates[0] }; |
| 87 | return { error: 'element_ambiguous', candidates: filtered }; |
| 88 | } |
| 89 | |
| 90 | for (const q of queries) { |
| 91 | const match = findElement(lines, q, tag); |
| 92 | if (match) return { match }; |
| 93 | } |
| 94 | return { error: 'element_not_found' }; |
| 95 | } |
| 96 | |
| 97 | export async function insertCli() { |
| 98 | const args = process.argv.slice(2); |
| 99 | |
| 100 | if (args.includes('--help') || args.includes('-h')) { |
| 101 | console.log(`Usage: node live-insert.mjs [options] |
| 102 | |
| 103 | Find an anchor element in source and splice an insert-variant wrapper. |
| 104 | |
| 105 | Required: |
| 106 | --id ID Session ID for the variant wrapper |
| 107 | --count N Number of expected variants (1-8) |
| 108 | --position POS before | after (relative to the anchor element) |
| 109 | |
| 110 | Element identification (at least one required): |
| 111 | --element-id ID HTML id attribute of the anchor element |
| 112 | --classes A,B,C Comma-separated CSS class names |
| 113 | --tag TAG Tag name (div, section, etc.) |
| 114 | --query TEXT Fallback: raw text to search for |
| 115 | |
| 116 | Optional: |
| 117 | --file PATH Source file to search in (skips auto-detection) |
| 118 | --text TEXT Anchor textContent for disambiguation (~80 chars) |
| 119 | |
| 120 | Output (JSON): |
| 121 | { mode: "insert", file, position, insertLine, commentSyntax, styleMode, styleTag, cssAuthoring }`); |
| 122 | process.exit(0); |
| 123 | } |
| 124 | |
| 125 | const id = argVal(args, '--id'); |
| 126 | const count = parseInt(argVal(args, '--count') || '3', 10); |
| 127 | const position = argVal(args, '--position'); |
| 128 | const elementId = argVal(args, '--element-id'); |
| 129 | const classes = argVal(args, '--classes'); |
| 130 | const tag = argVal(args, '--tag'); |
| 131 | const query = argVal(args, '--query'); |
| 132 | const filePath = argVal(args, '--file'); |
| 133 | const text = argVal(args, '--text'); |
| 134 | |
| 135 | if (!id) { console.error('Missing --id'); process.exit(1); } |
| 136 | if (!position) { console.error('Missing --position (before | after)'); process.exit(1); } |
| 137 | if (!isInsertPosition(position)) { console.error('Invalid --position: ' + position); process.exit(1); } |
| 138 | if (!elementId && !classes && !query) { |
| 139 | console.error('Need at least one of: --element-id, --classes, --query'); |
| 140 | process.exit(1); |
| 141 | } |
| 142 | |
| 143 | const queries = buildSearchQueries(elementId, classes, tag, query); |
| 144 | const genOpts = { cwd: process.cwd() }; |
| 145 | |
| 146 | let targetFile = filePath; |
| 147 | if (!targetFile) { |
| 148 | for (const q of queries) { |
| 149 | targetFile = findFileWithQuery(q, process.cwd(), genOpts); |
| 150 | if (targetFile) break; |
| 151 | } |
| 152 | if (!targetFile) { |
| 153 | let generatedHit = null; |
| 154 | for (const q of queries) { |
| 155 | generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true }); |
| 156 | if (generatedHit) break; |
| 157 | } |
| 158 | console.error(JSON.stringify({ |
| 159 | error: generatedHit ? 'element_not_in_source' : 'element_not_found', |
| 160 | fallback: 'agent-driven', |
| 161 | hint: 'See "Handle fallback" in live.md.', |
| 162 | })); |
| 163 | process.exit(1); |
| 164 | } |
| 165 | } else if (isGeneratedFile(targetFile, genOpts)) { |
| 166 | console.error(JSON.stringify({ |
| 167 | error: 'file_is_generated', |
| 168 | fallback: 'agent-driven', |
| 169 | file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)), |
| 170 | })); |
| 171 | process.exit(1); |
| 172 | } |
| 173 | |
| 174 | const content = fs.readFileSync(targetFile, 'utf-8'); |
| 175 | const lines = content.split('\n'); |
| 176 | const resolved = resolveElementMatch({ lines, queries, tag, text }); |
| 177 | |
| 178 | if (resolved.error === 'element_ambiguous') { |
| 179 | console.error(JSON.stringify({ |
| 180 | error: 'element_ambiguous', |
| 181 | fallback: 'agent-driven', |
| 182 | file: path.relative(process.cwd(), targetFile), |
| 183 | candidates: resolved.candidates.map((c) => ({ |
| 184 | startLine: c.startLine + 1, |
| 185 | endLine: c.endLine + 1, |
| 186 | })), |
| 187 | })); |
| 188 | process.exit(1); |
| 189 | } |
| 190 | if (!resolved.match) { |
| 191 | console.error(JSON.stringify({ error: 'element_not_found', fallback: 'agent-driven' })); |
| 192 | process.exit(1); |
| 193 | } |
| 194 | |
| 195 | const { startLine, endLine } = resolved.match; |
| 196 | const commentSyntax = detectCommentSyntax(targetFile); |
| 197 | const styleMode = detectStyleMode(targetFile); |
| 198 | const isJsx = commentSyntax.open === '{/*'; |
| 199 | const spliceIndex = computeInsertLine(startLine, endLine, position); |
| 200 | const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/'); |
| 201 | |
| 202 | if (shouldUseSvelteComponentInjection(targetFile)) { |
| 203 | const session = scaffoldSvelteComponentInsertSession({ |
| 204 | id, |
| 205 | count, |
| 206 | sourceFile: relTargetFile, |
| 207 | insertLine: spliceIndex + 1, |
| 208 | position, |
| 209 | anchorStartLine: startLine + 1, |
| 210 | anchorEndLine: endLine + 1, |
| 211 | anchorLines: lines.slice(startLine, endLine + 1), |
| 212 | cwd: process.cwd(), |
| 213 | }); |
| 214 | console.log(JSON.stringify({ |
| 215 | mode: 'insert', |
| 216 | position, |
| 217 | file: session.manifestFile, |
| 218 | sourceFile: relTargetFile, |
| 219 | previewMode: 'svelte-component', |
| 220 | componentDir: session.componentDir, |
| 221 | propContract: session.propContract, |
| 222 | insertLine: 1, |
| 223 | sourceInsertLine: spliceIndex + 1, |
| 224 | anchorStartLine: startLine + 1, |
| 225 | anchorEndLine: endLine + 1, |
| 226 | commentSyntax, |
| 227 | styleMode: 'svelte-component', |
| 228 | styleTag: null, |
| 229 | cssSelectorPrefixExamples: [], |
| 230 | cssAuthoring: buildSvelteComponentCssAuthoring(count), |
| 231 | })); |
| 232 | return; |
| 233 | } |
| 234 | |
| 235 | const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] |
| 236 | ?? lines[startLine]?.match(/^(\s*)/)?.[1] |
| 237 | ?? ''; |
| 238 | |
| 239 | const wrapperLines = buildInsertWrapperLines({ |
| 240 | id, |
| 241 | count, |
| 242 | indent, |
| 243 | commentSyntax, |
| 244 | isJsx, |
| 245 | }); |
| 246 | |
| 247 | const newLines = [ |
| 248 | ...lines.slice(0, spliceIndex), |
| 249 | ...wrapperLines, |
| 250 | ...lines.slice(spliceIndex), |
| 251 | ]; |
| 252 | fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); |
| 253 | |
| 254 | const insertLine = spliceIndex + 3; |
| 255 | |
| 256 | console.log(JSON.stringify({ |
| 257 | mode: 'insert', |
| 258 | position, |
| 259 | file: relTargetFile, |
| 260 | insertLine: insertLine + 1, |
| 261 | commentSyntax, |
| 262 | styleMode: styleMode.mode, |
| 263 | styleTag: styleMode.styleTag, |
| 264 | cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count), |
| 265 | cssAuthoring: buildCssAuthoring(styleMode, count), |
| 266 | })); |
| 267 | } |
| 268 | |
| 269 | const _running = process.argv[1]; |
| 270 | if (_running?.endsWith('live-insert.mjs') || _running?.endsWith('live-insert.mjs/')) { |
| 271 | insertCli(); |
| 272 | } |
| 273 |