返回 AiToEarn
live-insert.mjs
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
273 lines Plain Text