@kernlang/terminal 3.1.9 → 3.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/transpiler-ink.js +854 -171
- package/dist/transpiler-ink.js.map +1 -1
- package/package.json +2 -2
package/dist/transpiler-ink.js
CHANGED
|
@@ -117,23 +117,26 @@ function keyToCheck(key) {
|
|
|
117
117
|
class ImportTracker {
|
|
118
118
|
reactImports = new Set();
|
|
119
119
|
inkImports = new Set();
|
|
120
|
-
|
|
121
|
-
inkTextInput = false;
|
|
122
|
-
inkSelectInput = false;
|
|
120
|
+
inkUIImports = new Set();
|
|
123
121
|
addReact(name) {
|
|
124
122
|
this.reactImports.add(name);
|
|
125
123
|
}
|
|
126
124
|
addInk(name) {
|
|
127
125
|
this.inkImports.add(name);
|
|
128
126
|
}
|
|
127
|
+
/** Add an @inkjs/ui component import. */
|
|
128
|
+
addInkUI(name) {
|
|
129
|
+
this.inkUIImports.add(name);
|
|
130
|
+
}
|
|
131
|
+
// Legacy convenience methods — now route to @inkjs/ui
|
|
129
132
|
needSpinner() {
|
|
130
|
-
this.
|
|
133
|
+
this.inkUIImports.add('Spinner');
|
|
131
134
|
}
|
|
132
135
|
needTextInput() {
|
|
133
|
-
this.
|
|
136
|
+
this.inkUIImports.add('TextInput');
|
|
134
137
|
}
|
|
135
138
|
needSelectInput() {
|
|
136
|
-
this.
|
|
139
|
+
this.inkUIImports.add('Select');
|
|
137
140
|
}
|
|
138
141
|
emit() {
|
|
139
142
|
const lines = [];
|
|
@@ -146,47 +149,136 @@ class ImportTracker {
|
|
|
146
149
|
if (this.inkImports.size > 0) {
|
|
147
150
|
lines.push(`import { ${[...this.inkImports].sort().join(', ')} } from 'ink';`);
|
|
148
151
|
}
|
|
149
|
-
if (this.
|
|
150
|
-
lines.push(`import
|
|
151
|
-
}
|
|
152
|
-
if (this.inkTextInput) {
|
|
153
|
-
lines.push(`import TextInput from 'ink-text-input';`);
|
|
154
|
-
}
|
|
155
|
-
if (this.inkSelectInput) {
|
|
156
|
-
lines.push(`import SelectInput from 'ink-select-input';`);
|
|
152
|
+
if (this.inkUIImports.size > 0) {
|
|
153
|
+
lines.push(`import { ${[...this.inkUIImports].sort().join(', ')} } from '@inkjs/ui';`);
|
|
157
154
|
}
|
|
158
155
|
return lines;
|
|
159
156
|
}
|
|
160
157
|
}
|
|
161
|
-
// ──
|
|
162
|
-
|
|
158
|
+
// ── Ink-safe setter utility ─────────────────────────────────────────────
|
|
159
|
+
/** Emit the __inkSafe helper once per component — bridges microtask→macrotask for Ink repaints. */
|
|
160
|
+
function emitInkSafePreamble() {
|
|
161
|
+
return [
|
|
162
|
+
' // Ink-safe setter: bridges microtask → macrotask for reliable repaints',
|
|
163
|
+
' function __inkSafe<T>(setter: React.Dispatch<React.SetStateAction<T>>): React.Dispatch<React.SetStateAction<T>> {',
|
|
164
|
+
' return (value) => setTimeout(() => setter(value), 0);',
|
|
165
|
+
' }',
|
|
166
|
+
'',
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
/** Detect whether a useState initial value needs lazy initialization (prevents re-eval per render). */
|
|
170
|
+
function needsLazyInit(initial, type) {
|
|
171
|
+
const trimmed = initial.trim();
|
|
172
|
+
// IIFE: ((...) => ...)() or (function() { ... })()
|
|
173
|
+
if (/^\(.*\)\s*\(/.test(trimmed))
|
|
174
|
+
return true;
|
|
175
|
+
// function expression: function( — executes when called
|
|
176
|
+
if (trimmed.startsWith('function(') || trimmed.startsWith('function ('))
|
|
177
|
+
return true;
|
|
178
|
+
// new constructor: new Map(), new Set(), etc.
|
|
179
|
+
if (trimmed.startsWith('new '))
|
|
180
|
+
return true;
|
|
181
|
+
// Arrow functions: only wrap if state TYPE is a function (state holds a function value)
|
|
182
|
+
if (/^\(?[^)]*\)?\s*=>/.test(trimmed) && type && /=>/.test(type))
|
|
183
|
+
return true;
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
function generateStateHook(stateNode, imports, ctx) {
|
|
163
187
|
const lines = [];
|
|
164
188
|
const props = getProps(stateNode);
|
|
165
189
|
const name = props.name;
|
|
166
190
|
const initialProp = props.initial;
|
|
191
|
+
const safe = props.safe !== 'false' && props.safe !== false; // default true
|
|
167
192
|
if (name && initialProp !== undefined) {
|
|
168
193
|
imports.addReact('useState');
|
|
169
194
|
const initial = isExpr(initialProp) ? initialProp.code : String(initialProp);
|
|
170
195
|
const initVal = isExpr(initialProp)
|
|
171
196
|
? initial
|
|
172
|
-
: initial === '
|
|
173
|
-
? '
|
|
174
|
-
: initial === '
|
|
175
|
-
? '
|
|
176
|
-
: initial === '
|
|
177
|
-
? '
|
|
178
|
-
: initial
|
|
179
|
-
?
|
|
180
|
-
: initial.startsWith(
|
|
197
|
+
: initial === ''
|
|
198
|
+
? "''"
|
|
199
|
+
: initial === 'null'
|
|
200
|
+
? 'null'
|
|
201
|
+
: initial === 'true'
|
|
202
|
+
? 'true'
|
|
203
|
+
: initial === 'false'
|
|
204
|
+
? 'false'
|
|
205
|
+
: initial.startsWith('[') || initial.startsWith('{')
|
|
181
206
|
? initial
|
|
182
|
-
: initial.
|
|
207
|
+
: initial.startsWith("'") || initial.startsWith('"')
|
|
183
208
|
? initial
|
|
184
|
-
:
|
|
185
|
-
?
|
|
186
|
-
:
|
|
209
|
+
: initial.includes('(') || initial.includes('.')
|
|
210
|
+
? initial
|
|
211
|
+
: Number.isNaN(Number(initial))
|
|
212
|
+
? `'${initial}'`
|
|
213
|
+
: String(initial);
|
|
187
214
|
const setter = `set${capitalize(name)}`;
|
|
188
215
|
const typeAnnotation = props.type ? `<${props.type}>` : '';
|
|
189
|
-
|
|
216
|
+
// Lazy initialization for IIFEs and constructors (prevents re-eval per render)
|
|
217
|
+
const lazyInitVal = needsLazyInit(initVal, props.type) ? `() => ${initVal}` : initVal;
|
|
218
|
+
const throttle = props.throttle;
|
|
219
|
+
const debounce = props.debounce;
|
|
220
|
+
if (throttle) {
|
|
221
|
+
// Throttled setter — leading+trailing by default (lodash-style).
|
|
222
|
+
// trailing=false reverts to leading-only (drops intermediate + final values in window).
|
|
223
|
+
const trailing = props.trailing !== 'false' && props.trailing !== false;
|
|
224
|
+
imports.addReact('useMemo');
|
|
225
|
+
const valType = typeAnnotation ? props.type : 'any';
|
|
226
|
+
lines.push(` const [${name}, _${setter}Raw] = useState${typeAnnotation}(${lazyInitVal});`);
|
|
227
|
+
lines.push(` const ${setter} = useMemo(() => {`);
|
|
228
|
+
lines.push(` let _lastCall = 0;`);
|
|
229
|
+
if (trailing) {
|
|
230
|
+
lines.push(` let _pendingValue: React.SetStateAction<${valType}>;`);
|
|
231
|
+
lines.push(` let _pendingTimer: ReturnType<typeof setTimeout> | null = null;`);
|
|
232
|
+
}
|
|
233
|
+
lines.push(` return (value: React.SetStateAction<${valType}>) => {`);
|
|
234
|
+
lines.push(` const now = Date.now();`);
|
|
235
|
+
if (trailing) {
|
|
236
|
+
lines.push(` const elapsed = now - _lastCall;`);
|
|
237
|
+
lines.push(` if (elapsed >= ${throttle}) {`);
|
|
238
|
+
lines.push(` _lastCall = now;`);
|
|
239
|
+
lines.push(` if (_pendingTimer) { clearTimeout(_pendingTimer); _pendingTimer = null; }`);
|
|
240
|
+
lines.push(` setTimeout(() => _${setter}Raw(value), 0);`);
|
|
241
|
+
lines.push(` } else {`);
|
|
242
|
+
lines.push(` _pendingValue = value;`);
|
|
243
|
+
lines.push(` if (!_pendingTimer) {`);
|
|
244
|
+
lines.push(` _pendingTimer = setTimeout(() => {`);
|
|
245
|
+
lines.push(` _lastCall = Date.now();`);
|
|
246
|
+
lines.push(` _pendingTimer = null;`);
|
|
247
|
+
lines.push(` _${setter}Raw(_pendingValue);`);
|
|
248
|
+
lines.push(` }, ${throttle} - elapsed);`);
|
|
249
|
+
lines.push(` }`);
|
|
250
|
+
lines.push(` }`);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
lines.push(` if (now - _lastCall >= ${throttle}) {`);
|
|
254
|
+
lines.push(` _lastCall = now;`);
|
|
255
|
+
lines.push(` setTimeout(() => _${setter}Raw(value), 0);`);
|
|
256
|
+
lines.push(` }`);
|
|
257
|
+
}
|
|
258
|
+
lines.push(` };`);
|
|
259
|
+
lines.push(` }, []);`);
|
|
260
|
+
}
|
|
261
|
+
else if (debounce) {
|
|
262
|
+
// Debounced setter — delays updates, uses setTimeout for Ink safety
|
|
263
|
+
imports.addReact('useMemo');
|
|
264
|
+
lines.push(` const [${name}, _${setter}Raw] = useState${typeAnnotation}(${lazyInitVal});`);
|
|
265
|
+
lines.push(` const ${setter} = useMemo(() => {`);
|
|
266
|
+
lines.push(` let _timer: ReturnType<typeof setTimeout> | null = null;`);
|
|
267
|
+
lines.push(` return (value: React.SetStateAction<${typeAnnotation ? props.type : 'any'}>) => {`);
|
|
268
|
+
lines.push(` if (_timer) clearTimeout(_timer);`);
|
|
269
|
+
lines.push(` _timer = setTimeout(() => _${setter}Raw(value), ${debounce});`);
|
|
270
|
+
lines.push(` };`);
|
|
271
|
+
lines.push(` }, []);`);
|
|
272
|
+
}
|
|
273
|
+
else if (safe) {
|
|
274
|
+
ctx.needsInkSafe = true;
|
|
275
|
+
imports.addReact('useMemo');
|
|
276
|
+
lines.push(` const [${name}, _${setter}Raw] = useState${typeAnnotation}(${lazyInitVal});`);
|
|
277
|
+
lines.push(` const ${setter} = useMemo(() => __inkSafe(_${setter}Raw), [_${setter}Raw]);`);
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
lines.push(` const [${name}, ${setter}] = useState${typeAnnotation}(${lazyInitVal});`);
|
|
281
|
+
}
|
|
190
282
|
}
|
|
191
283
|
return lines;
|
|
192
284
|
}
|
|
@@ -212,24 +304,70 @@ function generateStreamEffect(streamNode, imports) {
|
|
|
212
304
|
const name = props.name;
|
|
213
305
|
const source = props.source;
|
|
214
306
|
const append = props.append !== 'false'; // default true
|
|
307
|
+
const mode = props.mode;
|
|
308
|
+
const dispatch = props.dispatch;
|
|
215
309
|
if (name && source) {
|
|
216
310
|
imports.addReact('useEffect');
|
|
217
311
|
const setter = `set${capitalize(name)}`;
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
lines.push(`
|
|
312
|
+
if (mode === 'channel' && dispatch) {
|
|
313
|
+
// Channel mode: AsyncGenerator → dispatch function with cleanup
|
|
314
|
+
// Pattern: session.send() → drain chunks → dispatch(chunk)
|
|
315
|
+
lines.push(` useEffect(() => {`);
|
|
316
|
+
lines.push(` let cancelled = false;`);
|
|
317
|
+
lines.push(` const abortController = new AbortController();`);
|
|
318
|
+
lines.push(` (async () => {`);
|
|
319
|
+
lines.push(` try {`);
|
|
320
|
+
lines.push(` for await (const chunk of ${source}) {`);
|
|
321
|
+
lines.push(` if (cancelled) break;`);
|
|
322
|
+
lines.push(` ${dispatch}(chunk);`);
|
|
323
|
+
lines.push(` }`);
|
|
324
|
+
lines.push(` } catch (err) {`);
|
|
325
|
+
lines.push(` if (!cancelled) console.error('Stream error:', err);`);
|
|
326
|
+
lines.push(` }`);
|
|
327
|
+
lines.push(` })();`);
|
|
328
|
+
lines.push(` return () => { cancelled = true; abortController.abort(); };`);
|
|
329
|
+
lines.push(` }, [${source}]);`);
|
|
330
|
+
}
|
|
331
|
+
else if (mode === 'channel') {
|
|
332
|
+
// Channel mode without dispatch: iterate source directly into state
|
|
333
|
+
lines.push(` useEffect(() => {`);
|
|
334
|
+
lines.push(` let cancelled = false;`);
|
|
335
|
+
lines.push(` (async () => {`);
|
|
336
|
+
lines.push(` try {`);
|
|
337
|
+
lines.push(` for await (const chunk of ${source}) {`);
|
|
338
|
+
lines.push(` if (cancelled) break;`);
|
|
339
|
+
if (append) {
|
|
340
|
+
lines.push(` ${setter}(prev => [...prev, chunk]);`);
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
lines.push(` ${setter}(chunk);`);
|
|
344
|
+
}
|
|
345
|
+
lines.push(` }`);
|
|
346
|
+
lines.push(` } catch (err) {`);
|
|
347
|
+
lines.push(` if (!cancelled) console.error('Stream error:', err);`);
|
|
348
|
+
lines.push(` }`);
|
|
349
|
+
lines.push(` })();`);
|
|
350
|
+
lines.push(` return () => { cancelled = true; };`);
|
|
351
|
+
lines.push(` }, [${source}]);`);
|
|
225
352
|
}
|
|
226
353
|
else {
|
|
227
|
-
|
|
354
|
+
// Default mode: source is a function that returns an AsyncGenerator
|
|
355
|
+
lines.push(` useEffect(() => {`);
|
|
356
|
+
lines.push(` let cancelled = false;`);
|
|
357
|
+
lines.push(` (async () => {`);
|
|
358
|
+
lines.push(` for await (const chunk of ${source}()) {`);
|
|
359
|
+
lines.push(` if (cancelled) break;`);
|
|
360
|
+
if (append) {
|
|
361
|
+
lines.push(` ${setter}(prev => [...prev, chunk]);`);
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
lines.push(` ${setter}(chunk);`);
|
|
365
|
+
}
|
|
366
|
+
lines.push(` }`);
|
|
367
|
+
lines.push(` })();`);
|
|
368
|
+
lines.push(` return () => { cancelled = true; };`);
|
|
369
|
+
lines.push(` }, []);`);
|
|
228
370
|
}
|
|
229
|
-
lines.push(` }`);
|
|
230
|
-
lines.push(` })();`);
|
|
231
|
-
lines.push(` return () => { cancelled = true; };`);
|
|
232
|
-
lines.push(` }, []);`);
|
|
233
371
|
}
|
|
234
372
|
return lines;
|
|
235
373
|
}
|
|
@@ -245,14 +383,91 @@ function generateLogicEffect(logicNode, imports) {
|
|
|
245
383
|
imports.addReact('useEffect');
|
|
246
384
|
const dedented = dedent(code);
|
|
247
385
|
const depsStr = deps ? `[${deps}]` : '[]';
|
|
386
|
+
// Auto-cleanup: detect setInterval/setTimeout at top-level scope (not inside nested functions)
|
|
387
|
+
const hasCleanup = /return\s*\(\s*\)\s*=>/.test(dedented) || /return\s*\(\)\s*\{/.test(dedented);
|
|
388
|
+
// Only match if declaration appears before any function/arrow — i.e., at the effect's top level
|
|
389
|
+
const hasNestedFn = /(?:function\s|=>)/.test(dedented.split(/set(?:Interval|Timeout)\s*\(/)[0] || '');
|
|
390
|
+
const intervalMatch = hasNestedFn ? null : dedented.match(/(?:const|let|var)\s+(\w+)\s*=\s*setInterval\s*\(/);
|
|
391
|
+
const timeoutMatch = hasNestedFn ? null : dedented.match(/(?:const|let|var)\s+(\w+)\s*=\s*setTimeout\s*\(/);
|
|
248
392
|
lines.push(` useEffect(() => {`);
|
|
249
393
|
for (const line of dedented.split('\n')) {
|
|
250
394
|
lines.push(` ${line}`);
|
|
251
395
|
}
|
|
396
|
+
if (!hasCleanup && intervalMatch) {
|
|
397
|
+
lines.push(` return () => { clearInterval(${intervalMatch[1]}); };`);
|
|
398
|
+
}
|
|
399
|
+
else if (!hasCleanup && timeoutMatch) {
|
|
400
|
+
lines.push(` return () => { clearTimeout(${timeoutMatch[1]}); };`);
|
|
401
|
+
}
|
|
252
402
|
lines.push(` }, ${depsStr});`);
|
|
253
403
|
}
|
|
254
404
|
return lines;
|
|
255
405
|
}
|
|
406
|
+
// ── Focus hook → useFocus (Phase 3) ────────────────────────────────
|
|
407
|
+
function generateFocusHook(focusNode, imports) {
|
|
408
|
+
const lines = [];
|
|
409
|
+
const props = getProps(focusNode);
|
|
410
|
+
const name = props.name;
|
|
411
|
+
const autoFocus = props.autoFocus === 'true' || props.autoFocus === true;
|
|
412
|
+
const id = props.id;
|
|
413
|
+
if (name) {
|
|
414
|
+
imports.addInk('useFocus');
|
|
415
|
+
const opts = [];
|
|
416
|
+
if (autoFocus)
|
|
417
|
+
opts.push('autoFocus: true');
|
|
418
|
+
if (id)
|
|
419
|
+
opts.push(`id: '${id}'`);
|
|
420
|
+
const optsStr = opts.length > 0 ? `{ ${opts.join(', ')} }` : '';
|
|
421
|
+
lines.push(` const { isFocused: ${name} } = useFocus(${optsStr});`);
|
|
422
|
+
}
|
|
423
|
+
return lines;
|
|
424
|
+
}
|
|
425
|
+
// ── App exit hook → useApp (Phase 3) ───────────────────────────────
|
|
426
|
+
function generateAppExitHook(exitNode, imports) {
|
|
427
|
+
const lines = [];
|
|
428
|
+
const props = getProps(exitNode);
|
|
429
|
+
const on = props.on;
|
|
430
|
+
if (on) {
|
|
431
|
+
imports.addInk('useApp');
|
|
432
|
+
imports.addReact('useEffect');
|
|
433
|
+
const condition = isExpr(on) ? on.code : String(on);
|
|
434
|
+
lines.push(` const { exit } = useApp();`);
|
|
435
|
+
lines.push(` useEffect(() => { if (${condition}) exit(); }, [${condition}]);`);
|
|
436
|
+
}
|
|
437
|
+
return lines;
|
|
438
|
+
}
|
|
439
|
+
// ── Animation block → useEffect with setInterval ────────────────────
|
|
440
|
+
function generateAnimation(animNode, imports) {
|
|
441
|
+
const lines = [];
|
|
442
|
+
const props = getProps(animNode);
|
|
443
|
+
const name = props.name;
|
|
444
|
+
const interval = props.interval;
|
|
445
|
+
const update = isExpr(props.update) ? props.update.code : String(props.update || '');
|
|
446
|
+
const active = props.active;
|
|
447
|
+
if (name && interval && update) {
|
|
448
|
+
imports.addReact('useEffect');
|
|
449
|
+
const setter = `set${capitalize(name)}`;
|
|
450
|
+
if (active) {
|
|
451
|
+
const activeExpr = isExpr(active) ? active.code : String(active);
|
|
452
|
+
lines.push(` useEffect(() => {`);
|
|
453
|
+
lines.push(` if (!(${activeExpr})) return;`);
|
|
454
|
+
lines.push(` const _animId = setInterval(() => {`);
|
|
455
|
+
lines.push(` ${setter}(${update});`);
|
|
456
|
+
lines.push(` }, ${interval});`);
|
|
457
|
+
lines.push(` return () => clearInterval(_animId);`);
|
|
458
|
+
lines.push(` }, [${activeExpr}]);`);
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
lines.push(` useEffect(() => {`);
|
|
462
|
+
lines.push(` const _animId = setInterval(() => {`);
|
|
463
|
+
lines.push(` ${setter}(${update});`);
|
|
464
|
+
lines.push(` }, ${interval});`);
|
|
465
|
+
lines.push(` return () => clearInterval(_animId);`);
|
|
466
|
+
lines.push(` }, []);`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return lines;
|
|
470
|
+
}
|
|
256
471
|
// ── Callback block → useCallback (Feature #11) ─────────────────────────
|
|
257
472
|
function generateCallbackHook(callbackNode, imports) {
|
|
258
473
|
const lines = [];
|
|
@@ -295,6 +510,7 @@ function collectNestedOnNodes(node) {
|
|
|
295
510
|
return found;
|
|
296
511
|
}
|
|
297
512
|
// ── Generate useInput from an on-node ───────────────────────────────────
|
|
513
|
+
let _onHookCounter = 0;
|
|
298
514
|
function generateOnHook(onNode, imports) {
|
|
299
515
|
const lines = [];
|
|
300
516
|
const onProps = getProps(onNode);
|
|
@@ -305,9 +521,12 @@ function generateOnHook(onNode, imports) {
|
|
|
305
521
|
const key = onProps.key;
|
|
306
522
|
const handlerChild = (onNode.children || []).find((c) => c.type === 'handler');
|
|
307
523
|
const code = handlerChild ? getProps(handlerChild).code || '' : '';
|
|
308
|
-
// Use ref pattern for fresh closures —
|
|
309
|
-
|
|
310
|
-
|
|
524
|
+
// Use ref pattern with unique suffix for fresh closures — supports multiple on-nodes
|
|
525
|
+
const suffix = _onHookCounter === 0 ? '' : `_${_onHookCounter}`;
|
|
526
|
+
_onHookCounter++;
|
|
527
|
+
const refName = `_inputHandlerRef${suffix}`;
|
|
528
|
+
lines.push(` const ${refName} = useRef<(input: string, key: any) => void>(() => {});`);
|
|
529
|
+
lines.push(` ${refName}.current = (input: string, key: any) => {`);
|
|
311
530
|
if (key) {
|
|
312
531
|
lines.push(` if (!(${keyToCheck(key)})) return;`);
|
|
313
532
|
}
|
|
@@ -318,7 +537,7 @@ function generateOnHook(onNode, imports) {
|
|
|
318
537
|
}
|
|
319
538
|
}
|
|
320
539
|
lines.push(` };`);
|
|
321
|
-
lines.push(` useInput((input: string, key: any) =>
|
|
540
|
+
lines.push(` useInput((input: string, key: any) => ${refName}.current(input, key));`);
|
|
322
541
|
lines.push('');
|
|
323
542
|
}
|
|
324
543
|
return lines;
|
|
@@ -549,11 +768,15 @@ function renderInkSelectInput(p, indent, imports) {
|
|
|
549
768
|
const rawItems = p.items;
|
|
550
769
|
const items = isExpr(rawItems) ? rawItems.code : rawItems || '[]';
|
|
551
770
|
const onSelect = p.onSelect;
|
|
552
|
-
const
|
|
771
|
+
const onChange = p.onChange;
|
|
772
|
+
const selectProps = [`options={${items}}`];
|
|
553
773
|
if (onSelect) {
|
|
554
|
-
selectProps.push(`
|
|
774
|
+
selectProps.push(`onChange={${onSelect}}`);
|
|
775
|
+
}
|
|
776
|
+
else if (onChange) {
|
|
777
|
+
selectProps.push(`onChange={${onChange}}`);
|
|
555
778
|
}
|
|
556
|
-
return [`${indent}<
|
|
779
|
+
return [`${indent}<Select ${selectProps.join(' ')} />`];
|
|
557
780
|
}
|
|
558
781
|
function renderInkHandler(p, indent) {
|
|
559
782
|
const code = p.code || '';
|
|
@@ -602,6 +825,215 @@ function renderInkConditional(node, p, indent, imports) {
|
|
|
602
825
|
lines.push(`${indent})}`);
|
|
603
826
|
return lines;
|
|
604
827
|
}
|
|
828
|
+
// ── @inkjs/ui Component Renderers (Phase 3) ─────────────────────────────
|
|
829
|
+
function renderInkMultiSelect(p, indent, imports) {
|
|
830
|
+
imports.addInkUI('MultiSelect');
|
|
831
|
+
const rawOptions = p.options;
|
|
832
|
+
const options = isExpr(rawOptions) ? rawOptions.code : rawOptions || '[]';
|
|
833
|
+
const onChange = p.onChange;
|
|
834
|
+
const msProps = [`options={${options}}`];
|
|
835
|
+
if (onChange)
|
|
836
|
+
msProps.push(`onChange={${onChange}}`);
|
|
837
|
+
return [`${indent}<MultiSelect ${msProps.join(' ')} />`];
|
|
838
|
+
}
|
|
839
|
+
function renderInkConfirmInput(p, indent, imports) {
|
|
840
|
+
imports.addInkUI('ConfirmInput');
|
|
841
|
+
const ciProps = [];
|
|
842
|
+
if (p.onConfirm)
|
|
843
|
+
ciProps.push(`onConfirm={${p.onConfirm}}`);
|
|
844
|
+
if (p.onCancel)
|
|
845
|
+
ciProps.push(`onCancel={${p.onCancel}}`);
|
|
846
|
+
if (p.defaultChoice)
|
|
847
|
+
ciProps.push(`defaultChoice="${p.defaultChoice}"`);
|
|
848
|
+
if (p.submitOnEnter === 'false' || p.submitOnEnter === false)
|
|
849
|
+
ciProps.push('submitOnEnter={false}');
|
|
850
|
+
return [`${indent}<ConfirmInput ${ciProps.join(' ')} />`];
|
|
851
|
+
}
|
|
852
|
+
function renderInkPasswordInput(p, indent, imports) {
|
|
853
|
+
imports.addInkUI('PasswordInput');
|
|
854
|
+
const piProps = [];
|
|
855
|
+
const bind = p.bind;
|
|
856
|
+
if (p.placeholder)
|
|
857
|
+
piProps.push(`placeholder=${JSON.stringify(p.placeholder)}`);
|
|
858
|
+
if (bind) {
|
|
859
|
+
piProps.push(`onChange={set${capitalize(bind)}}`);
|
|
860
|
+
}
|
|
861
|
+
if (p.onChange)
|
|
862
|
+
piProps.push(`onChange={${p.onChange}}`);
|
|
863
|
+
return [`${indent}<PasswordInput ${piProps.join(' ')} />`];
|
|
864
|
+
}
|
|
865
|
+
function renderInkStatusMessage(node, p, indent, imports) {
|
|
866
|
+
imports.addInkUI('StatusMessage');
|
|
867
|
+
const variant = p.variant || 'info';
|
|
868
|
+
const lines = [];
|
|
869
|
+
lines.push(`${indent}<StatusMessage variant="${variant}">`);
|
|
870
|
+
for (const child of node.children || []) {
|
|
871
|
+
lines.push(...renderInkNode(child, `${indent} `, imports));
|
|
872
|
+
}
|
|
873
|
+
lines.push(`${indent}</StatusMessage>`);
|
|
874
|
+
return lines;
|
|
875
|
+
}
|
|
876
|
+
function renderInkAlert(node, p, indent, imports) {
|
|
877
|
+
imports.addInkUI('Alert');
|
|
878
|
+
const variant = p.variant || 'info';
|
|
879
|
+
const title = p.title;
|
|
880
|
+
const alertProps = [`variant="${variant}"`];
|
|
881
|
+
if (title)
|
|
882
|
+
alertProps.push(`title=${JSON.stringify(title)}`);
|
|
883
|
+
const lines = [];
|
|
884
|
+
lines.push(`${indent}<Alert ${alertProps.join(' ')}>`);
|
|
885
|
+
for (const child of node.children || []) {
|
|
886
|
+
lines.push(...renderInkNode(child, `${indent} `, imports));
|
|
887
|
+
}
|
|
888
|
+
lines.push(`${indent}</Alert>`);
|
|
889
|
+
return lines;
|
|
890
|
+
}
|
|
891
|
+
function renderInkOrderedList(node, indent, imports) {
|
|
892
|
+
imports.addInkUI('OrderedList');
|
|
893
|
+
const lines = [];
|
|
894
|
+
lines.push(`${indent}<OrderedList>`);
|
|
895
|
+
for (const child of node.children || []) {
|
|
896
|
+
lines.push(`${indent} <OrderedList.Item>`);
|
|
897
|
+
lines.push(...renderInkNode(child, `${indent} `, imports));
|
|
898
|
+
lines.push(`${indent} </OrderedList.Item>`);
|
|
899
|
+
}
|
|
900
|
+
lines.push(`${indent}</OrderedList>`);
|
|
901
|
+
return lines;
|
|
902
|
+
}
|
|
903
|
+
function renderInkUnorderedList(node, indent, imports) {
|
|
904
|
+
imports.addInkUI('UnorderedList');
|
|
905
|
+
const lines = [];
|
|
906
|
+
lines.push(`${indent}<UnorderedList>`);
|
|
907
|
+
for (const child of node.children || []) {
|
|
908
|
+
lines.push(`${indent} <UnorderedList.Item>`);
|
|
909
|
+
lines.push(...renderInkNode(child, `${indent} `, imports));
|
|
910
|
+
lines.push(`${indent} </UnorderedList.Item>`);
|
|
911
|
+
}
|
|
912
|
+
lines.push(`${indent}</UnorderedList>`);
|
|
913
|
+
return lines;
|
|
914
|
+
}
|
|
915
|
+
function renderInkStaticLog(node, p, indent, imports) {
|
|
916
|
+
imports.addInk('Static');
|
|
917
|
+
imports.addInk('Text');
|
|
918
|
+
const rawItems = p.items;
|
|
919
|
+
const items = isExpr(rawItems) ? rawItems.code : rawItems || '[]';
|
|
920
|
+
const lines = [];
|
|
921
|
+
lines.push(`${indent}<Static items={${items}}>`);
|
|
922
|
+
lines.push(`${indent} {(item: any) => (`);
|
|
923
|
+
if (node.children && node.children.length > 1) {
|
|
924
|
+
// Multiple children need a fragment wrapper
|
|
925
|
+
lines.push(`${indent} <>`);
|
|
926
|
+
for (const child of node.children) {
|
|
927
|
+
lines.push(...renderInkNode(child, `${indent} `, imports));
|
|
928
|
+
}
|
|
929
|
+
lines.push(`${indent} </>`);
|
|
930
|
+
}
|
|
931
|
+
else if (node.children && node.children.length === 1) {
|
|
932
|
+
lines.push(...renderInkNode(node.children[0], `${indent} `, imports));
|
|
933
|
+
}
|
|
934
|
+
else {
|
|
935
|
+
lines.push(`${indent} <Text>{String(item)}</Text>`);
|
|
936
|
+
}
|
|
937
|
+
lines.push(`${indent} )}`);
|
|
938
|
+
lines.push(`${indent}</Static>`);
|
|
939
|
+
return lines;
|
|
940
|
+
}
|
|
941
|
+
function renderInkNewline(p, indent, imports) {
|
|
942
|
+
imports.addInk('Newline');
|
|
943
|
+
const count = p.count;
|
|
944
|
+
if (count && Number(count) > 1) {
|
|
945
|
+
return [`${indent}<Newline count={${count}} />`];
|
|
946
|
+
}
|
|
947
|
+
return [`${indent}<Newline />`];
|
|
948
|
+
}
|
|
949
|
+
// ── Layout Primitives (Phase 4) ──────────────────────────────────────────
|
|
950
|
+
function renderLayoutRow(node, p, indent, imports) {
|
|
951
|
+
imports.addInk('Box');
|
|
952
|
+
const gap = p.gap;
|
|
953
|
+
const padding = p.padding;
|
|
954
|
+
const boxProps = ['flexDirection="row"'];
|
|
955
|
+
if (gap)
|
|
956
|
+
boxProps.push(`gap={${gap}}`);
|
|
957
|
+
if (padding)
|
|
958
|
+
boxProps.push(`padding={${padding}}`);
|
|
959
|
+
const lines = [];
|
|
960
|
+
lines.push(`${indent}<Box ${boxProps.join(' ')}>`);
|
|
961
|
+
for (const child of node.children || []) {
|
|
962
|
+
lines.push(...renderInkNode(child, `${indent} `, imports));
|
|
963
|
+
}
|
|
964
|
+
lines.push(`${indent}</Box>`);
|
|
965
|
+
return lines;
|
|
966
|
+
}
|
|
967
|
+
function renderLayoutCol(node, p, indent, imports) {
|
|
968
|
+
imports.addInk('Box');
|
|
969
|
+
const flex = p.flex;
|
|
970
|
+
const width = p.width;
|
|
971
|
+
const boxProps = ['flexDirection="column"'];
|
|
972
|
+
if (flex)
|
|
973
|
+
boxProps.push(`flexGrow={${flex}}`);
|
|
974
|
+
if (width)
|
|
975
|
+
boxProps.push(`width={${width}}`);
|
|
976
|
+
const lines = [];
|
|
977
|
+
lines.push(`${indent}<Box ${boxProps.join(' ')}>`);
|
|
978
|
+
for (const child of node.children || []) {
|
|
979
|
+
lines.push(...renderInkNode(child, `${indent} `, imports));
|
|
980
|
+
}
|
|
981
|
+
lines.push(`${indent}</Box>`);
|
|
982
|
+
return lines;
|
|
983
|
+
}
|
|
984
|
+
function renderLayoutStack(node, p, indent, imports) {
|
|
985
|
+
imports.addInk('Box');
|
|
986
|
+
const padding = p.padding;
|
|
987
|
+
const gap = p.gap;
|
|
988
|
+
const boxProps = ['flexDirection="column"'];
|
|
989
|
+
if (padding)
|
|
990
|
+
boxProps.push(`padding={${padding}}`);
|
|
991
|
+
if (gap)
|
|
992
|
+
boxProps.push(`gap={${gap}}`);
|
|
993
|
+
const lines = [];
|
|
994
|
+
lines.push(`${indent}<Box ${boxProps.join(' ')}>`);
|
|
995
|
+
for (const child of node.children || []) {
|
|
996
|
+
lines.push(...renderInkNode(child, `${indent} `, imports));
|
|
997
|
+
}
|
|
998
|
+
lines.push(`${indent}</Box>`);
|
|
999
|
+
return lines;
|
|
1000
|
+
}
|
|
1001
|
+
function renderSpacer(indent, imports) {
|
|
1002
|
+
imports.addInk('Box');
|
|
1003
|
+
return [`${indent}<Box flexGrow={1} />`];
|
|
1004
|
+
}
|
|
1005
|
+
/** Cross-file import collector — populated by screen-embed with from= */
|
|
1006
|
+
const _crossFileImports = new Map();
|
|
1007
|
+
function renderScreenEmbed(p, indent) {
|
|
1008
|
+
const screen = p.screen;
|
|
1009
|
+
if (!screen)
|
|
1010
|
+
return [];
|
|
1011
|
+
const from = p.from;
|
|
1012
|
+
// Track cross-file import if from= is specified
|
|
1013
|
+
if (from) {
|
|
1014
|
+
// Normalize: strip .kern extension, add .js for ESM
|
|
1015
|
+
const importPath = from.replace(/\.kern$/, '.js');
|
|
1016
|
+
if (!_crossFileImports.has(importPath))
|
|
1017
|
+
_crossFileImports.set(importPath, new Set());
|
|
1018
|
+
_crossFileImports.get(importPath).add(screen);
|
|
1019
|
+
}
|
|
1020
|
+
// Collect all non-meta props as component props
|
|
1021
|
+
const propEntries = Object.entries(p).filter(([k]) => k !== 'screen' && k !== 'from' && k !== 'styles' && k !== 'themeRefs');
|
|
1022
|
+
const propsStr = propEntries
|
|
1023
|
+
.map(([k, v]) => {
|
|
1024
|
+
if (isExpr(v))
|
|
1025
|
+
return `${k}={${v.code}}`;
|
|
1026
|
+
const s = String(v);
|
|
1027
|
+
// Preserve non-string literals as JSX expressions
|
|
1028
|
+
if (s === 'true' || s === 'false')
|
|
1029
|
+
return `${k}={${s}}`;
|
|
1030
|
+
if (!Number.isNaN(Number(s)) && s !== '')
|
|
1031
|
+
return `${k}={${s}}`;
|
|
1032
|
+
return `${k}=${JSON.stringify(s)}`;
|
|
1033
|
+
})
|
|
1034
|
+
.join(' ');
|
|
1035
|
+
return [`${indent}<${screen}${propsStr ? ` ${propsStr}` : ''} />`];
|
|
1036
|
+
}
|
|
605
1037
|
// ── Node renderer → JSX (dispatcher) ─────────────────────────────────────
|
|
606
1038
|
function renderInkNode(node, indent, imports) {
|
|
607
1039
|
const p = getProps(node);
|
|
@@ -630,6 +1062,34 @@ function renderInkNode(node, indent, imports) {
|
|
|
630
1062
|
return renderInkTextInput(p, indent, imports);
|
|
631
1063
|
case 'select-input':
|
|
632
1064
|
return renderInkSelectInput(p, indent, imports);
|
|
1065
|
+
case 'multi-select':
|
|
1066
|
+
return renderInkMultiSelect(p, indent, imports);
|
|
1067
|
+
case 'confirm-input':
|
|
1068
|
+
return renderInkConfirmInput(p, indent, imports);
|
|
1069
|
+
case 'password-input':
|
|
1070
|
+
return renderInkPasswordInput(p, indent, imports);
|
|
1071
|
+
case 'status-message':
|
|
1072
|
+
return renderInkStatusMessage(node, p, indent, imports);
|
|
1073
|
+
case 'alert':
|
|
1074
|
+
return renderInkAlert(node, p, indent, imports);
|
|
1075
|
+
case 'ordered-list':
|
|
1076
|
+
return renderInkOrderedList(node, indent, imports);
|
|
1077
|
+
case 'unordered-list':
|
|
1078
|
+
return renderInkUnorderedList(node, indent, imports);
|
|
1079
|
+
case 'static-log':
|
|
1080
|
+
return renderInkStaticLog(node, p, indent, imports);
|
|
1081
|
+
case 'newline':
|
|
1082
|
+
return renderInkNewline(p, indent, imports);
|
|
1083
|
+
case 'layout-row':
|
|
1084
|
+
return renderLayoutRow(node, p, indent, imports);
|
|
1085
|
+
case 'layout-col':
|
|
1086
|
+
return renderLayoutCol(node, p, indent, imports);
|
|
1087
|
+
case 'layout-stack':
|
|
1088
|
+
return renderLayoutStack(node, p, indent, imports);
|
|
1089
|
+
case 'spacer':
|
|
1090
|
+
return renderSpacer(indent, imports);
|
|
1091
|
+
case 'screen-embed':
|
|
1092
|
+
return renderScreenEmbed(p, indent);
|
|
633
1093
|
case 'handler':
|
|
634
1094
|
return renderInkHandler(p, indent);
|
|
635
1095
|
case 'each':
|
|
@@ -640,14 +1100,22 @@ function renderInkNode(node, indent, imports) {
|
|
|
640
1100
|
case 'ref':
|
|
641
1101
|
case 'stream':
|
|
642
1102
|
case 'logic':
|
|
1103
|
+
case 'effect':
|
|
643
1104
|
case 'callback':
|
|
1105
|
+
case 'memo':
|
|
1106
|
+
case 'render':
|
|
1107
|
+
case 'prop':
|
|
644
1108
|
case 'on':
|
|
1109
|
+
case 'animation':
|
|
1110
|
+
case 'derive':
|
|
1111
|
+
case 'focus':
|
|
1112
|
+
case 'app-exit':
|
|
645
1113
|
return [];
|
|
646
1114
|
default: {
|
|
647
1115
|
const lines = [];
|
|
648
1116
|
if (isCoreNode(node.type)) {
|
|
649
1117
|
if (node.type === 'machine') {
|
|
650
|
-
lines.push(...generateMachineReducer(node).map((l) => l));
|
|
1118
|
+
lines.push(...generateMachineReducer(node, { safeDispatch: true, emitImport: false }).map((l) => l));
|
|
651
1119
|
}
|
|
652
1120
|
else {
|
|
653
1121
|
lines.push(...generateCoreNode(node));
|
|
@@ -663,14 +1131,225 @@ function renderInkNode(node, indent, imports) {
|
|
|
663
1131
|
}
|
|
664
1132
|
}
|
|
665
1133
|
}
|
|
1134
|
+
// ── Reusable screen body compiler ────────────────────────────────────────
|
|
1135
|
+
const NON_UI_TYPES = new Set([
|
|
1136
|
+
'state',
|
|
1137
|
+
'ref',
|
|
1138
|
+
'on',
|
|
1139
|
+
'stream',
|
|
1140
|
+
'logic',
|
|
1141
|
+
'effect',
|
|
1142
|
+
'callback',
|
|
1143
|
+
'memo',
|
|
1144
|
+
'render',
|
|
1145
|
+
'prop',
|
|
1146
|
+
'animation',
|
|
1147
|
+
'derive',
|
|
1148
|
+
'focus',
|
|
1149
|
+
'app-exit',
|
|
1150
|
+
]);
|
|
1151
|
+
function isInkUiNode(type) {
|
|
1152
|
+
return (type === 'each' ||
|
|
1153
|
+
type === 'conditional' ||
|
|
1154
|
+
type === 'select' ||
|
|
1155
|
+
type === 'model' ||
|
|
1156
|
+
type === 'repository' ||
|
|
1157
|
+
type === 'dependency' ||
|
|
1158
|
+
type === 'cache');
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Compile a screen node's full body: all hooks, effects, and JSX return.
|
|
1162
|
+
* Used for both primary and secondary screens to ensure feature parity.
|
|
1163
|
+
*/
|
|
1164
|
+
function compileScreenBody(screenNode, imports) {
|
|
1165
|
+
const bodyLines = [];
|
|
1166
|
+
const stateCtx = { needsInkSafe: false };
|
|
1167
|
+
// Collect all node categories from this screen
|
|
1168
|
+
const stateNodes = getChildren(screenNode, 'state');
|
|
1169
|
+
const refNodes = getChildren(screenNode, 'ref');
|
|
1170
|
+
const onNodes = getChildren(screenNode, 'on');
|
|
1171
|
+
const streamNodes = getChildren(screenNode, 'stream');
|
|
1172
|
+
const logicNodes = [...getChildren(screenNode, 'logic'), ...getChildren(screenNode, 'effect')];
|
|
1173
|
+
const callbackNodes = getChildren(screenNode, 'callback');
|
|
1174
|
+
const animationNodes = getChildren(screenNode, 'animation');
|
|
1175
|
+
const deriveNodes = getChildren(screenNode, 'derive');
|
|
1176
|
+
const focusNodes = getChildren(screenNode, 'focus');
|
|
1177
|
+
const appExitNodes = getChildren(screenNode, 'app-exit');
|
|
1178
|
+
const memoNodes = getChildren(screenNode, 'memo');
|
|
1179
|
+
const renderNode = getChildren(screenNode, 'render')[0];
|
|
1180
|
+
const uiChildren = (screenNode.children || []).filter((c) => !NON_UI_TYPES.has(c.type) && (!isCoreNode(c.type) || isInkUiNode(c.type)));
|
|
1181
|
+
// Hoist nested on-nodes from UI tree
|
|
1182
|
+
const nestedOnNodes = collectNestedOnNodes(screenNode);
|
|
1183
|
+
const allOnNodes = [...onNodes];
|
|
1184
|
+
for (const nested of nestedOnNodes) {
|
|
1185
|
+
if (!onNodes.includes(nested))
|
|
1186
|
+
allOnNodes.push(nested);
|
|
1187
|
+
}
|
|
1188
|
+
// State hooks
|
|
1189
|
+
for (const stateNode of stateNodes) {
|
|
1190
|
+
bodyLines.push(...generateStateHook(stateNode, imports, stateCtx));
|
|
1191
|
+
}
|
|
1192
|
+
if (stateNodes.length > 0)
|
|
1193
|
+
bodyLines.push('');
|
|
1194
|
+
// Ref hooks
|
|
1195
|
+
for (const refNode of refNodes) {
|
|
1196
|
+
bodyLines.push(...generateRefHook(refNode, imports));
|
|
1197
|
+
}
|
|
1198
|
+
if (refNodes.length > 0)
|
|
1199
|
+
bodyLines.push('');
|
|
1200
|
+
// Focus hooks
|
|
1201
|
+
for (const focusNode of focusNodes) {
|
|
1202
|
+
bodyLines.push(...generateFocusHook(focusNode, imports));
|
|
1203
|
+
}
|
|
1204
|
+
if (focusNodes.length > 0)
|
|
1205
|
+
bodyLines.push('');
|
|
1206
|
+
// App exit hooks
|
|
1207
|
+
for (const exitNode of appExitNodes) {
|
|
1208
|
+
bodyLines.push(...generateAppExitHook(exitNode, imports));
|
|
1209
|
+
}
|
|
1210
|
+
if (appExitNodes.length > 0)
|
|
1211
|
+
bodyLines.push('');
|
|
1212
|
+
// Memo hooks
|
|
1213
|
+
for (const memoNode of memoNodes) {
|
|
1214
|
+
const mp = getProps(memoNode);
|
|
1215
|
+
const mName = mp.name;
|
|
1216
|
+
const mDeps = mp.deps || '';
|
|
1217
|
+
const mDepsArr = mDeps ? `[${mDeps}]` : '[]';
|
|
1218
|
+
const handlerChild = (memoNode.children || []).find((c) => c.type === 'handler');
|
|
1219
|
+
const code = handlerChild ? getProps(handlerChild).code || '' : '';
|
|
1220
|
+
if (mName && code) {
|
|
1221
|
+
imports.addReact('useMemo');
|
|
1222
|
+
const dedented = dedent(code);
|
|
1223
|
+
bodyLines.push(` const ${mName} = useMemo(() => {`);
|
|
1224
|
+
for (const line of dedented.split('\n')) {
|
|
1225
|
+
bodyLines.push(` ${line}`);
|
|
1226
|
+
}
|
|
1227
|
+
bodyLines.push(` }, ${mDepsArr});`);
|
|
1228
|
+
bodyLines.push('');
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
// Callback hooks
|
|
1232
|
+
for (const callbackNode of callbackNodes) {
|
|
1233
|
+
bodyLines.push(...generateCallbackHook(callbackNode, imports));
|
|
1234
|
+
bodyLines.push('');
|
|
1235
|
+
}
|
|
1236
|
+
// on event=key → useInput() hooks
|
|
1237
|
+
for (const onNode of allOnNodes) {
|
|
1238
|
+
bodyLines.push(...generateOnHook(onNode, imports));
|
|
1239
|
+
}
|
|
1240
|
+
// Stream effects
|
|
1241
|
+
for (const streamNode of streamNodes) {
|
|
1242
|
+
bodyLines.push(...generateStreamEffect(streamNode, imports));
|
|
1243
|
+
bodyLines.push('');
|
|
1244
|
+
}
|
|
1245
|
+
// Logic effects
|
|
1246
|
+
for (const logicNode of logicNodes) {
|
|
1247
|
+
bodyLines.push(...generateLogicEffect(logicNode, imports));
|
|
1248
|
+
bodyLines.push('');
|
|
1249
|
+
}
|
|
1250
|
+
// Animation effects
|
|
1251
|
+
for (const animNode of animationNodes) {
|
|
1252
|
+
bodyLines.push(...generateAnimation(animNode, imports));
|
|
1253
|
+
bodyLines.push('');
|
|
1254
|
+
}
|
|
1255
|
+
// Derive nodes → useMemo with auto-dep tracking
|
|
1256
|
+
for (const deriveNode of deriveNodes) {
|
|
1257
|
+
const dp = getProps(deriveNode);
|
|
1258
|
+
const dName = dp.name;
|
|
1259
|
+
const dExpr = isExpr(dp.expr) ? dp.expr.code : dp.expr || '';
|
|
1260
|
+
const dDeps = dp.deps;
|
|
1261
|
+
const dType = dp.type;
|
|
1262
|
+
if (dName && dExpr) {
|
|
1263
|
+
imports.addReact('useMemo');
|
|
1264
|
+
const typeAnnotation = dType ? `<${dType}>` : '';
|
|
1265
|
+
let depsStr;
|
|
1266
|
+
if (dDeps) {
|
|
1267
|
+
depsStr = `[${dDeps}]`;
|
|
1268
|
+
}
|
|
1269
|
+
else {
|
|
1270
|
+
const sNames = stateNodes.map((s) => getProps(s).name).filter(Boolean);
|
|
1271
|
+
const rNames = refNodes
|
|
1272
|
+
.map((r) => {
|
|
1273
|
+
const rn = getProps(r).name;
|
|
1274
|
+
return rn ? (rn.endsWith('Ref') ? rn : `${rn}Ref`) : '';
|
|
1275
|
+
})
|
|
1276
|
+
.filter(Boolean);
|
|
1277
|
+
const allNames = [...sNames, ...rNames];
|
|
1278
|
+
const autoDeps = allNames.filter((n) => new RegExp(`\\b${n}\\b`).test(dExpr));
|
|
1279
|
+
depsStr = `[${autoDeps.join(', ')}]`;
|
|
1280
|
+
}
|
|
1281
|
+
bodyLines.push(` const ${dName} = useMemo${typeAnnotation}(() => ${dExpr}, ${depsStr});`);
|
|
1282
|
+
bodyLines.push('');
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
// JSX return — auto-insert return when handler body is a bare JSX expression
|
|
1286
|
+
if (renderNode) {
|
|
1287
|
+
const handlerChild = (renderNode.children || []).find((c) => c.type === 'handler');
|
|
1288
|
+
const code = handlerChild ? getProps(handlerChild).code || '' : '';
|
|
1289
|
+
if (code.trim()) {
|
|
1290
|
+
const dedented = dedent(code);
|
|
1291
|
+
const trimmed = dedented.trim();
|
|
1292
|
+
if (trimmed.includes('return ') || trimmed.includes('return(')) {
|
|
1293
|
+
// User wrote explicit return — emit as-is
|
|
1294
|
+
for (const line of dedented.split('\n')) {
|
|
1295
|
+
bodyLines.push(` ${line}`);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
else {
|
|
1299
|
+
// Bare expression (likely JSX) — wrap in return()
|
|
1300
|
+
bodyLines.push(' return (');
|
|
1301
|
+
for (const line of dedented.split('\n')) {
|
|
1302
|
+
bodyLines.push(` ${line}`);
|
|
1303
|
+
}
|
|
1304
|
+
bodyLines.push(' );');
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
else {
|
|
1308
|
+
bodyLines.push(' return null;');
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
else {
|
|
1312
|
+
imports.addInk('Box');
|
|
1313
|
+
bodyLines.push(' return (');
|
|
1314
|
+
bodyLines.push(' <Box flexDirection="column">');
|
|
1315
|
+
for (const child of uiChildren) {
|
|
1316
|
+
bodyLines.push(...renderInkNode(child, ' ', imports));
|
|
1317
|
+
}
|
|
1318
|
+
bodyLines.push(' </Box>');
|
|
1319
|
+
bodyLines.push(' );');
|
|
1320
|
+
}
|
|
1321
|
+
// Auto-detect React hooks referenced in handler bodies but not yet in the import tracker.
|
|
1322
|
+
// Covers cases where user code calls hooks inline (e.g. useEffect in a render handler)
|
|
1323
|
+
// rather than via dedicated KERN nodes.
|
|
1324
|
+
const bodyText = bodyLines.join('\n');
|
|
1325
|
+
for (const hook of [
|
|
1326
|
+
'useEffect',
|
|
1327
|
+
'useState',
|
|
1328
|
+
'useMemo',
|
|
1329
|
+
'useCallback',
|
|
1330
|
+
'useRef',
|
|
1331
|
+
'useReducer',
|
|
1332
|
+
'useContext',
|
|
1333
|
+
'useLayoutEffect',
|
|
1334
|
+
]) {
|
|
1335
|
+
if (bodyText.includes(hook))
|
|
1336
|
+
imports.addReact(hook);
|
|
1337
|
+
}
|
|
1338
|
+
return { bodyLines, stateCtx };
|
|
1339
|
+
}
|
|
666
1340
|
// ── Main export ──────────────────────────────────────────────────────────
|
|
667
1341
|
export function transpileInk(root, _config) {
|
|
1342
|
+
_onHookCounter = 0; // Reset per-component counter
|
|
1343
|
+
_crossFileImports.clear(); // Reset cross-file imports
|
|
668
1344
|
const sourceMap = [];
|
|
669
1345
|
const imports = new ImportTracker();
|
|
670
1346
|
const lines = [];
|
|
671
|
-
// Handle file-level AST: find
|
|
672
|
-
const
|
|
673
|
-
const
|
|
1347
|
+
// Handle file-level AST: find screen node(s), keep siblings as file-level nodes
|
|
1348
|
+
const allScreenNodes = root.type === 'screen' ? [root] : (root.children || []).filter((c) => c.type === 'screen');
|
|
1349
|
+
const screenNode = allScreenNodes.length > 0 ? allScreenNodes[allScreenNodes.length - 1] : root;
|
|
1350
|
+
const fileLevelNodes = root.type === 'screen' ? [] : (root.children || []).filter((c) => c.type !== 'screen');
|
|
1351
|
+
// Secondary screens (all except the last/default one)
|
|
1352
|
+
const secondaryScreens = allScreenNodes.slice(0, -1);
|
|
674
1353
|
const screenProps = getProps(screenNode);
|
|
675
1354
|
const screenName = screenProps.name || 'App';
|
|
676
1355
|
// Component props from screen attributes OR prop child nodes
|
|
@@ -695,48 +1374,15 @@ export function transpileInk(root, _config) {
|
|
|
695
1374
|
})
|
|
696
1375
|
.join('; ')} }`
|
|
697
1376
|
: '';
|
|
698
|
-
// Separate node categories — search inside the screen node
|
|
699
|
-
const stateNodes = getChildren(screenNode, 'state');
|
|
700
|
-
const refNodes = getChildren(screenNode, 'ref');
|
|
701
|
-
const onNodes = getChildren(screenNode, 'on');
|
|
702
|
-
const streamNodes = getChildren(screenNode, 'stream');
|
|
703
|
-
const logicNodes = [...getChildren(screenNode, 'logic'), ...getChildren(screenNode, 'effect')];
|
|
704
|
-
const callbackNodes = getChildren(screenNode, 'callback');
|
|
705
|
-
const isInkUiNode = (type) => type === 'each' ||
|
|
706
|
-
type === 'conditional' ||
|
|
707
|
-
type === 'select' ||
|
|
708
|
-
type === 'model' ||
|
|
709
|
-
type === 'repository' ||
|
|
710
|
-
type === 'dependency' ||
|
|
711
|
-
type === 'cache';
|
|
712
1377
|
// File-level imports go before component; file-level fn/const go after
|
|
1378
|
+
// Collect import nodes from ALL screens (not just primary) so user imports aren't dropped
|
|
1379
|
+
const secondaryImports = secondaryScreens.flatMap((s) => (s.children || []).filter((c) => c.type === 'import'));
|
|
713
1380
|
const coreChildren = [
|
|
714
1381
|
...fileLevelNodes.filter((c) => isCoreNode(c.type) && c.type !== 'screen' && c.type !== 'fn' && c.type !== 'const'),
|
|
715
1382
|
...(screenNode.children || []).filter((c) => isCoreNode(c.type) && c.type !== 'on' && !isInkUiNode(c.type)),
|
|
1383
|
+
...secondaryImports,
|
|
716
1384
|
];
|
|
717
|
-
const memoNodes = getChildren(screenNode, 'memo');
|
|
718
|
-
const renderNode = getChildren(screenNode, 'render')[0];
|
|
719
|
-
const uiChildren = (screenNode.children || []).filter((c) => c.type !== 'state' &&
|
|
720
|
-
c.type !== 'ref' &&
|
|
721
|
-
c.type !== 'on' &&
|
|
722
|
-
c.type !== 'stream' &&
|
|
723
|
-
c.type !== 'logic' &&
|
|
724
|
-
c.type !== 'effect' &&
|
|
725
|
-
c.type !== 'callback' &&
|
|
726
|
-
c.type !== 'memo' &&
|
|
727
|
-
c.type !== 'render' &&
|
|
728
|
-
c.type !== 'prop' &&
|
|
729
|
-
(!isCoreNode(c.type) || isInkUiNode(c.type)));
|
|
730
1385
|
const fileLevelFns = fileLevelNodes.filter((c) => c.type === 'fn' || c.type === 'const');
|
|
731
|
-
// Bug #1: Collect nested on-nodes from UI tree and hoist to component level
|
|
732
|
-
const nestedOnNodes = collectNestedOnNodes(screenNode);
|
|
733
|
-
// Deduplicate — top-level on-nodes are already in onNodes
|
|
734
|
-
const allOnNodes = [...onNodes];
|
|
735
|
-
for (const nested of nestedOnNodes) {
|
|
736
|
-
if (!onNodes.includes(nested)) {
|
|
737
|
-
allOnNodes.push(nested);
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
1386
|
// ── Core nodes emitted above component (types, interfaces, machines, events) ──
|
|
741
1387
|
const coreLines = [];
|
|
742
1388
|
if (coreChildren.length > 0) {
|
|
@@ -744,7 +1390,7 @@ export function transpileInk(root, _config) {
|
|
|
744
1390
|
for (const child of coreChildren) {
|
|
745
1391
|
if (child.type === 'machine') {
|
|
746
1392
|
imports.addReact('useReducer');
|
|
747
|
-
coreLines.push(...generateMachineReducer(child));
|
|
1393
|
+
coreLines.push(...generateMachineReducer(child, { safeDispatch: true, emitImport: false }));
|
|
748
1394
|
}
|
|
749
1395
|
else if (child.type === 'import') {
|
|
750
1396
|
// Merge react/ink imports into tracker to avoid duplicates
|
|
@@ -776,102 +1422,114 @@ export function transpileInk(root, _config) {
|
|
|
776
1422
|
coreLines.push('');
|
|
777
1423
|
}
|
|
778
1424
|
}
|
|
779
|
-
// ── Component body ──
|
|
780
|
-
const bodyLines =
|
|
781
|
-
//
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
for (const refNode of refNodes) {
|
|
789
|
-
bodyLines.push(...generateRefHook(refNode, imports));
|
|
1425
|
+
// ── Component body (via shared compileScreenBody) ──
|
|
1426
|
+
const { bodyLines, stateCtx } = compileScreenBody(screenNode, imports);
|
|
1427
|
+
// ── Assemble ──
|
|
1428
|
+
// Note: imports.emit() is deferred to AFTER secondary screens + default component
|
|
1429
|
+
// so all required imports are tracked before emission.
|
|
1430
|
+
const componentLines = [];
|
|
1431
|
+
// Core nodes
|
|
1432
|
+
if (coreLines.length > 0) {
|
|
1433
|
+
componentLines.push(...coreLines);
|
|
790
1434
|
}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
const
|
|
796
|
-
const
|
|
797
|
-
const
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
for (const line of dedented.split('\n')) {
|
|
806
|
-
bodyLines.push(` ${line}`);
|
|
807
|
-
}
|
|
808
|
-
bodyLines.push(` }, ${mDepsArr});`);
|
|
809
|
-
bodyLines.push('');
|
|
1435
|
+
// Secondary screen components — full compilation via shared compileScreenBody
|
|
1436
|
+
for (const secScreen of secondaryScreens) {
|
|
1437
|
+
const secProps = getProps(secScreen);
|
|
1438
|
+
const secName = secProps.name || 'Component';
|
|
1439
|
+
const secPropsAttr = secProps.props;
|
|
1440
|
+
const secPropChildren = getChildren(secScreen, 'prop');
|
|
1441
|
+
const secPropParts = secPropsAttr ? splitPropsRespectingDepth(secPropsAttr) : [];
|
|
1442
|
+
for (const pc of secPropChildren) {
|
|
1443
|
+
const pp = getProps(pc);
|
|
1444
|
+
const pn = pp.name;
|
|
1445
|
+
const pt = pp.type || 'any';
|
|
1446
|
+
const opt = pp.optional === 'true' || pp.optional === true;
|
|
1447
|
+
if (pn)
|
|
1448
|
+
secPropParts.push(`${pn}${opt ? '?' : ''}:${pt}`);
|
|
810
1449
|
}
|
|
1450
|
+
const secParam = secPropParts.length > 0
|
|
1451
|
+
? `{ ${secPropParts.map((p) => p.trim().split(':')[0].replace('?', '').trim()).join(', ')} }: { ${secPropParts
|
|
1452
|
+
.map((p) => {
|
|
1453
|
+
const t = p.trim();
|
|
1454
|
+
return t.includes(':') ? t : `${t}: any`;
|
|
1455
|
+
})
|
|
1456
|
+
.join('; ')} }`
|
|
1457
|
+
: '';
|
|
1458
|
+
// Full body compilation — same pipeline as primary screen
|
|
1459
|
+
const { bodyLines: secBodyLines, stateCtx: secCtx } = compileScreenBody(secScreen, imports);
|
|
1460
|
+
const secExportAttr = secProps.export;
|
|
1461
|
+
const secExportKw = secExportAttr === 'default' ? 'export default' : 'export';
|
|
1462
|
+
const secMemoAttr = secProps.memo;
|
|
1463
|
+
const secUseMemo = secMemoAttr === 'true' || secMemoAttr === true || (typeof secMemoAttr === 'string' && secMemoAttr !== 'false');
|
|
1464
|
+
const secMemoComp = secUseMemo && typeof secMemoAttr === 'string' && secMemoAttr !== 'true' ? secMemoAttr : null;
|
|
1465
|
+
const secMemoExpr = secMemoComp && isExpr(secProps.memo) ? secProps.memo.code : secMemoComp;
|
|
1466
|
+
if (secUseMemo) {
|
|
1467
|
+
componentLines.push(`const ${secName} = React.memo(function ${secName}(${secParam}) {`);
|
|
1468
|
+
if (secCtx.needsInkSafe)
|
|
1469
|
+
componentLines.push(...emitInkSafePreamble());
|
|
1470
|
+
componentLines.push(...secBodyLines);
|
|
1471
|
+
componentLines.push(secMemoExpr ? `}, ${secMemoExpr});` : '});');
|
|
1472
|
+
componentLines.push(`${secExportKw} { ${secName} };`);
|
|
1473
|
+
}
|
|
1474
|
+
else {
|
|
1475
|
+
componentLines.push(`${secExportKw} function ${secName}(${secParam}) {`);
|
|
1476
|
+
if (secCtx.needsInkSafe)
|
|
1477
|
+
componentLines.push(...emitInkSafePreamble());
|
|
1478
|
+
componentLines.push(...secBodyLines);
|
|
1479
|
+
componentLines.push('}');
|
|
1480
|
+
}
|
|
1481
|
+
componentLines.push('');
|
|
811
1482
|
}
|
|
812
|
-
//
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
}
|
|
831
|
-
// JSX return — use explicit render handler if present, otherwise auto-generate
|
|
832
|
-
if (renderNode) {
|
|
833
|
-
const handlerChild = (renderNode.children || []).find((c) => c.type === 'handler');
|
|
834
|
-
const code = handlerChild ? getProps(handlerChild).code || '' : '';
|
|
835
|
-
if (code.trim()) {
|
|
836
|
-
const dedented = dedent(code);
|
|
837
|
-
for (const line of dedented.split('\n')) {
|
|
838
|
-
bodyLines.push(` ${line}`);
|
|
839
|
-
}
|
|
1483
|
+
// Component (Feature #9: with props) — respect export= and memo= attributes
|
|
1484
|
+
const screenExportAttr = screenProps.export;
|
|
1485
|
+
const screenMemoAttr = screenProps.memo;
|
|
1486
|
+
const useMemo = screenMemoAttr === 'true' ||
|
|
1487
|
+
screenMemoAttr === true ||
|
|
1488
|
+
(typeof screenMemoAttr === 'string' && screenMemoAttr !== 'false');
|
|
1489
|
+
const memoComparator = useMemo && typeof screenMemoAttr === 'string' && screenMemoAttr !== 'true' ? screenMemoAttr : null;
|
|
1490
|
+
const memoComparatorExpr = memoComparator && isExpr(screenProps.memo) ? screenProps.memo.code : memoComparator;
|
|
1491
|
+
if (useMemo) {
|
|
1492
|
+
// React.memo wrapper: const Name = React.memo(function Name(props) { ... }, comparator?);
|
|
1493
|
+
const exportKw = screenExportAttr === 'default' ? 'export default' : 'export';
|
|
1494
|
+
componentLines.push(`const ${screenName} = React.memo(function ${screenName}(${propsParam}) {`);
|
|
1495
|
+
if (stateCtx.needsInkSafe) {
|
|
1496
|
+
componentLines.push(...emitInkSafePreamble());
|
|
1497
|
+
}
|
|
1498
|
+
componentLines.push(...bodyLines);
|
|
1499
|
+
if (memoComparatorExpr) {
|
|
1500
|
+
componentLines.push(`}, ${memoComparatorExpr});`);
|
|
840
1501
|
}
|
|
841
1502
|
else {
|
|
842
|
-
|
|
1503
|
+
componentLines.push('});');
|
|
843
1504
|
}
|
|
1505
|
+
componentLines.push(`${exportKw} { ${screenName} };`);
|
|
844
1506
|
}
|
|
845
1507
|
else {
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
bodyLines.push(...renderInkNode(child, ' ', imports));
|
|
1508
|
+
const exportKw = screenExportAttr === 'default' ? 'export default' : 'export';
|
|
1509
|
+
componentLines.push(`${exportKw} function ${screenName}(${propsParam}) {`);
|
|
1510
|
+
if (stateCtx.needsInkSafe) {
|
|
1511
|
+
componentLines.push(...emitInkSafePreamble());
|
|
851
1512
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
}
|
|
855
|
-
// ── Assemble ──
|
|
856
|
-
// Imports (computed last since renderInkNode populates the tracker)
|
|
857
|
-
lines.push(...imports.emit());
|
|
858
|
-
lines.push('');
|
|
859
|
-
// Core nodes
|
|
860
|
-
if (coreLines.length > 0) {
|
|
861
|
-
lines.push(...coreLines);
|
|
1513
|
+
componentLines.push(...bodyLines);
|
|
1514
|
+
componentLines.push('}');
|
|
862
1515
|
}
|
|
863
|
-
// Component (Feature #9: with props)
|
|
864
|
-
lines.push(`export default function ${screenName}(${propsParam}) {`);
|
|
865
|
-
lines.push(...bodyLines);
|
|
866
|
-
lines.push('}');
|
|
867
1516
|
// File-level functions/constants emitted after the screen component
|
|
868
1517
|
if (fileLevelFns.length > 0) {
|
|
869
|
-
|
|
1518
|
+
componentLines.push('');
|
|
870
1519
|
for (const fn of fileLevelFns) {
|
|
871
|
-
|
|
872
|
-
|
|
1520
|
+
componentLines.push(...generateCoreNode(fn));
|
|
1521
|
+
componentLines.push('');
|
|
873
1522
|
}
|
|
874
1523
|
}
|
|
1524
|
+
// NOW emit imports — after all components have populated the tracker
|
|
1525
|
+
lines.push(...imports.emit());
|
|
1526
|
+
// Cross-file screen imports (screen-embed with from=)
|
|
1527
|
+
for (const [path, names] of _crossFileImports) {
|
|
1528
|
+
lines.push(`import { ${[...names].sort().join(', ')} } from '${path}';`);
|
|
1529
|
+
}
|
|
1530
|
+
_crossFileImports.clear();
|
|
1531
|
+
lines.push('');
|
|
1532
|
+
lines.push(...componentLines);
|
|
875
1533
|
// Source map
|
|
876
1534
|
sourceMap.push({
|
|
877
1535
|
irLine: root.loc?.line || 0,
|
|
@@ -884,12 +1542,37 @@ export function transpileInk(root, _config) {
|
|
|
884
1542
|
const irTokenCount = countTokens(irText);
|
|
885
1543
|
const tsTokenCount = countTokens(code);
|
|
886
1544
|
const tokenReduction = Math.round((1 - irTokenCount / tsTokenCount) * 100);
|
|
1545
|
+
// Generate artifacts: entry point + per-screen component files for multi-screen
|
|
1546
|
+
const artifacts = [];
|
|
1547
|
+
// Entry-point artifact: render(<App />) + waitUntilExit()
|
|
1548
|
+
const entryLines = [];
|
|
1549
|
+
entryLines.push(`#!/usr/bin/env node`);
|
|
1550
|
+
entryLines.push(`import React from 'react';`);
|
|
1551
|
+
entryLines.push(`import { render } from 'ink';`);
|
|
1552
|
+
if (screenExportAttr === 'named') {
|
|
1553
|
+
entryLines.push(`import { ${screenName} } from './${screenName}.js';`);
|
|
1554
|
+
}
|
|
1555
|
+
else {
|
|
1556
|
+
entryLines.push(`import ${screenName} from './${screenName}.js';`);
|
|
1557
|
+
}
|
|
1558
|
+
entryLines.push('');
|
|
1559
|
+
entryLines.push(`const app = render(<${screenName} />);`);
|
|
1560
|
+
entryLines.push(`await app.waitUntilExit();`);
|
|
1561
|
+
artifacts.push({ path: 'index.tsx', content: entryLines.join('\n'), type: 'entry' });
|
|
1562
|
+
// Main component artifact (always emitted so entry-point import resolves)
|
|
1563
|
+
artifacts.push({ path: `${screenName}.tsx`, content: code, type: 'component' });
|
|
1564
|
+
// Per-screen component artifacts for secondary screens
|
|
1565
|
+
for (const secScreen of secondaryScreens) {
|
|
1566
|
+
const secName = getProps(secScreen).name || 'Component';
|
|
1567
|
+
artifacts.push({ path: `${secName}.tsx`, content: '', type: 'component' });
|
|
1568
|
+
}
|
|
887
1569
|
return {
|
|
888
1570
|
code,
|
|
889
1571
|
sourceMap,
|
|
890
1572
|
irTokenCount,
|
|
891
1573
|
tsTokenCount,
|
|
892
1574
|
tokenReduction,
|
|
1575
|
+
artifacts,
|
|
893
1576
|
diagnostics: (() => {
|
|
894
1577
|
const accounted = new Map();
|
|
895
1578
|
accountNode(accounted, root, 'expressed', undefined, true);
|