@kernlang/terminal 3.1.9 → 3.3.4
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/README.md +27 -1
- package/dist/runtime/alternate-screen.d.ts +6 -0
- package/dist/runtime/alternate-screen.js +103 -0
- package/dist/runtime/alternate-screen.js.map +1 -0
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.js +3 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/runtime/scroll-box.d.ts +23 -0
- package/dist/runtime/scroll-box.js +186 -0
- package/dist/runtime/scroll-box.js.map +1 -0
- package/dist/runtime/terminal-mode.d.ts +10 -0
- package/dist/runtime/terminal-mode.js +59 -0
- package/dist/runtime/terminal-mode.js.map +1 -0
- package/dist/transpiler-ink.js +1095 -173
- package/dist/transpiler-ink.js.map +1 -1
- package/package.json +22 -2
package/dist/transpiler-ink.js
CHANGED
|
@@ -3,7 +3,7 @@ import { accountNode, buildDiagnostics, countTokens, dedent, generateCoreNode, g
|
|
|
3
3
|
* Ink Transpiler — generates React (Ink) TSX components for terminal UIs
|
|
4
4
|
*
|
|
5
5
|
* Maps KERN terminal nodes to Ink components:
|
|
6
|
-
* screen → React function component (export default)
|
|
6
|
+
* screen → React function component (named export by default, default export when requested)
|
|
7
7
|
* text → <Text bold color="blue">...</Text>
|
|
8
8
|
* box → <Box borderStyle="round" borderColor="blue">...</Box>
|
|
9
9
|
* separator → <Text dimColor>{'─'.repeat(48)}</Text>
|
|
@@ -28,6 +28,16 @@ import { accountNode, buildDiagnostics, countTokens, dedent, generateCoreNode, g
|
|
|
28
28
|
function capitalize(s) {
|
|
29
29
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
30
30
|
}
|
|
31
|
+
function inkScreenExportKeyword(exportAttr) {
|
|
32
|
+
if (exportAttr === false || exportAttr === 'false')
|
|
33
|
+
return '';
|
|
34
|
+
return exportAttr === 'default' ? 'export default' : 'export';
|
|
35
|
+
}
|
|
36
|
+
function inkScreenExportStatement(exportKw, symbol) {
|
|
37
|
+
if (!exportKw)
|
|
38
|
+
return null;
|
|
39
|
+
return exportKw === 'export default' ? `export default ${symbol};` : `export { ${symbol} };`;
|
|
40
|
+
}
|
|
31
41
|
/** Check if a prop value is a {{ expression }} object from the parser. */
|
|
32
42
|
function isExpr(v) {
|
|
33
43
|
return typeof v === 'object' && v !== null && '__expr' in v;
|
|
@@ -117,23 +127,31 @@ function keyToCheck(key) {
|
|
|
117
127
|
class ImportTracker {
|
|
118
128
|
reactImports = new Set();
|
|
119
129
|
inkImports = new Set();
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
inkSelectInput = false;
|
|
130
|
+
inkUIImports = new Set();
|
|
131
|
+
kernRuntimeImports = new Set();
|
|
123
132
|
addReact(name) {
|
|
124
133
|
this.reactImports.add(name);
|
|
125
134
|
}
|
|
126
135
|
addInk(name) {
|
|
127
136
|
this.inkImports.add(name);
|
|
128
137
|
}
|
|
138
|
+
/** Add an @inkjs/ui component import. */
|
|
139
|
+
addInkUI(name) {
|
|
140
|
+
this.inkUIImports.add(name);
|
|
141
|
+
}
|
|
142
|
+
/** Add a component from @kernlang/terminal/runtime. */
|
|
143
|
+
addKernRuntime(name) {
|
|
144
|
+
this.kernRuntimeImports.add(name);
|
|
145
|
+
}
|
|
146
|
+
// Legacy convenience methods — now route to @inkjs/ui
|
|
129
147
|
needSpinner() {
|
|
130
|
-
this.
|
|
148
|
+
this.inkUIImports.add('Spinner');
|
|
131
149
|
}
|
|
132
150
|
needTextInput() {
|
|
133
|
-
this.
|
|
151
|
+
this.inkUIImports.add('TextInput');
|
|
134
152
|
}
|
|
135
153
|
needSelectInput() {
|
|
136
|
-
this.
|
|
154
|
+
this.inkUIImports.add('Select');
|
|
137
155
|
}
|
|
138
156
|
emit() {
|
|
139
157
|
const lines = [];
|
|
@@ -146,47 +164,170 @@ class ImportTracker {
|
|
|
146
164
|
if (this.inkImports.size > 0) {
|
|
147
165
|
lines.push(`import { ${[...this.inkImports].sort().join(', ')} } from 'ink';`);
|
|
148
166
|
}
|
|
149
|
-
if (this.
|
|
150
|
-
lines.push(`import
|
|
151
|
-
}
|
|
152
|
-
if (this.inkTextInput) {
|
|
153
|
-
lines.push(`import TextInput from 'ink-text-input';`);
|
|
167
|
+
if (this.inkUIImports.size > 0) {
|
|
168
|
+
lines.push(`import { ${[...this.inkUIImports].sort().join(', ')} } from '@inkjs/ui';`);
|
|
154
169
|
}
|
|
155
|
-
if (this.
|
|
156
|
-
lines.push(`import
|
|
170
|
+
if (this.kernRuntimeImports.size > 0) {
|
|
171
|
+
lines.push(`import { ${[...this.kernRuntimeImports].sort().join(', ')} } from '@kernlang/terminal/runtime';`);
|
|
157
172
|
}
|
|
158
173
|
return lines;
|
|
159
174
|
}
|
|
160
175
|
}
|
|
161
|
-
// ──
|
|
162
|
-
|
|
176
|
+
// ── Ink-safe setter utility ─────────────────────────────────────────────
|
|
177
|
+
/** Emit the __inkSafe helper once per component — bridges microtask→macrotask for Ink repaints. */
|
|
178
|
+
function emitInkSafePreamble() {
|
|
179
|
+
return [
|
|
180
|
+
' // Ink-safe setter: bridges microtask → macrotask for reliable repaints',
|
|
181
|
+
' function __inkSafe<T>(setter: React.Dispatch<React.SetStateAction<T>>): React.Dispatch<React.SetStateAction<T>> {',
|
|
182
|
+
' return (value) => setTimeout(() => setter(value), 0);',
|
|
183
|
+
' }',
|
|
184
|
+
'',
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
/** Detect whether a useState initial value needs lazy initialization (prevents re-eval per render). */
|
|
188
|
+
function needsLazyInit(initial, type) {
|
|
189
|
+
const trimmed = initial.trim();
|
|
190
|
+
// IIFE: ((...) => ...)() or (function() { ... })()
|
|
191
|
+
if (/^\(.*\)\s*\(/.test(trimmed))
|
|
192
|
+
return true;
|
|
193
|
+
// function expression: function( — executes when called
|
|
194
|
+
if (trimmed.startsWith('function(') || trimmed.startsWith('function ('))
|
|
195
|
+
return true;
|
|
196
|
+
// new constructor: new Map(), new Set(), etc.
|
|
197
|
+
if (trimmed.startsWith('new '))
|
|
198
|
+
return true;
|
|
199
|
+
// Arrow functions: only wrap if state TYPE is a function (state holds a function value)
|
|
200
|
+
if (/^\(?[^)]*\)?\s*=>/.test(trimmed) && type && /=>/.test(type))
|
|
201
|
+
return true;
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
function generateStateHook(stateNode, imports, ctx) {
|
|
163
205
|
const lines = [];
|
|
164
206
|
const props = getProps(stateNode);
|
|
165
207
|
const name = props.name;
|
|
166
208
|
const initialProp = props.initial;
|
|
209
|
+
const safe = props.safe !== 'false' && props.safe !== false; // default true
|
|
210
|
+
const external = props.external === 'true' || props.external === true;
|
|
167
211
|
if (name && initialProp !== undefined) {
|
|
168
212
|
imports.addReact('useState');
|
|
169
213
|
const initial = isExpr(initialProp) ? initialProp.code : String(initialProp);
|
|
170
214
|
const initVal = isExpr(initialProp)
|
|
171
215
|
? initial
|
|
172
|
-
: initial === '
|
|
173
|
-
? '
|
|
174
|
-
: initial === '
|
|
175
|
-
? '
|
|
176
|
-
: initial === '
|
|
177
|
-
? '
|
|
178
|
-
: initial
|
|
179
|
-
?
|
|
180
|
-
: initial.startsWith(
|
|
216
|
+
: initial === ''
|
|
217
|
+
? "''"
|
|
218
|
+
: initial === 'null'
|
|
219
|
+
? 'null'
|
|
220
|
+
: initial === 'true'
|
|
221
|
+
? 'true'
|
|
222
|
+
: initial === 'false'
|
|
223
|
+
? 'false'
|
|
224
|
+
: initial.startsWith('[') || initial.startsWith('{')
|
|
181
225
|
? initial
|
|
182
|
-
: initial.
|
|
226
|
+
: initial.startsWith("'") || initial.startsWith('"')
|
|
183
227
|
? initial
|
|
184
|
-
:
|
|
185
|
-
?
|
|
186
|
-
:
|
|
228
|
+
: initial.includes('(') || initial.includes('.')
|
|
229
|
+
? initial
|
|
230
|
+
: Number.isNaN(Number(initial))
|
|
231
|
+
? `'${initial}'`
|
|
232
|
+
: String(initial);
|
|
187
233
|
const setter = `set${capitalize(name)}`;
|
|
188
234
|
const typeAnnotation = props.type ? `<${props.type}>` : '';
|
|
189
|
-
|
|
235
|
+
// Lazy initialization for IIFEs and constructors (prevents re-eval per render)
|
|
236
|
+
const lazyInitVal = needsLazyInit(initVal, props.type) ? `() => ${initVal}` : initVal;
|
|
237
|
+
const throttle = props.throttle;
|
|
238
|
+
const debounce = props.debounce;
|
|
239
|
+
if (external) {
|
|
240
|
+
// External-state primitive: a stable reference whose internal mutations
|
|
241
|
+
// are tracked via a sibling version counter. Replaces the manual
|
|
242
|
+
// `state foo + state fooVersion + setFooVersion(v => v + 1)` pattern.
|
|
243
|
+
// The held value is emitted as a bare useState (no __inkSafe wrap — the
|
|
244
|
+
// user mutates the object in place; the rare full-replacement case is
|
|
245
|
+
// a sync setState that React 18 batches inside event handlers). The
|
|
246
|
+
// version counter is hidden; the user calls `bumpFoo()` after mutating
|
|
247
|
+
// the object, and any memo that references `foo` automatically gets
|
|
248
|
+
// `_fooVersion` injected into its dep array.
|
|
249
|
+
if (throttle !== undefined || debounce !== undefined) {
|
|
250
|
+
throw new Error(`state '${name}' uses external=true with throttle/debounce, which are mutually exclusive. ` +
|
|
251
|
+
`External state holds a stable reference; throttle/debounce apply to setter call rates and have no meaning ` +
|
|
252
|
+
`here. Drop one of the two, or split into a separate state node if you really need both.`);
|
|
253
|
+
}
|
|
254
|
+
if (props.safe === 'false' || props.safe === false) {
|
|
255
|
+
throw new Error(`state '${name}' uses external=true with safe=false. External state already emits a bare useState (the safe wrapper does not apply), so safe=false is redundant — and combining them suggests a misunderstanding. Drop safe=false.`);
|
|
256
|
+
}
|
|
257
|
+
imports.addReact('useMemo');
|
|
258
|
+
const cap = capitalize(name);
|
|
259
|
+
lines.push(` const [${name}, ${setter}] = useState${typeAnnotation}(${lazyInitVal});`);
|
|
260
|
+
lines.push(` const [_${name}Version, _set${cap}VersionRaw] = useState<number>(0);`);
|
|
261
|
+
lines.push(` const bump${cap} = useMemo(() => {`);
|
|
262
|
+
lines.push(` return () => setTimeout(() => _set${cap}VersionRaw((v: number) => v + 1), 0);`);
|
|
263
|
+
lines.push(` }, []);`);
|
|
264
|
+
// Touch the version in the closure so React picks it up if the user references
|
|
265
|
+
// it directly. The void cast keeps the lint quiet about an unused binding.
|
|
266
|
+
lines.push(` void _${name}Version;`);
|
|
267
|
+
return lines;
|
|
268
|
+
}
|
|
269
|
+
if (throttle) {
|
|
270
|
+
// Throttled setter — leading+trailing by default (lodash-style).
|
|
271
|
+
// trailing=false reverts to leading-only (drops intermediate + final values in window).
|
|
272
|
+
const trailing = props.trailing !== 'false' && props.trailing !== false;
|
|
273
|
+
imports.addReact('useMemo');
|
|
274
|
+
const valType = typeAnnotation ? props.type : 'any';
|
|
275
|
+
lines.push(` const [${name}, _${setter}Raw] = useState${typeAnnotation}(${lazyInitVal});`);
|
|
276
|
+
lines.push(` const ${setter} = useMemo(() => {`);
|
|
277
|
+
lines.push(` let _lastCall = 0;`);
|
|
278
|
+
if (trailing) {
|
|
279
|
+
lines.push(` let _pendingValue: React.SetStateAction<${valType}>;`);
|
|
280
|
+
lines.push(` let _pendingTimer: ReturnType<typeof setTimeout> | null = null;`);
|
|
281
|
+
}
|
|
282
|
+
lines.push(` return (value: React.SetStateAction<${valType}>) => {`);
|
|
283
|
+
lines.push(` const now = Date.now();`);
|
|
284
|
+
if (trailing) {
|
|
285
|
+
lines.push(` const elapsed = now - _lastCall;`);
|
|
286
|
+
lines.push(` if (elapsed >= ${throttle}) {`);
|
|
287
|
+
lines.push(` _lastCall = now;`);
|
|
288
|
+
lines.push(` if (_pendingTimer) { clearTimeout(_pendingTimer); _pendingTimer = null; }`);
|
|
289
|
+
lines.push(` setTimeout(() => _${setter}Raw(value), 0);`);
|
|
290
|
+
lines.push(` } else {`);
|
|
291
|
+
lines.push(` _pendingValue = value;`);
|
|
292
|
+
lines.push(` if (!_pendingTimer) {`);
|
|
293
|
+
lines.push(` _pendingTimer = setTimeout(() => {`);
|
|
294
|
+
lines.push(` _lastCall = Date.now();`);
|
|
295
|
+
lines.push(` _pendingTimer = null;`);
|
|
296
|
+
lines.push(` _${setter}Raw(_pendingValue);`);
|
|
297
|
+
lines.push(` }, ${throttle} - elapsed);`);
|
|
298
|
+
lines.push(` }`);
|
|
299
|
+
lines.push(` }`);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
lines.push(` if (now - _lastCall >= ${throttle}) {`);
|
|
303
|
+
lines.push(` _lastCall = now;`);
|
|
304
|
+
lines.push(` setTimeout(() => _${setter}Raw(value), 0);`);
|
|
305
|
+
lines.push(` }`);
|
|
306
|
+
}
|
|
307
|
+
lines.push(` };`);
|
|
308
|
+
lines.push(` }, []);`);
|
|
309
|
+
}
|
|
310
|
+
else if (debounce) {
|
|
311
|
+
// Debounced setter — delays updates, uses setTimeout for Ink safety
|
|
312
|
+
imports.addReact('useMemo');
|
|
313
|
+
lines.push(` const [${name}, _${setter}Raw] = useState${typeAnnotation}(${lazyInitVal});`);
|
|
314
|
+
lines.push(` const ${setter} = useMemo(() => {`);
|
|
315
|
+
lines.push(` let _timer: ReturnType<typeof setTimeout> | null = null;`);
|
|
316
|
+
lines.push(` return (value: React.SetStateAction<${typeAnnotation ? props.type : 'any'}>) => {`);
|
|
317
|
+
lines.push(` if (_timer) clearTimeout(_timer);`);
|
|
318
|
+
lines.push(` _timer = setTimeout(() => _${setter}Raw(value), ${debounce});`);
|
|
319
|
+
lines.push(` };`);
|
|
320
|
+
lines.push(` }, []);`);
|
|
321
|
+
}
|
|
322
|
+
else if (safe) {
|
|
323
|
+
ctx.needsInkSafe = true;
|
|
324
|
+
imports.addReact('useMemo');
|
|
325
|
+
lines.push(` const [${name}, _${setter}Raw] = useState${typeAnnotation}(${lazyInitVal});`);
|
|
326
|
+
lines.push(` const ${setter} = useMemo(() => __inkSafe(_${setter}Raw), [_${setter}Raw]);`);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
lines.push(` const [${name}, ${setter}] = useState${typeAnnotation}(${lazyInitVal});`);
|
|
330
|
+
}
|
|
190
331
|
}
|
|
191
332
|
return lines;
|
|
192
333
|
}
|
|
@@ -212,24 +353,70 @@ function generateStreamEffect(streamNode, imports) {
|
|
|
212
353
|
const name = props.name;
|
|
213
354
|
const source = props.source;
|
|
214
355
|
const append = props.append !== 'false'; // default true
|
|
356
|
+
const mode = props.mode;
|
|
357
|
+
const dispatch = props.dispatch;
|
|
215
358
|
if (name && source) {
|
|
216
359
|
imports.addReact('useEffect');
|
|
217
360
|
const setter = `set${capitalize(name)}`;
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
lines.push(`
|
|
361
|
+
if (mode === 'channel' && dispatch) {
|
|
362
|
+
// Channel mode: AsyncGenerator → dispatch function with cleanup
|
|
363
|
+
// Pattern: session.send() → drain chunks → dispatch(chunk)
|
|
364
|
+
lines.push(` useEffect(() => {`);
|
|
365
|
+
lines.push(` let cancelled = false;`);
|
|
366
|
+
lines.push(` const abortController = new AbortController();`);
|
|
367
|
+
lines.push(` (async () => {`);
|
|
368
|
+
lines.push(` try {`);
|
|
369
|
+
lines.push(` for await (const chunk of ${source}) {`);
|
|
370
|
+
lines.push(` if (cancelled) break;`);
|
|
371
|
+
lines.push(` ${dispatch}(chunk);`);
|
|
372
|
+
lines.push(` }`);
|
|
373
|
+
lines.push(` } catch (err) {`);
|
|
374
|
+
lines.push(` if (!cancelled) console.error('Stream error:', err);`);
|
|
375
|
+
lines.push(` }`);
|
|
376
|
+
lines.push(` })();`);
|
|
377
|
+
lines.push(` return () => { cancelled = true; abortController.abort(); };`);
|
|
378
|
+
lines.push(` }, [${source}]);`);
|
|
379
|
+
}
|
|
380
|
+
else if (mode === 'channel') {
|
|
381
|
+
// Channel mode without dispatch: iterate source directly into state
|
|
382
|
+
lines.push(` useEffect(() => {`);
|
|
383
|
+
lines.push(` let cancelled = false;`);
|
|
384
|
+
lines.push(` (async () => {`);
|
|
385
|
+
lines.push(` try {`);
|
|
386
|
+
lines.push(` for await (const chunk of ${source}) {`);
|
|
387
|
+
lines.push(` if (cancelled) break;`);
|
|
388
|
+
if (append) {
|
|
389
|
+
lines.push(` ${setter}(prev => [...prev, chunk]);`);
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
lines.push(` ${setter}(chunk);`);
|
|
393
|
+
}
|
|
394
|
+
lines.push(` }`);
|
|
395
|
+
lines.push(` } catch (err) {`);
|
|
396
|
+
lines.push(` if (!cancelled) console.error('Stream error:', err);`);
|
|
397
|
+
lines.push(` }`);
|
|
398
|
+
lines.push(` })();`);
|
|
399
|
+
lines.push(` return () => { cancelled = true; };`);
|
|
400
|
+
lines.push(` }, [${source}]);`);
|
|
225
401
|
}
|
|
226
402
|
else {
|
|
227
|
-
|
|
403
|
+
// Default mode: source is a function that returns an AsyncGenerator
|
|
404
|
+
lines.push(` useEffect(() => {`);
|
|
405
|
+
lines.push(` let cancelled = false;`);
|
|
406
|
+
lines.push(` (async () => {`);
|
|
407
|
+
lines.push(` for await (const chunk of ${source}()) {`);
|
|
408
|
+
lines.push(` if (cancelled) break;`);
|
|
409
|
+
if (append) {
|
|
410
|
+
lines.push(` ${setter}(prev => [...prev, chunk]);`);
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
lines.push(` ${setter}(chunk);`);
|
|
414
|
+
}
|
|
415
|
+
lines.push(` }`);
|
|
416
|
+
lines.push(` })();`);
|
|
417
|
+
lines.push(` return () => { cancelled = true; };`);
|
|
418
|
+
lines.push(` }, []);`);
|
|
228
419
|
}
|
|
229
|
-
lines.push(` }`);
|
|
230
|
-
lines.push(` })();`);
|
|
231
|
-
lines.push(` return () => { cancelled = true; };`);
|
|
232
|
-
lines.push(` }, []);`);
|
|
233
420
|
}
|
|
234
421
|
return lines;
|
|
235
422
|
}
|
|
@@ -245,14 +432,91 @@ function generateLogicEffect(logicNode, imports) {
|
|
|
245
432
|
imports.addReact('useEffect');
|
|
246
433
|
const dedented = dedent(code);
|
|
247
434
|
const depsStr = deps ? `[${deps}]` : '[]';
|
|
435
|
+
// Auto-cleanup: detect setInterval/setTimeout at top-level scope (not inside nested functions)
|
|
436
|
+
const hasCleanup = /return\s*\(\s*\)\s*=>/.test(dedented) || /return\s*\(\)\s*\{/.test(dedented);
|
|
437
|
+
// Only match if declaration appears before any function/arrow — i.e., at the effect's top level
|
|
438
|
+
const hasNestedFn = /(?:function\s|=>)/.test(dedented.split(/set(?:Interval|Timeout)\s*\(/)[0] || '');
|
|
439
|
+
const intervalMatch = hasNestedFn ? null : dedented.match(/(?:const|let|var)\s+(\w+)\s*=\s*setInterval\s*\(/);
|
|
440
|
+
const timeoutMatch = hasNestedFn ? null : dedented.match(/(?:const|let|var)\s+(\w+)\s*=\s*setTimeout\s*\(/);
|
|
248
441
|
lines.push(` useEffect(() => {`);
|
|
249
442
|
for (const line of dedented.split('\n')) {
|
|
250
443
|
lines.push(` ${line}`);
|
|
251
444
|
}
|
|
445
|
+
if (!hasCleanup && intervalMatch) {
|
|
446
|
+
lines.push(` return () => { clearInterval(${intervalMatch[1]}); };`);
|
|
447
|
+
}
|
|
448
|
+
else if (!hasCleanup && timeoutMatch) {
|
|
449
|
+
lines.push(` return () => { clearTimeout(${timeoutMatch[1]}); };`);
|
|
450
|
+
}
|
|
252
451
|
lines.push(` }, ${depsStr});`);
|
|
253
452
|
}
|
|
254
453
|
return lines;
|
|
255
454
|
}
|
|
455
|
+
// ── Focus hook → useFocus (Phase 3) ────────────────────────────────
|
|
456
|
+
function generateFocusHook(focusNode, imports) {
|
|
457
|
+
const lines = [];
|
|
458
|
+
const props = getProps(focusNode);
|
|
459
|
+
const name = props.name;
|
|
460
|
+
const autoFocus = props.autoFocus === 'true' || props.autoFocus === true;
|
|
461
|
+
const id = props.id;
|
|
462
|
+
if (name) {
|
|
463
|
+
imports.addInk('useFocus');
|
|
464
|
+
const opts = [];
|
|
465
|
+
if (autoFocus)
|
|
466
|
+
opts.push('autoFocus: true');
|
|
467
|
+
if (id)
|
|
468
|
+
opts.push(`id: '${id}'`);
|
|
469
|
+
const optsStr = opts.length > 0 ? `{ ${opts.join(', ')} }` : '';
|
|
470
|
+
lines.push(` const { isFocused: ${name} } = useFocus(${optsStr});`);
|
|
471
|
+
}
|
|
472
|
+
return lines;
|
|
473
|
+
}
|
|
474
|
+
// ── App exit hook → useApp (Phase 3) ───────────────────────────────
|
|
475
|
+
function generateAppExitHook(exitNode, imports) {
|
|
476
|
+
const lines = [];
|
|
477
|
+
const props = getProps(exitNode);
|
|
478
|
+
const on = props.on;
|
|
479
|
+
if (on) {
|
|
480
|
+
imports.addInk('useApp');
|
|
481
|
+
imports.addReact('useEffect');
|
|
482
|
+
const condition = isExpr(on) ? on.code : String(on);
|
|
483
|
+
lines.push(` const { exit } = useApp();`);
|
|
484
|
+
lines.push(` useEffect(() => { if (${condition}) exit(); }, [${condition}]);`);
|
|
485
|
+
}
|
|
486
|
+
return lines;
|
|
487
|
+
}
|
|
488
|
+
// ── Animation block → useEffect with setInterval ────────────────────
|
|
489
|
+
function generateAnimation(animNode, imports) {
|
|
490
|
+
const lines = [];
|
|
491
|
+
const props = getProps(animNode);
|
|
492
|
+
const name = props.name;
|
|
493
|
+
const interval = props.interval;
|
|
494
|
+
const update = isExpr(props.update) ? props.update.code : String(props.update || '');
|
|
495
|
+
const active = props.active;
|
|
496
|
+
if (name && interval && update) {
|
|
497
|
+
imports.addReact('useEffect');
|
|
498
|
+
const setter = `set${capitalize(name)}`;
|
|
499
|
+
if (active) {
|
|
500
|
+
const activeExpr = isExpr(active) ? active.code : String(active);
|
|
501
|
+
lines.push(` useEffect(() => {`);
|
|
502
|
+
lines.push(` if (!(${activeExpr})) return;`);
|
|
503
|
+
lines.push(` const _animId = setInterval(() => {`);
|
|
504
|
+
lines.push(` ${setter}(${update});`);
|
|
505
|
+
lines.push(` }, ${interval});`);
|
|
506
|
+
lines.push(` return () => clearInterval(_animId);`);
|
|
507
|
+
lines.push(` }, [${activeExpr}]);`);
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
lines.push(` useEffect(() => {`);
|
|
511
|
+
lines.push(` const _animId = setInterval(() => {`);
|
|
512
|
+
lines.push(` ${setter}(${update});`);
|
|
513
|
+
lines.push(` }, ${interval});`);
|
|
514
|
+
lines.push(` return () => clearInterval(_animId);`);
|
|
515
|
+
lines.push(` }, []);`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return lines;
|
|
519
|
+
}
|
|
256
520
|
// ── Callback block → useCallback (Feature #11) ─────────────────────────
|
|
257
521
|
function generateCallbackHook(callbackNode, imports) {
|
|
258
522
|
const lines = [];
|
|
@@ -295,30 +559,137 @@ function collectNestedOnNodes(node) {
|
|
|
295
559
|
return found;
|
|
296
560
|
}
|
|
297
561
|
// ── Generate useInput from an on-node ───────────────────────────────────
|
|
298
|
-
|
|
562
|
+
let _onHookCounter = 0;
|
|
563
|
+
/**
|
|
564
|
+
* Rewrite a handler body so that every `setX(...)` call against a known safe state
|
|
565
|
+
* is replaced with the corresponding raw setter `_setXRaw(...)`. Used by batched
|
|
566
|
+
* handlers to bypass the per-setter __inkSafe macrotask deferral, so the whole
|
|
567
|
+
* batch can share a single deferred macrotask.
|
|
568
|
+
*
|
|
569
|
+
* The match uses a negative lookbehind so it only fires on bare setter calls.
|
|
570
|
+
* `form.setCount(...)` and any locally-shadowed `const setCount = ...; setCount(...)`
|
|
571
|
+
* preceded by a member access or word char are NOT rewritten.
|
|
572
|
+
*
|
|
573
|
+
* Limitation: substitution is text-based, so a setter name appearing as a bare
|
|
574
|
+
* call inside a string literal will also be rewritten. Document this in the
|
|
575
|
+
* language reference.
|
|
576
|
+
*/
|
|
577
|
+
function rewriteToRawSetters(code, stateNodes) {
|
|
578
|
+
let out = code;
|
|
579
|
+
for (const stateNode of stateNodes) {
|
|
580
|
+
const sp = getProps(stateNode);
|
|
581
|
+
// External-state setters use the bare useState form — there is no
|
|
582
|
+
// `_setXRaw` to rewrite to. Leave call sites alone.
|
|
583
|
+
if (sp.external === 'true' || sp.external === true)
|
|
584
|
+
continue;
|
|
585
|
+
const safe = sp.safe !== 'false' && sp.safe !== false;
|
|
586
|
+
if (!safe)
|
|
587
|
+
continue;
|
|
588
|
+
if (sp.throttle !== undefined || sp.debounce !== undefined)
|
|
589
|
+
continue;
|
|
590
|
+
const name = sp.name;
|
|
591
|
+
if (!name)
|
|
592
|
+
continue;
|
|
593
|
+
const setter = `set${capitalize(name)}`;
|
|
594
|
+
const raw = `_${setter}Raw`;
|
|
595
|
+
// Negative lookbehind: setter must not be preceded by `.` (member access)
|
|
596
|
+
// or `\w` (substring of a longer identifier).
|
|
597
|
+
const pattern = new RegExp(`(?<![\\w.])${setter}\\s*\\(`, 'g');
|
|
598
|
+
out = out.replace(pattern, `${raw}(`);
|
|
599
|
+
}
|
|
600
|
+
return out;
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Refuse to batch handlers that contain async or deferred constructs. The whole
|
|
604
|
+
* point of batch=true is "collapse N synchronous setter calls into one shared
|
|
605
|
+
* macrotask." If the handler defers work into a nested timer or promise, those
|
|
606
|
+
* inner setter calls would land in their own task AFTER the batch's setTimeout
|
|
607
|
+
* has already flushed, with no __inkSafe wrapper to bridge them — exactly the
|
|
608
|
+
* missed-repaint failure mode __inkSafe exists to prevent. Better to surface
|
|
609
|
+
* the misuse at compile time than ship code that silently regresses on a
|
|
610
|
+
* subset of paths.
|
|
611
|
+
*/
|
|
612
|
+
const BATCH_FORBIDDEN_PATTERNS = [
|
|
613
|
+
{ name: 'setTimeout(', pattern: /\bsetTimeout\s*\(/ },
|
|
614
|
+
{ name: 'setInterval(', pattern: /\bsetInterval\s*\(/ },
|
|
615
|
+
{ name: 'setImmediate(', pattern: /\bsetImmediate\s*\(/ },
|
|
616
|
+
{ name: 'queueMicrotask(', pattern: /\bqueueMicrotask\s*\(/ },
|
|
617
|
+
{ name: 'await', pattern: /\bawait\b/ },
|
|
618
|
+
{ name: '.then(', pattern: /\.then\s*\(/ },
|
|
619
|
+
];
|
|
620
|
+
/**
|
|
621
|
+
* Append `_${name}Version` to a memo's dep list for every external state name
|
|
622
|
+
* the memo already references. The user writes `deps="registry"` and the codegen
|
|
623
|
+
* produces `[registry, _registryVersion]`, so the memo invalidates when the user
|
|
624
|
+
* calls `bumpRegistry()` after mutating the held object in place. Idempotent —
|
|
625
|
+
* if the user already listed the version manually, nothing is duplicated.
|
|
626
|
+
*/
|
|
627
|
+
function injectExternalVersionDeps(depsRaw, externalStateNames) {
|
|
628
|
+
if (externalStateNames.length === 0)
|
|
629
|
+
return depsRaw;
|
|
630
|
+
const tokens = depsRaw
|
|
631
|
+
.split(',')
|
|
632
|
+
.map((t) => t.trim())
|
|
633
|
+
.filter(Boolean);
|
|
634
|
+
const present = new Set(tokens);
|
|
635
|
+
for (const name of externalStateNames) {
|
|
636
|
+
if (!present.has(name))
|
|
637
|
+
continue;
|
|
638
|
+
const versionTok = `_${name}Version`;
|
|
639
|
+
if (!present.has(versionTok)) {
|
|
640
|
+
tokens.push(versionTok);
|
|
641
|
+
present.add(versionTok);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return tokens.join(', ');
|
|
645
|
+
}
|
|
646
|
+
function checkBatchBodyIsSync(code, onNode) {
|
|
647
|
+
for (const { name, pattern } of BATCH_FORBIDDEN_PATTERNS) {
|
|
648
|
+
if (pattern.test(code)) {
|
|
649
|
+
throw new Error(`batch=true handler at on-node '${getProps(onNode).key || getProps(onNode).event || 'unknown'}' contains '${name}'. Batched handlers must be fully synchronous — deferred work would bypass __inkSafe and cause missed repaints. Either remove batch=true or move the deferred work to a separate non-batched on-node.`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
function generateOnHook(onNode, imports, stateNodes) {
|
|
299
654
|
const lines = [];
|
|
300
655
|
const onProps = getProps(onNode);
|
|
301
656
|
const event = (onProps.event || onProps.name);
|
|
657
|
+
const batch = onProps.batch === 'true' || onProps.batch === true;
|
|
302
658
|
if (event === 'key' || event === 'input') {
|
|
303
659
|
imports.addInk('useInput');
|
|
304
660
|
imports.addReact('useRef');
|
|
305
661
|
const key = onProps.key;
|
|
306
662
|
const handlerChild = (onNode.children || []).find((c) => c.type === 'handler');
|
|
307
663
|
const code = handlerChild ? getProps(handlerChild).code || '' : '';
|
|
308
|
-
// Use ref pattern for fresh closures —
|
|
309
|
-
|
|
310
|
-
|
|
664
|
+
// Use ref pattern with unique suffix for fresh closures — supports multiple on-nodes
|
|
665
|
+
const suffix = _onHookCounter === 0 ? '' : `_${_onHookCounter}`;
|
|
666
|
+
_onHookCounter++;
|
|
667
|
+
const refName = `_inputHandlerRef${suffix}`;
|
|
668
|
+
lines.push(` const ${refName} = useRef<(input: string, key: any) => void>(() => {});`);
|
|
669
|
+
lines.push(` ${refName}.current = (input: string, key: any) => {`);
|
|
311
670
|
if (key) {
|
|
312
671
|
lines.push(` if (!(${keyToCheck(key)})) return;`);
|
|
313
672
|
}
|
|
314
673
|
if (code) {
|
|
315
674
|
const dedented = dedent(code);
|
|
316
|
-
|
|
317
|
-
|
|
675
|
+
if (batch) {
|
|
676
|
+
checkBatchBodyIsSync(dedented, onNode);
|
|
677
|
+
const body = rewriteToRawSetters(dedented, stateNodes);
|
|
678
|
+
// Single deferred macrotask: collapse N __inkSafe wrappers into one paint cycle.
|
|
679
|
+
lines.push(` setTimeout(() => {`);
|
|
680
|
+
for (const line of body.split('\n')) {
|
|
681
|
+
lines.push(` ${line}`);
|
|
682
|
+
}
|
|
683
|
+
lines.push(` }, 0);`);
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
for (const line of dedented.split('\n')) {
|
|
687
|
+
lines.push(` ${line}`);
|
|
688
|
+
}
|
|
318
689
|
}
|
|
319
690
|
}
|
|
320
691
|
lines.push(` };`);
|
|
321
|
-
lines.push(` useInput((input: string, key: any) =>
|
|
692
|
+
lines.push(` useInput((input: string, key: any) => ${refName}.current(input, key));`);
|
|
322
693
|
lines.push('');
|
|
323
694
|
}
|
|
324
695
|
return lines;
|
|
@@ -388,6 +759,58 @@ function renderInkBox(node, p, indent, imports) {
|
|
|
388
759
|
lines.push(`${indent}</Box>`);
|
|
389
760
|
return lines;
|
|
390
761
|
}
|
|
762
|
+
function renderInkAlternateScreen(node, p, indent, imports) {
|
|
763
|
+
imports.addKernRuntime('AlternateScreen');
|
|
764
|
+
const mouseTracking = p.mouseTracking === 'true' ||
|
|
765
|
+
p.mouseTracking === true ||
|
|
766
|
+
p['mouse-tracking'] === 'true' ||
|
|
767
|
+
p['mouse-tracking'] === true;
|
|
768
|
+
const attrs = [];
|
|
769
|
+
if (mouseTracking)
|
|
770
|
+
attrs.push('mouseTracking');
|
|
771
|
+
const propsStr = attrs.length > 0 ? ` ${attrs.join(' ')}` : '';
|
|
772
|
+
const lines = [];
|
|
773
|
+
lines.push(`${indent}<AlternateScreen${propsStr}>`);
|
|
774
|
+
for (const child of node.children || []) {
|
|
775
|
+
if (child.type === 'on')
|
|
776
|
+
continue;
|
|
777
|
+
lines.push(...renderInkNode(child, `${indent} `, imports));
|
|
778
|
+
}
|
|
779
|
+
lines.push(`${indent}</AlternateScreen>`);
|
|
780
|
+
return lines;
|
|
781
|
+
}
|
|
782
|
+
function renderInkScrollBox(node, p, indent, imports) {
|
|
783
|
+
imports.addKernRuntime('ScrollBox');
|
|
784
|
+
const stickyScroll = p.stickyScroll === 'true' ||
|
|
785
|
+
p.stickyScroll === true ||
|
|
786
|
+
p['sticky-scroll'] === 'true' ||
|
|
787
|
+
p['sticky-scroll'] === true;
|
|
788
|
+
const flexGrow = p.flexGrow ?? p['flex-grow'];
|
|
789
|
+
const flexShrink = p.flexShrink ?? p['flex-shrink'];
|
|
790
|
+
const height = p.height;
|
|
791
|
+
const rowHeight = p.rowHeight ?? p['row-height'];
|
|
792
|
+
const attrs = [];
|
|
793
|
+
if (stickyScroll)
|
|
794
|
+
attrs.push('stickyScroll');
|
|
795
|
+
if (flexGrow !== undefined)
|
|
796
|
+
attrs.push(`flexGrow={${unwrapProp(flexGrow)}}`);
|
|
797
|
+
if (flexShrink !== undefined)
|
|
798
|
+
attrs.push(`flexShrink={${unwrapProp(flexShrink)}}`);
|
|
799
|
+
if (height !== undefined)
|
|
800
|
+
attrs.push(`height={${unwrapProp(height)}}`);
|
|
801
|
+
if (rowHeight !== undefined)
|
|
802
|
+
attrs.push(`rowHeight={${unwrapProp(rowHeight)}}`);
|
|
803
|
+
const propsStr = attrs.length > 0 ? ` ${attrs.join(' ')}` : '';
|
|
804
|
+
const lines = [];
|
|
805
|
+
lines.push(`${indent}<ScrollBox${propsStr}>`);
|
|
806
|
+
for (const child of node.children || []) {
|
|
807
|
+
if (child.type === 'on')
|
|
808
|
+
continue;
|
|
809
|
+
lines.push(...renderInkNode(child, `${indent} `, imports));
|
|
810
|
+
}
|
|
811
|
+
lines.push(`${indent}</ScrollBox>`);
|
|
812
|
+
return lines;
|
|
813
|
+
}
|
|
391
814
|
function renderInkTable(node, p, indent, imports) {
|
|
392
815
|
imports.addInk('Box');
|
|
393
816
|
imports.addInk('Text');
|
|
@@ -549,11 +972,15 @@ function renderInkSelectInput(p, indent, imports) {
|
|
|
549
972
|
const rawItems = p.items;
|
|
550
973
|
const items = isExpr(rawItems) ? rawItems.code : rawItems || '[]';
|
|
551
974
|
const onSelect = p.onSelect;
|
|
552
|
-
const
|
|
975
|
+
const onChange = p.onChange;
|
|
976
|
+
const selectProps = [`options={${items}}`];
|
|
553
977
|
if (onSelect) {
|
|
554
|
-
selectProps.push(`
|
|
978
|
+
selectProps.push(`onChange={${onSelect}}`);
|
|
979
|
+
}
|
|
980
|
+
else if (onChange) {
|
|
981
|
+
selectProps.push(`onChange={${onChange}}`);
|
|
555
982
|
}
|
|
556
|
-
return [`${indent}<
|
|
983
|
+
return [`${indent}<Select ${selectProps.join(' ')} />`];
|
|
557
984
|
}
|
|
558
985
|
function renderInkHandler(p, indent) {
|
|
559
986
|
const code = p.code || '';
|
|
@@ -602,6 +1029,215 @@ function renderInkConditional(node, p, indent, imports) {
|
|
|
602
1029
|
lines.push(`${indent})}`);
|
|
603
1030
|
return lines;
|
|
604
1031
|
}
|
|
1032
|
+
// ── @inkjs/ui Component Renderers (Phase 3) ─────────────────────────────
|
|
1033
|
+
function renderInkMultiSelect(p, indent, imports) {
|
|
1034
|
+
imports.addInkUI('MultiSelect');
|
|
1035
|
+
const rawOptions = p.options;
|
|
1036
|
+
const options = isExpr(rawOptions) ? rawOptions.code : rawOptions || '[]';
|
|
1037
|
+
const onChange = p.onChange;
|
|
1038
|
+
const msProps = [`options={${options}}`];
|
|
1039
|
+
if (onChange)
|
|
1040
|
+
msProps.push(`onChange={${onChange}}`);
|
|
1041
|
+
return [`${indent}<MultiSelect ${msProps.join(' ')} />`];
|
|
1042
|
+
}
|
|
1043
|
+
function renderInkConfirmInput(p, indent, imports) {
|
|
1044
|
+
imports.addInkUI('ConfirmInput');
|
|
1045
|
+
const ciProps = [];
|
|
1046
|
+
if (p.onConfirm)
|
|
1047
|
+
ciProps.push(`onConfirm={${p.onConfirm}}`);
|
|
1048
|
+
if (p.onCancel)
|
|
1049
|
+
ciProps.push(`onCancel={${p.onCancel}}`);
|
|
1050
|
+
if (p.defaultChoice)
|
|
1051
|
+
ciProps.push(`defaultChoice="${p.defaultChoice}"`);
|
|
1052
|
+
if (p.submitOnEnter === 'false' || p.submitOnEnter === false)
|
|
1053
|
+
ciProps.push('submitOnEnter={false}');
|
|
1054
|
+
return [`${indent}<ConfirmInput ${ciProps.join(' ')} />`];
|
|
1055
|
+
}
|
|
1056
|
+
function renderInkPasswordInput(p, indent, imports) {
|
|
1057
|
+
imports.addInkUI('PasswordInput');
|
|
1058
|
+
const piProps = [];
|
|
1059
|
+
const bind = p.bind;
|
|
1060
|
+
if (p.placeholder)
|
|
1061
|
+
piProps.push(`placeholder=${JSON.stringify(p.placeholder)}`);
|
|
1062
|
+
if (bind) {
|
|
1063
|
+
piProps.push(`onChange={set${capitalize(bind)}}`);
|
|
1064
|
+
}
|
|
1065
|
+
if (p.onChange)
|
|
1066
|
+
piProps.push(`onChange={${p.onChange}}`);
|
|
1067
|
+
return [`${indent}<PasswordInput ${piProps.join(' ')} />`];
|
|
1068
|
+
}
|
|
1069
|
+
function renderInkStatusMessage(node, p, indent, imports) {
|
|
1070
|
+
imports.addInkUI('StatusMessage');
|
|
1071
|
+
const variant = p.variant || 'info';
|
|
1072
|
+
const lines = [];
|
|
1073
|
+
lines.push(`${indent}<StatusMessage variant="${variant}">`);
|
|
1074
|
+
for (const child of node.children || []) {
|
|
1075
|
+
lines.push(...renderInkNode(child, `${indent} `, imports));
|
|
1076
|
+
}
|
|
1077
|
+
lines.push(`${indent}</StatusMessage>`);
|
|
1078
|
+
return lines;
|
|
1079
|
+
}
|
|
1080
|
+
function renderInkAlert(node, p, indent, imports) {
|
|
1081
|
+
imports.addInkUI('Alert');
|
|
1082
|
+
const variant = p.variant || 'info';
|
|
1083
|
+
const title = p.title;
|
|
1084
|
+
const alertProps = [`variant="${variant}"`];
|
|
1085
|
+
if (title)
|
|
1086
|
+
alertProps.push(`title=${JSON.stringify(title)}`);
|
|
1087
|
+
const lines = [];
|
|
1088
|
+
lines.push(`${indent}<Alert ${alertProps.join(' ')}>`);
|
|
1089
|
+
for (const child of node.children || []) {
|
|
1090
|
+
lines.push(...renderInkNode(child, `${indent} `, imports));
|
|
1091
|
+
}
|
|
1092
|
+
lines.push(`${indent}</Alert>`);
|
|
1093
|
+
return lines;
|
|
1094
|
+
}
|
|
1095
|
+
function renderInkOrderedList(node, indent, imports) {
|
|
1096
|
+
imports.addInkUI('OrderedList');
|
|
1097
|
+
const lines = [];
|
|
1098
|
+
lines.push(`${indent}<OrderedList>`);
|
|
1099
|
+
for (const child of node.children || []) {
|
|
1100
|
+
lines.push(`${indent} <OrderedList.Item>`);
|
|
1101
|
+
lines.push(...renderInkNode(child, `${indent} `, imports));
|
|
1102
|
+
lines.push(`${indent} </OrderedList.Item>`);
|
|
1103
|
+
}
|
|
1104
|
+
lines.push(`${indent}</OrderedList>`);
|
|
1105
|
+
return lines;
|
|
1106
|
+
}
|
|
1107
|
+
function renderInkUnorderedList(node, indent, imports) {
|
|
1108
|
+
imports.addInkUI('UnorderedList');
|
|
1109
|
+
const lines = [];
|
|
1110
|
+
lines.push(`${indent}<UnorderedList>`);
|
|
1111
|
+
for (const child of node.children || []) {
|
|
1112
|
+
lines.push(`${indent} <UnorderedList.Item>`);
|
|
1113
|
+
lines.push(...renderInkNode(child, `${indent} `, imports));
|
|
1114
|
+
lines.push(`${indent} </UnorderedList.Item>`);
|
|
1115
|
+
}
|
|
1116
|
+
lines.push(`${indent}</UnorderedList>`);
|
|
1117
|
+
return lines;
|
|
1118
|
+
}
|
|
1119
|
+
function renderInkStaticLog(node, p, indent, imports) {
|
|
1120
|
+
imports.addInk('Static');
|
|
1121
|
+
imports.addInk('Text');
|
|
1122
|
+
const rawItems = p.items;
|
|
1123
|
+
const items = isExpr(rawItems) ? rawItems.code : rawItems || '[]';
|
|
1124
|
+
const lines = [];
|
|
1125
|
+
lines.push(`${indent}<Static items={${items}}>`);
|
|
1126
|
+
lines.push(`${indent} {(item: any) => (`);
|
|
1127
|
+
if (node.children && node.children.length > 1) {
|
|
1128
|
+
// Multiple children need a fragment wrapper
|
|
1129
|
+
lines.push(`${indent} <>`);
|
|
1130
|
+
for (const child of node.children) {
|
|
1131
|
+
lines.push(...renderInkNode(child, `${indent} `, imports));
|
|
1132
|
+
}
|
|
1133
|
+
lines.push(`${indent} </>`);
|
|
1134
|
+
}
|
|
1135
|
+
else if (node.children && node.children.length === 1) {
|
|
1136
|
+
lines.push(...renderInkNode(node.children[0], `${indent} `, imports));
|
|
1137
|
+
}
|
|
1138
|
+
else {
|
|
1139
|
+
lines.push(`${indent} <Text>{String(item)}</Text>`);
|
|
1140
|
+
}
|
|
1141
|
+
lines.push(`${indent} )}`);
|
|
1142
|
+
lines.push(`${indent}</Static>`);
|
|
1143
|
+
return lines;
|
|
1144
|
+
}
|
|
1145
|
+
function renderInkNewline(p, indent, imports) {
|
|
1146
|
+
imports.addInk('Newline');
|
|
1147
|
+
const count = p.count;
|
|
1148
|
+
if (count && Number(count) > 1) {
|
|
1149
|
+
return [`${indent}<Newline count={${count}} />`];
|
|
1150
|
+
}
|
|
1151
|
+
return [`${indent}<Newline />`];
|
|
1152
|
+
}
|
|
1153
|
+
// ── Layout Primitives (Phase 4) ──────────────────────────────────────────
|
|
1154
|
+
function renderLayoutRow(node, p, indent, imports) {
|
|
1155
|
+
imports.addInk('Box');
|
|
1156
|
+
const gap = p.gap;
|
|
1157
|
+
const padding = p.padding;
|
|
1158
|
+
const boxProps = ['flexDirection="row"'];
|
|
1159
|
+
if (gap)
|
|
1160
|
+
boxProps.push(`gap={${gap}}`);
|
|
1161
|
+
if (padding)
|
|
1162
|
+
boxProps.push(`padding={${padding}}`);
|
|
1163
|
+
const lines = [];
|
|
1164
|
+
lines.push(`${indent}<Box ${boxProps.join(' ')}>`);
|
|
1165
|
+
for (const child of node.children || []) {
|
|
1166
|
+
lines.push(...renderInkNode(child, `${indent} `, imports));
|
|
1167
|
+
}
|
|
1168
|
+
lines.push(`${indent}</Box>`);
|
|
1169
|
+
return lines;
|
|
1170
|
+
}
|
|
1171
|
+
function renderLayoutCol(node, p, indent, imports) {
|
|
1172
|
+
imports.addInk('Box');
|
|
1173
|
+
const flex = p.flex;
|
|
1174
|
+
const width = p.width;
|
|
1175
|
+
const boxProps = ['flexDirection="column"'];
|
|
1176
|
+
if (flex)
|
|
1177
|
+
boxProps.push(`flexGrow={${flex}}`);
|
|
1178
|
+
if (width)
|
|
1179
|
+
boxProps.push(`width={${width}}`);
|
|
1180
|
+
const lines = [];
|
|
1181
|
+
lines.push(`${indent}<Box ${boxProps.join(' ')}>`);
|
|
1182
|
+
for (const child of node.children || []) {
|
|
1183
|
+
lines.push(...renderInkNode(child, `${indent} `, imports));
|
|
1184
|
+
}
|
|
1185
|
+
lines.push(`${indent}</Box>`);
|
|
1186
|
+
return lines;
|
|
1187
|
+
}
|
|
1188
|
+
function renderLayoutStack(node, p, indent, imports) {
|
|
1189
|
+
imports.addInk('Box');
|
|
1190
|
+
const padding = p.padding;
|
|
1191
|
+
const gap = p.gap;
|
|
1192
|
+
const boxProps = ['flexDirection="column"'];
|
|
1193
|
+
if (padding)
|
|
1194
|
+
boxProps.push(`padding={${padding}}`);
|
|
1195
|
+
if (gap)
|
|
1196
|
+
boxProps.push(`gap={${gap}}`);
|
|
1197
|
+
const lines = [];
|
|
1198
|
+
lines.push(`${indent}<Box ${boxProps.join(' ')}>`);
|
|
1199
|
+
for (const child of node.children || []) {
|
|
1200
|
+
lines.push(...renderInkNode(child, `${indent} `, imports));
|
|
1201
|
+
}
|
|
1202
|
+
lines.push(`${indent}</Box>`);
|
|
1203
|
+
return lines;
|
|
1204
|
+
}
|
|
1205
|
+
function renderSpacer(indent, imports) {
|
|
1206
|
+
imports.addInk('Box');
|
|
1207
|
+
return [`${indent}<Box flexGrow={1} />`];
|
|
1208
|
+
}
|
|
1209
|
+
/** Cross-file import collector — populated by screen-embed with from= */
|
|
1210
|
+
const _crossFileImports = new Map();
|
|
1211
|
+
function renderScreenEmbed(p, indent) {
|
|
1212
|
+
const screen = p.screen;
|
|
1213
|
+
if (!screen)
|
|
1214
|
+
return [];
|
|
1215
|
+
const from = p.from;
|
|
1216
|
+
// Track cross-file import if from= is specified
|
|
1217
|
+
if (from) {
|
|
1218
|
+
// Normalize: strip .kern extension, add .js for ESM
|
|
1219
|
+
const importPath = from.replace(/\.kern$/, '.js');
|
|
1220
|
+
if (!_crossFileImports.has(importPath))
|
|
1221
|
+
_crossFileImports.set(importPath, new Set());
|
|
1222
|
+
_crossFileImports.get(importPath).add(screen);
|
|
1223
|
+
}
|
|
1224
|
+
// Collect all non-meta props as component props
|
|
1225
|
+
const propEntries = Object.entries(p).filter(([k]) => k !== 'screen' && k !== 'from' && k !== 'styles' && k !== 'themeRefs');
|
|
1226
|
+
const propsStr = propEntries
|
|
1227
|
+
.map(([k, v]) => {
|
|
1228
|
+
if (isExpr(v))
|
|
1229
|
+
return `${k}={${v.code}}`;
|
|
1230
|
+
const s = String(v);
|
|
1231
|
+
// Preserve non-string literals as JSX expressions
|
|
1232
|
+
if (s === 'true' || s === 'false')
|
|
1233
|
+
return `${k}={${s}}`;
|
|
1234
|
+
if (!Number.isNaN(Number(s)) && s !== '')
|
|
1235
|
+
return `${k}={${s}}`;
|
|
1236
|
+
return `${k}=${JSON.stringify(s)}`;
|
|
1237
|
+
})
|
|
1238
|
+
.join(' ');
|
|
1239
|
+
return [`${indent}<${screen}${propsStr ? ` ${propsStr}` : ''} />`];
|
|
1240
|
+
}
|
|
605
1241
|
// ── Node renderer → JSX (dispatcher) ─────────────────────────────────────
|
|
606
1242
|
function renderInkNode(node, indent, imports) {
|
|
607
1243
|
const p = getProps(node);
|
|
@@ -612,6 +1248,10 @@ function renderInkNode(node, indent, imports) {
|
|
|
612
1248
|
return renderInkSeparator(p, indent, imports);
|
|
613
1249
|
case 'box':
|
|
614
1250
|
return renderInkBox(node, p, indent, imports);
|
|
1251
|
+
case 'alternate-screen':
|
|
1252
|
+
return renderInkAlternateScreen(node, p, indent, imports);
|
|
1253
|
+
case 'scroll-box':
|
|
1254
|
+
return renderInkScrollBox(node, p, indent, imports);
|
|
615
1255
|
case 'table':
|
|
616
1256
|
return renderInkTable(node, p, indent, imports);
|
|
617
1257
|
case 'scoreboard':
|
|
@@ -630,6 +1270,34 @@ function renderInkNode(node, indent, imports) {
|
|
|
630
1270
|
return renderInkTextInput(p, indent, imports);
|
|
631
1271
|
case 'select-input':
|
|
632
1272
|
return renderInkSelectInput(p, indent, imports);
|
|
1273
|
+
case 'multi-select':
|
|
1274
|
+
return renderInkMultiSelect(p, indent, imports);
|
|
1275
|
+
case 'confirm-input':
|
|
1276
|
+
return renderInkConfirmInput(p, indent, imports);
|
|
1277
|
+
case 'password-input':
|
|
1278
|
+
return renderInkPasswordInput(p, indent, imports);
|
|
1279
|
+
case 'status-message':
|
|
1280
|
+
return renderInkStatusMessage(node, p, indent, imports);
|
|
1281
|
+
case 'alert':
|
|
1282
|
+
return renderInkAlert(node, p, indent, imports);
|
|
1283
|
+
case 'ordered-list':
|
|
1284
|
+
return renderInkOrderedList(node, indent, imports);
|
|
1285
|
+
case 'unordered-list':
|
|
1286
|
+
return renderInkUnorderedList(node, indent, imports);
|
|
1287
|
+
case 'static-log':
|
|
1288
|
+
return renderInkStaticLog(node, p, indent, imports);
|
|
1289
|
+
case 'newline':
|
|
1290
|
+
return renderInkNewline(p, indent, imports);
|
|
1291
|
+
case 'layout-row':
|
|
1292
|
+
return renderLayoutRow(node, p, indent, imports);
|
|
1293
|
+
case 'layout-col':
|
|
1294
|
+
return renderLayoutCol(node, p, indent, imports);
|
|
1295
|
+
case 'layout-stack':
|
|
1296
|
+
return renderLayoutStack(node, p, indent, imports);
|
|
1297
|
+
case 'spacer':
|
|
1298
|
+
return renderSpacer(indent, imports);
|
|
1299
|
+
case 'screen-embed':
|
|
1300
|
+
return renderScreenEmbed(p, indent);
|
|
633
1301
|
case 'handler':
|
|
634
1302
|
return renderInkHandler(p, indent);
|
|
635
1303
|
case 'each':
|
|
@@ -640,14 +1308,22 @@ function renderInkNode(node, indent, imports) {
|
|
|
640
1308
|
case 'ref':
|
|
641
1309
|
case 'stream':
|
|
642
1310
|
case 'logic':
|
|
1311
|
+
case 'effect':
|
|
643
1312
|
case 'callback':
|
|
1313
|
+
case 'memo':
|
|
1314
|
+
case 'render':
|
|
1315
|
+
case 'prop':
|
|
644
1316
|
case 'on':
|
|
1317
|
+
case 'animation':
|
|
1318
|
+
case 'derive':
|
|
1319
|
+
case 'focus':
|
|
1320
|
+
case 'app-exit':
|
|
645
1321
|
return [];
|
|
646
1322
|
default: {
|
|
647
1323
|
const lines = [];
|
|
648
1324
|
if (isCoreNode(node.type)) {
|
|
649
1325
|
if (node.type === 'machine') {
|
|
650
|
-
lines.push(...generateMachineReducer(node).map((l) => l));
|
|
1326
|
+
lines.push(...generateMachineReducer(node, { safeDispatch: true, emitImport: false }).map((l) => l));
|
|
651
1327
|
}
|
|
652
1328
|
else {
|
|
653
1329
|
lines.push(...generateCoreNode(node));
|
|
@@ -663,14 +1339,251 @@ function renderInkNode(node, indent, imports) {
|
|
|
663
1339
|
}
|
|
664
1340
|
}
|
|
665
1341
|
}
|
|
1342
|
+
// ── Reusable screen body compiler ────────────────────────────────────────
|
|
1343
|
+
const NON_UI_TYPES = new Set([
|
|
1344
|
+
'state',
|
|
1345
|
+
'ref',
|
|
1346
|
+
'on',
|
|
1347
|
+
'stream',
|
|
1348
|
+
'logic',
|
|
1349
|
+
'effect',
|
|
1350
|
+
'callback',
|
|
1351
|
+
'memo',
|
|
1352
|
+
'render',
|
|
1353
|
+
'prop',
|
|
1354
|
+
'animation',
|
|
1355
|
+
'derive',
|
|
1356
|
+
'focus',
|
|
1357
|
+
'app-exit',
|
|
1358
|
+
]);
|
|
1359
|
+
function isInkUiNode(type) {
|
|
1360
|
+
return (type === 'each' ||
|
|
1361
|
+
type === 'conditional' ||
|
|
1362
|
+
type === 'select' ||
|
|
1363
|
+
type === 'model' ||
|
|
1364
|
+
type === 'repository' ||
|
|
1365
|
+
type === 'dependency' ||
|
|
1366
|
+
type === 'cache');
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Compile a screen node's full body: all hooks, effects, and JSX return.
|
|
1370
|
+
* Used for both primary and secondary screens to ensure feature parity.
|
|
1371
|
+
*/
|
|
1372
|
+
function compileScreenBody(screenNode, imports) {
|
|
1373
|
+
const bodyLines = [];
|
|
1374
|
+
const stateCtx = { needsInkSafe: false };
|
|
1375
|
+
// Collect all node categories from this screen
|
|
1376
|
+
const stateNodes = getChildren(screenNode, 'state');
|
|
1377
|
+
const refNodes = getChildren(screenNode, 'ref');
|
|
1378
|
+
const onNodes = getChildren(screenNode, 'on');
|
|
1379
|
+
const streamNodes = getChildren(screenNode, 'stream');
|
|
1380
|
+
const logicNodes = [...getChildren(screenNode, 'logic'), ...getChildren(screenNode, 'effect')];
|
|
1381
|
+
const callbackNodes = getChildren(screenNode, 'callback');
|
|
1382
|
+
const animationNodes = getChildren(screenNode, 'animation');
|
|
1383
|
+
const deriveNodes = getChildren(screenNode, 'derive');
|
|
1384
|
+
const focusNodes = getChildren(screenNode, 'focus');
|
|
1385
|
+
const appExitNodes = getChildren(screenNode, 'app-exit');
|
|
1386
|
+
const memoNodes = getChildren(screenNode, 'memo');
|
|
1387
|
+
const renderNode = getChildren(screenNode, 'render')[0];
|
|
1388
|
+
const uiChildren = (screenNode.children || []).filter((c) => !NON_UI_TYPES.has(c.type) && (!isCoreNode(c.type) || isInkUiNode(c.type)));
|
|
1389
|
+
// Hoist nested on-nodes from UI tree
|
|
1390
|
+
const nestedOnNodes = collectNestedOnNodes(screenNode);
|
|
1391
|
+
const allOnNodes = [...onNodes];
|
|
1392
|
+
for (const nested of nestedOnNodes) {
|
|
1393
|
+
if (!onNodes.includes(nested))
|
|
1394
|
+
allOnNodes.push(nested);
|
|
1395
|
+
}
|
|
1396
|
+
// State hooks
|
|
1397
|
+
for (const stateNode of stateNodes) {
|
|
1398
|
+
bodyLines.push(...generateStateHook(stateNode, imports, stateCtx));
|
|
1399
|
+
}
|
|
1400
|
+
if (stateNodes.length > 0)
|
|
1401
|
+
bodyLines.push('');
|
|
1402
|
+
// Ref hooks
|
|
1403
|
+
for (const refNode of refNodes) {
|
|
1404
|
+
bodyLines.push(...generateRefHook(refNode, imports));
|
|
1405
|
+
}
|
|
1406
|
+
if (refNodes.length > 0)
|
|
1407
|
+
bodyLines.push('');
|
|
1408
|
+
// Focus hooks
|
|
1409
|
+
for (const focusNode of focusNodes) {
|
|
1410
|
+
bodyLines.push(...generateFocusHook(focusNode, imports));
|
|
1411
|
+
}
|
|
1412
|
+
if (focusNodes.length > 0)
|
|
1413
|
+
bodyLines.push('');
|
|
1414
|
+
// App exit hooks
|
|
1415
|
+
for (const exitNode of appExitNodes) {
|
|
1416
|
+
bodyLines.push(...generateAppExitHook(exitNode, imports));
|
|
1417
|
+
}
|
|
1418
|
+
if (appExitNodes.length > 0)
|
|
1419
|
+
bodyLines.push('');
|
|
1420
|
+
// Names of state nodes declared with external=true. Memos that reference
|
|
1421
|
+
// any of these names auto-receive the corresponding `_${name}Version` token
|
|
1422
|
+
// in their dep array, so the user does not have to remember to list both.
|
|
1423
|
+
const externalStateNames = stateNodes
|
|
1424
|
+
.filter((s) => {
|
|
1425
|
+
const sp = getProps(s);
|
|
1426
|
+
return sp.external === 'true' || sp.external === true;
|
|
1427
|
+
})
|
|
1428
|
+
.map((s) => getProps(s).name)
|
|
1429
|
+
.filter(Boolean);
|
|
1430
|
+
// Memo hooks
|
|
1431
|
+
for (const memoNode of memoNodes) {
|
|
1432
|
+
const mp = getProps(memoNode);
|
|
1433
|
+
const mName = mp.name;
|
|
1434
|
+
const mDepsRaw = mp.deps || '';
|
|
1435
|
+
// Auto-inject `_${name}Version` for every external state referenced in deps.
|
|
1436
|
+
const mDeps = injectExternalVersionDeps(mDepsRaw, externalStateNames);
|
|
1437
|
+
const mDepsArr = mDeps ? `[${mDeps}]` : '[]';
|
|
1438
|
+
const handlerChild = (memoNode.children || []).find((c) => c.type === 'handler');
|
|
1439
|
+
const code = handlerChild ? getProps(handlerChild).code || '' : '';
|
|
1440
|
+
if (mName && code) {
|
|
1441
|
+
imports.addReact('useMemo');
|
|
1442
|
+
const dedented = dedent(code);
|
|
1443
|
+
bodyLines.push(` const ${mName} = useMemo(() => {`);
|
|
1444
|
+
for (const line of dedented.split('\n')) {
|
|
1445
|
+
bodyLines.push(` ${line}`);
|
|
1446
|
+
}
|
|
1447
|
+
bodyLines.push(` }, ${mDepsArr});`);
|
|
1448
|
+
bodyLines.push('');
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
// Callback hooks
|
|
1452
|
+
for (const callbackNode of callbackNodes) {
|
|
1453
|
+
bodyLines.push(...generateCallbackHook(callbackNode, imports));
|
|
1454
|
+
bodyLines.push('');
|
|
1455
|
+
}
|
|
1456
|
+
// on event=key → useInput() hooks
|
|
1457
|
+
for (const onNode of allOnNodes) {
|
|
1458
|
+
bodyLines.push(...generateOnHook(onNode, imports, stateNodes));
|
|
1459
|
+
}
|
|
1460
|
+
// Stream effects
|
|
1461
|
+
for (const streamNode of streamNodes) {
|
|
1462
|
+
bodyLines.push(...generateStreamEffect(streamNode, imports));
|
|
1463
|
+
bodyLines.push('');
|
|
1464
|
+
}
|
|
1465
|
+
// Logic effects
|
|
1466
|
+
for (const logicNode of logicNodes) {
|
|
1467
|
+
bodyLines.push(...generateLogicEffect(logicNode, imports));
|
|
1468
|
+
bodyLines.push('');
|
|
1469
|
+
}
|
|
1470
|
+
// Animation effects
|
|
1471
|
+
for (const animNode of animationNodes) {
|
|
1472
|
+
bodyLines.push(...generateAnimation(animNode, imports));
|
|
1473
|
+
bodyLines.push('');
|
|
1474
|
+
}
|
|
1475
|
+
// Derive nodes → useMemo with auto-dep tracking
|
|
1476
|
+
for (const deriveNode of deriveNodes) {
|
|
1477
|
+
const dp = getProps(deriveNode);
|
|
1478
|
+
const dName = dp.name;
|
|
1479
|
+
const dExpr = isExpr(dp.expr) ? dp.expr.code : dp.expr || '';
|
|
1480
|
+
const dDeps = dp.deps;
|
|
1481
|
+
const dType = dp.type;
|
|
1482
|
+
if (dName && dExpr) {
|
|
1483
|
+
imports.addReact('useMemo');
|
|
1484
|
+
const typeAnnotation = dType ? `<${dType}>` : '';
|
|
1485
|
+
let depsStr;
|
|
1486
|
+
if (dDeps) {
|
|
1487
|
+
// Explicit deps — same auto-injection path as memo nodes.
|
|
1488
|
+
const injected = injectExternalVersionDeps(dDeps, externalStateNames);
|
|
1489
|
+
depsStr = `[${injected}]`;
|
|
1490
|
+
}
|
|
1491
|
+
else {
|
|
1492
|
+
const sNames = stateNodes.map((s) => getProps(s).name).filter(Boolean);
|
|
1493
|
+
const rNames = refNodes
|
|
1494
|
+
.map((r) => {
|
|
1495
|
+
const rn = getProps(r).name;
|
|
1496
|
+
return rn ? (rn.endsWith('Ref') ? rn : `${rn}Ref`) : '';
|
|
1497
|
+
})
|
|
1498
|
+
.filter(Boolean);
|
|
1499
|
+
const allNames = [...sNames, ...rNames];
|
|
1500
|
+
const autoDeps = allNames.filter((n) => new RegExp(`\\b${n}\\b`).test(dExpr));
|
|
1501
|
+
// After auto-detect, append `_${name}Version` for any external state that
|
|
1502
|
+
// showed up in the expression — otherwise bumpRegistry() never invalidates.
|
|
1503
|
+
const autoDepsWithVersions = [];
|
|
1504
|
+
for (const dep of autoDeps) {
|
|
1505
|
+
autoDepsWithVersions.push(dep);
|
|
1506
|
+
if (externalStateNames.includes(dep)) {
|
|
1507
|
+
const versionTok = `_${dep}Version`;
|
|
1508
|
+
if (!autoDepsWithVersions.includes(versionTok)) {
|
|
1509
|
+
autoDepsWithVersions.push(versionTok);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
depsStr = `[${autoDepsWithVersions.join(', ')}]`;
|
|
1514
|
+
}
|
|
1515
|
+
bodyLines.push(` const ${dName} = useMemo${typeAnnotation}(() => ${dExpr}, ${depsStr});`);
|
|
1516
|
+
bodyLines.push('');
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
// JSX return — auto-insert return when handler body is a bare JSX expression
|
|
1520
|
+
if (renderNode) {
|
|
1521
|
+
const handlerChild = (renderNode.children || []).find((c) => c.type === 'handler');
|
|
1522
|
+
const code = handlerChild ? getProps(handlerChild).code || '' : '';
|
|
1523
|
+
if (code.trim()) {
|
|
1524
|
+
const dedented = dedent(code);
|
|
1525
|
+
const trimmed = dedented.trim();
|
|
1526
|
+
if (trimmed.includes('return ') || trimmed.includes('return(')) {
|
|
1527
|
+
// User wrote explicit return — emit as-is
|
|
1528
|
+
for (const line of dedented.split('\n')) {
|
|
1529
|
+
bodyLines.push(` ${line}`);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
else {
|
|
1533
|
+
// Bare expression (likely JSX) — wrap in return()
|
|
1534
|
+
bodyLines.push(' return (');
|
|
1535
|
+
for (const line of dedented.split('\n')) {
|
|
1536
|
+
bodyLines.push(` ${line}`);
|
|
1537
|
+
}
|
|
1538
|
+
bodyLines.push(' );');
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
else {
|
|
1542
|
+
bodyLines.push(' return null;');
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
else {
|
|
1546
|
+
imports.addInk('Box');
|
|
1547
|
+
bodyLines.push(' return (');
|
|
1548
|
+
bodyLines.push(' <Box flexDirection="column">');
|
|
1549
|
+
for (const child of uiChildren) {
|
|
1550
|
+
bodyLines.push(...renderInkNode(child, ' ', imports));
|
|
1551
|
+
}
|
|
1552
|
+
bodyLines.push(' </Box>');
|
|
1553
|
+
bodyLines.push(' );');
|
|
1554
|
+
}
|
|
1555
|
+
// Auto-detect React hooks referenced in handler bodies but not yet in the import tracker.
|
|
1556
|
+
// Covers cases where user code calls hooks inline (e.g. useEffect in a render handler)
|
|
1557
|
+
// rather than via dedicated KERN nodes.
|
|
1558
|
+
const bodyText = bodyLines.join('\n');
|
|
1559
|
+
for (const hook of [
|
|
1560
|
+
'useEffect',
|
|
1561
|
+
'useState',
|
|
1562
|
+
'useMemo',
|
|
1563
|
+
'useCallback',
|
|
1564
|
+
'useRef',
|
|
1565
|
+
'useReducer',
|
|
1566
|
+
'useContext',
|
|
1567
|
+
'useLayoutEffect',
|
|
1568
|
+
]) {
|
|
1569
|
+
if (bodyText.includes(hook))
|
|
1570
|
+
imports.addReact(hook);
|
|
1571
|
+
}
|
|
1572
|
+
return { bodyLines, stateCtx };
|
|
1573
|
+
}
|
|
666
1574
|
// ── Main export ──────────────────────────────────────────────────────────
|
|
667
1575
|
export function transpileInk(root, _config) {
|
|
1576
|
+
_onHookCounter = 0; // Reset per-component counter
|
|
1577
|
+
_crossFileImports.clear(); // Reset cross-file imports
|
|
668
1578
|
const sourceMap = [];
|
|
669
1579
|
const imports = new ImportTracker();
|
|
670
1580
|
const lines = [];
|
|
671
|
-
// Handle file-level AST: find
|
|
672
|
-
const
|
|
673
|
-
const
|
|
1581
|
+
// Handle file-level AST: find screen node(s), keep siblings as file-level nodes
|
|
1582
|
+
const allScreenNodes = root.type === 'screen' ? [root] : (root.children || []).filter((c) => c.type === 'screen');
|
|
1583
|
+
const screenNode = allScreenNodes.length > 0 ? allScreenNodes[allScreenNodes.length - 1] : root;
|
|
1584
|
+
const fileLevelNodes = root.type === 'screen' ? [] : (root.children || []).filter((c) => c.type !== 'screen');
|
|
1585
|
+
// Secondary screens (all except the last/default one)
|
|
1586
|
+
const secondaryScreens = allScreenNodes.slice(0, -1);
|
|
674
1587
|
const screenProps = getProps(screenNode);
|
|
675
1588
|
const screenName = screenProps.name || 'App';
|
|
676
1589
|
// Component props from screen attributes OR prop child nodes
|
|
@@ -695,48 +1608,15 @@ export function transpileInk(root, _config) {
|
|
|
695
1608
|
})
|
|
696
1609
|
.join('; ')} }`
|
|
697
1610
|
: '';
|
|
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
1611
|
// File-level imports go before component; file-level fn/const go after
|
|
1612
|
+
// Collect import nodes from ALL screens (not just primary) so user imports aren't dropped
|
|
1613
|
+
const secondaryImports = secondaryScreens.flatMap((s) => (s.children || []).filter((c) => c.type === 'import'));
|
|
713
1614
|
const coreChildren = [
|
|
714
1615
|
...fileLevelNodes.filter((c) => isCoreNode(c.type) && c.type !== 'screen' && c.type !== 'fn' && c.type !== 'const'),
|
|
715
1616
|
...(screenNode.children || []).filter((c) => isCoreNode(c.type) && c.type !== 'on' && !isInkUiNode(c.type)),
|
|
1617
|
+
...secondaryImports,
|
|
716
1618
|
];
|
|
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
1619
|
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
1620
|
// ── Core nodes emitted above component (types, interfaces, machines, events) ──
|
|
741
1621
|
const coreLines = [];
|
|
742
1622
|
if (coreChildren.length > 0) {
|
|
@@ -744,7 +1624,7 @@ export function transpileInk(root, _config) {
|
|
|
744
1624
|
for (const child of coreChildren) {
|
|
745
1625
|
if (child.type === 'machine') {
|
|
746
1626
|
imports.addReact('useReducer');
|
|
747
|
-
coreLines.push(...generateMachineReducer(child));
|
|
1627
|
+
coreLines.push(...generateMachineReducer(child, { safeDispatch: true, emitImport: false }));
|
|
748
1628
|
}
|
|
749
1629
|
else if (child.type === 'import') {
|
|
750
1630
|
// Merge react/ink imports into tracker to avoid duplicates
|
|
@@ -776,102 +1656,117 @@ export function transpileInk(root, _config) {
|
|
|
776
1656
|
coreLines.push('');
|
|
777
1657
|
}
|
|
778
1658
|
}
|
|
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));
|
|
1659
|
+
// ── Component body (via shared compileScreenBody) ──
|
|
1660
|
+
const { bodyLines, stateCtx } = compileScreenBody(screenNode, imports);
|
|
1661
|
+
// ── Assemble ──
|
|
1662
|
+
// Note: imports.emit() is deferred to AFTER secondary screens + default component
|
|
1663
|
+
// so all required imports are tracked before emission.
|
|
1664
|
+
const componentLines = [];
|
|
1665
|
+
// Core nodes
|
|
1666
|
+
if (coreLines.length > 0) {
|
|
1667
|
+
componentLines.push(...coreLines);
|
|
790
1668
|
}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
const
|
|
796
|
-
const
|
|
797
|
-
const
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
1669
|
+
// Secondary screen components — full compilation via shared compileScreenBody
|
|
1670
|
+
for (const secScreen of secondaryScreens) {
|
|
1671
|
+
const secProps = getProps(secScreen);
|
|
1672
|
+
const secName = secProps.name || 'Component';
|
|
1673
|
+
const secPropsAttr = secProps.props;
|
|
1674
|
+
const secPropChildren = getChildren(secScreen, 'prop');
|
|
1675
|
+
const secPropParts = secPropsAttr ? splitPropsRespectingDepth(secPropsAttr) : [];
|
|
1676
|
+
for (const pc of secPropChildren) {
|
|
1677
|
+
const pp = getProps(pc);
|
|
1678
|
+
const pn = pp.name;
|
|
1679
|
+
const pt = pp.type || 'any';
|
|
1680
|
+
const opt = pp.optional === 'true' || pp.optional === true;
|
|
1681
|
+
if (pn)
|
|
1682
|
+
secPropParts.push(`${pn}${opt ? '?' : ''}:${pt}`);
|
|
1683
|
+
}
|
|
1684
|
+
const secParam = secPropParts.length > 0
|
|
1685
|
+
? `{ ${secPropParts.map((p) => p.trim().split(':')[0].replace('?', '').trim()).join(', ')} }: { ${secPropParts
|
|
1686
|
+
.map((p) => {
|
|
1687
|
+
const t = p.trim();
|
|
1688
|
+
return t.includes(':') ? t : `${t}: any`;
|
|
1689
|
+
})
|
|
1690
|
+
.join('; ')} }`
|
|
1691
|
+
: '';
|
|
1692
|
+
// Full body compilation — same pipeline as primary screen
|
|
1693
|
+
const { bodyLines: secBodyLines, stateCtx: secCtx } = compileScreenBody(secScreen, imports);
|
|
1694
|
+
const secExportKw = inkScreenExportKeyword(secProps.export);
|
|
1695
|
+
const secMemoAttr = secProps.memo;
|
|
1696
|
+
const secUseMemo = secMemoAttr === 'true' || secMemoAttr === true || (typeof secMemoAttr === 'string' && secMemoAttr !== 'false');
|
|
1697
|
+
const secMemoComp = secUseMemo && typeof secMemoAttr === 'string' && secMemoAttr !== 'true' ? secMemoAttr : null;
|
|
1698
|
+
const secMemoExpr = secMemoComp && isExpr(secProps.memo) ? secProps.memo.code : secMemoComp;
|
|
1699
|
+
if (secUseMemo) {
|
|
1700
|
+
componentLines.push(`const ${secName} = React.memo(function ${secName}(${secParam}) {`);
|
|
1701
|
+
if (secCtx.needsInkSafe)
|
|
1702
|
+
componentLines.push(...emitInkSafePreamble());
|
|
1703
|
+
componentLines.push(...secBodyLines);
|
|
1704
|
+
componentLines.push(secMemoExpr ? `}, ${secMemoExpr});` : '});');
|
|
1705
|
+
const secExportStatement = inkScreenExportStatement(secExportKw, secName);
|
|
1706
|
+
if (secExportStatement) {
|
|
1707
|
+
componentLines.push(secExportStatement);
|
|
807
1708
|
}
|
|
808
|
-
bodyLines.push(` }, ${mDepsArr});`);
|
|
809
|
-
bodyLines.push('');
|
|
810
1709
|
}
|
|
1710
|
+
else {
|
|
1711
|
+
componentLines.push(`${secExportKw ? `${secExportKw} ` : ''}function ${secName}(${secParam}) {`);
|
|
1712
|
+
if (secCtx.needsInkSafe)
|
|
1713
|
+
componentLines.push(...emitInkSafePreamble());
|
|
1714
|
+
componentLines.push(...secBodyLines);
|
|
1715
|
+
componentLines.push('}');
|
|
1716
|
+
}
|
|
1717
|
+
componentLines.push('');
|
|
811
1718
|
}
|
|
812
|
-
//
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
bodyLines.push('');
|
|
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
|
-
}
|
|
1719
|
+
// Component (Feature #9: with props) — respect export= and memo= attributes
|
|
1720
|
+
const screenExportKw = inkScreenExportKeyword(screenProps.export);
|
|
1721
|
+
const screenMemoAttr = screenProps.memo;
|
|
1722
|
+
const useMemo = screenMemoAttr === 'true' ||
|
|
1723
|
+
screenMemoAttr === true ||
|
|
1724
|
+
(typeof screenMemoAttr === 'string' && screenMemoAttr !== 'false');
|
|
1725
|
+
const memoComparator = useMemo && typeof screenMemoAttr === 'string' && screenMemoAttr !== 'true' ? screenMemoAttr : null;
|
|
1726
|
+
const memoComparatorExpr = memoComparator && isExpr(screenProps.memo) ? screenProps.memo.code : memoComparator;
|
|
1727
|
+
if (useMemo) {
|
|
1728
|
+
// React.memo wrapper: const Name = React.memo(function Name(props) { ... }, comparator?);
|
|
1729
|
+
componentLines.push(`const ${screenName} = React.memo(function ${screenName}(${propsParam}) {`);
|
|
1730
|
+
if (stateCtx.needsInkSafe) {
|
|
1731
|
+
componentLines.push(...emitInkSafePreamble());
|
|
1732
|
+
}
|
|
1733
|
+
componentLines.push(...bodyLines);
|
|
1734
|
+
if (memoComparatorExpr) {
|
|
1735
|
+
componentLines.push(`}, ${memoComparatorExpr});`);
|
|
840
1736
|
}
|
|
841
1737
|
else {
|
|
842
|
-
|
|
1738
|
+
componentLines.push('});');
|
|
1739
|
+
}
|
|
1740
|
+
const exportStatement = inkScreenExportStatement(screenExportKw, screenName);
|
|
1741
|
+
if (exportStatement) {
|
|
1742
|
+
componentLines.push(exportStatement);
|
|
843
1743
|
}
|
|
844
1744
|
}
|
|
845
1745
|
else {
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
for (const child of uiChildren) {
|
|
850
|
-
bodyLines.push(...renderInkNode(child, ' ', imports));
|
|
1746
|
+
componentLines.push(`${screenExportKw ? `${screenExportKw} ` : ''}function ${screenName}(${propsParam}) {`);
|
|
1747
|
+
if (stateCtx.needsInkSafe) {
|
|
1748
|
+
componentLines.push(...emitInkSafePreamble());
|
|
851
1749
|
}
|
|
852
|
-
|
|
853
|
-
|
|
1750
|
+
componentLines.push(...bodyLines);
|
|
1751
|
+
componentLines.push('}');
|
|
854
1752
|
}
|
|
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);
|
|
862
|
-
}
|
|
863
|
-
// Component (Feature #9: with props)
|
|
864
|
-
lines.push(`export default function ${screenName}(${propsParam}) {`);
|
|
865
|
-
lines.push(...bodyLines);
|
|
866
|
-
lines.push('}');
|
|
867
1753
|
// File-level functions/constants emitted after the screen component
|
|
868
1754
|
if (fileLevelFns.length > 0) {
|
|
869
|
-
|
|
1755
|
+
componentLines.push('');
|
|
870
1756
|
for (const fn of fileLevelFns) {
|
|
871
|
-
|
|
872
|
-
|
|
1757
|
+
componentLines.push(...generateCoreNode(fn));
|
|
1758
|
+
componentLines.push('');
|
|
873
1759
|
}
|
|
874
1760
|
}
|
|
1761
|
+
// NOW emit imports — after all components have populated the tracker
|
|
1762
|
+
lines.push(...imports.emit());
|
|
1763
|
+
// Cross-file screen imports (screen-embed with from=)
|
|
1764
|
+
for (const [path, names] of _crossFileImports) {
|
|
1765
|
+
lines.push(`import { ${[...names].sort().join(', ')} } from '${path}';`);
|
|
1766
|
+
}
|
|
1767
|
+
_crossFileImports.clear();
|
|
1768
|
+
lines.push('');
|
|
1769
|
+
lines.push(...componentLines);
|
|
875
1770
|
// Source map
|
|
876
1771
|
sourceMap.push({
|
|
877
1772
|
irLine: root.loc?.line || 0,
|
|
@@ -884,12 +1779,39 @@ export function transpileInk(root, _config) {
|
|
|
884
1779
|
const irTokenCount = countTokens(irText);
|
|
885
1780
|
const tsTokenCount = countTokens(code);
|
|
886
1781
|
const tokenReduction = Math.round((1 - irTokenCount / tsTokenCount) * 100);
|
|
1782
|
+
// Generate artifacts: entry point + per-screen component files for multi-screen
|
|
1783
|
+
const artifacts = [];
|
|
1784
|
+
// Entry-point artifact: render(<App />) + waitUntilExit()
|
|
1785
|
+
if (screenExportKw) {
|
|
1786
|
+
const entryLines = [];
|
|
1787
|
+
entryLines.push(`#!/usr/bin/env node`);
|
|
1788
|
+
entryLines.push(`import React from 'react';`);
|
|
1789
|
+
entryLines.push(`import { render } from 'ink';`);
|
|
1790
|
+
if (screenExportKw === 'export default') {
|
|
1791
|
+
entryLines.push(`import ${screenName} from './${screenName}.js';`);
|
|
1792
|
+
}
|
|
1793
|
+
else {
|
|
1794
|
+
entryLines.push(`import { ${screenName} } from './${screenName}.js';`);
|
|
1795
|
+
}
|
|
1796
|
+
entryLines.push('');
|
|
1797
|
+
entryLines.push(`const app = render(<${screenName} />);`);
|
|
1798
|
+
entryLines.push(`await app.waitUntilExit();`);
|
|
1799
|
+
artifacts.push({ path: 'index.tsx', content: entryLines.join('\n'), type: 'entry' });
|
|
1800
|
+
}
|
|
1801
|
+
// Main component artifact (always emitted so entry-point import resolves)
|
|
1802
|
+
artifacts.push({ path: `${screenName}.tsx`, content: code, type: 'component' });
|
|
1803
|
+
// Per-screen component artifacts for secondary screens
|
|
1804
|
+
for (const secScreen of secondaryScreens) {
|
|
1805
|
+
const secName = getProps(secScreen).name || 'Component';
|
|
1806
|
+
artifacts.push({ path: `${secName}.tsx`, content: '', type: 'component' });
|
|
1807
|
+
}
|
|
887
1808
|
return {
|
|
888
1809
|
code,
|
|
889
1810
|
sourceMap,
|
|
890
1811
|
irTokenCount,
|
|
891
1812
|
tsTokenCount,
|
|
892
1813
|
tokenReduction,
|
|
1814
|
+
artifacts,
|
|
893
1815
|
diagnostics: (() => {
|
|
894
1816
|
const accounted = new Map();
|
|
895
1817
|
accountNode(accounted, root, 'expressed', undefined, true);
|