@kernlang/react 2.0.0 → 3.0.0

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.
@@ -1,7 +1,8 @@
1
- import { stylesToTailwind, colorToTw, countTokens, serializeIR, camelKey, escapeJsxText, escapeJsString, buildTailwindProfile, buildNextjsProfile, applyTailwindTokenRules } from '@kernlang/core';
1
+ import { stylesToTailwind, colorToTw, countTokens, serializeIR, escapeJsxText, escapeJsxAttr, escapeJsString, buildTailwindProfile, buildNextjsProfile, applyTailwindTokenRules } from '@kernlang/core';
2
2
  import { planStructure } from './structure.js';
3
3
  import { buildStructuredArtifacts } from './artifact-utils.js';
4
4
  function getProps(node) { return node.props || {}; }
5
+ function isExpr(v) { return typeof v === 'object' && v !== null && '__expr' in v; }
5
6
  function getStyles(node) { return getProps(node).styles || {}; }
6
7
  // ── Unified import helpers (from Codex) ──────────────────────────────────
7
8
  function addDefaultImport(ctx, source, name) {
@@ -56,11 +57,53 @@ function emitImports(ctx) {
56
57
  return lines;
57
58
  }
58
59
  function twClasses(node, ctx, extra = '') {
59
- let tw = stylesToTailwind(getStyles(node), ctx.colors);
60
+ const styles = getStyles(node);
61
+ // Extract className pass-through (e.g. {className:doc.page} → className={doc.page})
62
+ const classNameRef = styles.className;
63
+ const inlineStyles = {};
64
+ const filteredStyles = {};
65
+ for (const [k, v] of Object.entries(styles)) {
66
+ if (k === 'className')
67
+ continue;
68
+ // CSS custom properties and complex values → inline style
69
+ if (v.includes('var(') || k === 'borderBottom' || k === 'background' || k === 'color' || k === 'fontFamily') {
70
+ inlineStyles[k] = v;
71
+ }
72
+ else {
73
+ filteredStyles[k] = v;
74
+ }
75
+ }
76
+ let tw = stylesToTailwind(filteredStyles, ctx.colors);
60
77
  if (ctx.twProfile)
61
78
  tw = applyTailwindTokenRules(tw, ctx.twProfile);
62
79
  const parts = [tw, extra].filter(Boolean);
63
- return parts.length > 0 ? ` className="${parts.join(' ')}"` : '';
80
+ const attrs = [];
81
+ if (classNameRef) {
82
+ // className is a JS expression reference like doc.page
83
+ if (parts.length > 0) {
84
+ attrs.push(` className={\`\${${classNameRef}} ${parts.join(' ')}\`}`);
85
+ }
86
+ else {
87
+ attrs.push(` className={${classNameRef}}`);
88
+ }
89
+ }
90
+ else if (parts.length > 0) {
91
+ attrs.push(` className="${parts.join(' ')}"`);
92
+ }
93
+ if (Object.keys(inlineStyles).length > 0) {
94
+ const pairs = Object.entries(inlineStyles).map(([k, v]) => `${k}: '${v}'`);
95
+ attrs.push(` style={{ ${pairs.join(', ')} }}`);
96
+ }
97
+ return attrs.join('');
98
+ }
99
+ // ── Route path helper ────────────────────────────────────────────────────
100
+ function routeToPath(route, segment) {
101
+ // Normalize: strip leading/trailing slashes
102
+ const normalized = route.replace(/^\/+|\/+$/g, '');
103
+ const parts = normalized ? normalized.split('/') : [];
104
+ if (segment)
105
+ parts.push(segment);
106
+ return parts.length > 0 ? parts.join('/') + '/' : '';
64
107
  }
65
108
  // ── Node renderers ──────────────────────────────────────────────────────
66
109
  function renderNode(node, ctx, indent) {
@@ -114,8 +157,12 @@ function renderNode(node, ctx, indent) {
114
157
  case 'image':
115
158
  renderImage(node, ctx, indent);
116
159
  break;
160
+ case 'codeblock':
161
+ renderCodeBlock(node, ctx, indent);
162
+ break;
117
163
  case 'input':
118
- ctx.lines.push(`${indent}<input${twClasses(node, ctx)} />`);
164
+ case 'textarea':
165
+ renderInput(node, ctx, indent);
119
166
  break;
120
167
  case 'slider':
121
168
  renderSlider(node, ctx, indent);
@@ -157,6 +204,32 @@ function renderNode(node, ctx, indent) {
157
204
  case 'tab':
158
205
  ctx.lines.push(`${indent}<button${twClasses(node, ctx)}>${escapeJsxText(String(p.label || ''))}</button>`);
159
206
  break;
207
+ case 'table':
208
+ ctx.lines.push(`${indent}<table${twClasses(node, ctx)}>`);
209
+ renderChildren(node, ctx, indent);
210
+ ctx.lines.push(`${indent}</table>`);
211
+ break;
212
+ case 'thead':
213
+ ctx.lines.push(`${indent}<thead>`);
214
+ renderChildren(node, ctx, indent);
215
+ ctx.lines.push(`${indent}</thead>`);
216
+ break;
217
+ case 'tbody':
218
+ ctx.lines.push(`${indent}<tbody>`);
219
+ renderChildren(node, ctx, indent);
220
+ ctx.lines.push(`${indent}</tbody>`);
221
+ break;
222
+ case 'tr':
223
+ ctx.lines.push(`${indent}<tr${twClasses(node, ctx)}>`);
224
+ renderChildren(node, ctx, indent);
225
+ ctx.lines.push(`${indent}</tr>`);
226
+ break;
227
+ case 'th':
228
+ renderTableCell(node, ctx, indent, 'th');
229
+ break;
230
+ case 'td':
231
+ renderTableCell(node, ctx, indent, 'td');
232
+ break;
160
233
  case 'generateMetadata':
161
234
  renderGenerateMetadata(node, ctx);
162
235
  break;
@@ -172,6 +245,23 @@ function renderNode(node, ctx, indent) {
172
245
  case 'fetch':
173
246
  renderFetchNode(node, ctx);
174
247
  break;
248
+ case 'on':
249
+ renderOnHandler(node, ctx);
250
+ return;
251
+ case 'state':
252
+ ctx.stateDecls.push({ name: String(p.name || ''), initial: String(p.initial ?? '') });
253
+ ctx.isClient = true; // state requires 'use client'
254
+ return;
255
+ case 'logic':
256
+ if (p.code)
257
+ ctx.logicBlocks.push(String(p.code));
258
+ else if (node.children) {
259
+ const handlerChild = node.children.find(c => c.type === 'handler');
260
+ if (handlerChild?.props?.code)
261
+ ctx.logicBlocks.push(String(handlerChild.props.code));
262
+ }
263
+ ctx.isClient = true;
264
+ return;
175
265
  case 'theme': break;
176
266
  default:
177
267
  ctx.lines.push(`${indent}<div${twClasses(node, ctx)}>`);
@@ -231,12 +321,51 @@ function renderMetadata(node, ctx) {
231
321
  function renderSection(node, ctx, indent) {
232
322
  const p = getProps(node);
233
323
  const title = p.title || '';
234
- const key = p.key || camelKey(title);
235
- ctx.lines.push(`${indent}<section>`);
236
- ctx.lines.push(`${indent} <h2 className="text-lg font-semibold mb-4">${escapeJsxText(title)}</h2>`);
324
+ const id = p.id;
325
+ const idAttr = id ? ` id="${id}"` : '';
326
+ const tw = twClasses(node, ctx);
327
+ ctx.lines.push(`${indent}<section${idAttr}${tw}>`);
328
+ if (title) {
329
+ ctx.lines.push(`${indent} <h2 className="text-lg font-semibold mb-4">${escapeJsxText(title)}</h2>`);
330
+ }
237
331
  renderChildren(node, ctx, indent);
238
332
  ctx.lines.push(`${indent}</section>`);
239
333
  }
334
+ function renderCodeBlock(node, ctx, indent) {
335
+ const p = getProps(node);
336
+ const lang = p.lang || '';
337
+ const langClass = lang ? ` language-${lang}` : '';
338
+ const hasCustomStyle = getStyles(node).className || getStyles(node).background;
339
+ const preAttrs = hasCustomStyle ? twClasses(node, ctx) : ` className="bg-zinc-900 rounded-lg p-4 overflow-x-auto"`;
340
+ const codeClass = hasCustomStyle
341
+ ? `className="${langClass.trim()}"` + (getStyles(node).fontFamily ? ` style={{ fontFamily: '${getStyles(node).fontFamily}' }}` : '')
342
+ : `className="text-sm font-mono text-zinc-100${langClass}"`;
343
+ // Content: inline value prop or body child node
344
+ const rawValue = p.value;
345
+ if (isExpr(rawValue)) {
346
+ ctx.lines.push(`${indent}<pre${preAttrs}>`);
347
+ ctx.lines.push(`${indent} <code ${codeClass}>{${rawValue.code}}</code>`);
348
+ ctx.lines.push(`${indent}</pre>`);
349
+ return;
350
+ }
351
+ let content = rawValue || '';
352
+ if (!content && node.children) {
353
+ const bodyNode = node.children.find(c => c.type === 'body');
354
+ if (bodyNode) {
355
+ const bp = getProps(bodyNode);
356
+ // body value="..." OR body <<<...>>> (multiline block → code prop)
357
+ content = bp.code || bp.value || '';
358
+ }
359
+ }
360
+ // Escape for JSX template literal: backslashes, backticks, ${
361
+ const escaped = content
362
+ .replace(/\\/g, '\\\\')
363
+ .replace(/`/g, '\\`')
364
+ .replace(/\$\{/g, '\\${');
365
+ ctx.lines.push(`${indent}<pre${preAttrs}>`);
366
+ ctx.lines.push(`${indent} <code ${codeClass}>{\`${escaped}\`}</code>`);
367
+ ctx.lines.push(`${indent}</pre>`);
368
+ }
240
369
  function renderCard(node, ctx, indent) {
241
370
  const styles = { ...getStyles(node) };
242
371
  const border = styles.border;
@@ -258,30 +387,67 @@ function renderCard(node, ctx, indent) {
258
387
  ctx.lines.push(`${indent}</div>`);
259
388
  }
260
389
  }
390
+ const TEXT_TAG_MAP = { p: 'p', h1: 'h1', h2: 'h2', h3: 'h3', h4: 'h4', h5: 'h5', h6: 'h6', label: 'label', span: 'span', pre: 'pre', code: 'code' };
261
391
  function renderText(node, ctx, indent) {
262
392
  const p = getProps(node);
263
- const value = p.value;
393
+ const rawValue = p.value;
264
394
  const bind = p.bind;
265
- const el = p.tag === 'p' ? 'p' : p.tag === 'h1' ? 'h1' : p.tag === 'h2' ? 'h2' : p.tag === 'label' ? 'label' : 'span';
395
+ const el = TEXT_TAG_MAP[p.tag] || 'span';
266
396
  const tw = twClasses(node, ctx);
267
- if (bind)
397
+ if (isExpr(rawValue))
398
+ ctx.lines.push(`${indent}<${el}${tw}>{${rawValue.code}}</${el}>`);
399
+ else if (bind)
268
400
  ctx.lines.push(`${indent}<${el}${tw}>{${bind}}</${el}>`);
269
- else if (value)
270
- ctx.lines.push(`${indent}<${el}${tw}>${escapeJsxText(value)}</${el}>`);
401
+ else if (rawValue)
402
+ ctx.lines.push(`${indent}<${el}${tw}>${escapeJsxText(rawValue)}</${el}>`);
271
403
  }
272
404
  function renderButton(node, ctx, indent) {
273
405
  const p = getProps(node);
274
406
  const text = p.text || '';
275
407
  const to = p.to;
276
- const onClick = p.onClick;
408
+ const rawOnClick = p.onClick;
409
+ const onClick = isExpr(rawOnClick) ? rawOnClick.code : rawOnClick;
277
410
  if (to) {
278
411
  addDefaultImport(ctx, 'next/link', 'Link');
279
412
  ctx.lines.push(`${indent}<Link href="/${to.toLowerCase()}"${twClasses(node, ctx)}>${escapeJsxText(text)}</Link>`);
280
413
  }
281
414
  else {
415
+ ctx.isClient = true; // onClick requires 'use client'
282
416
  ctx.lines.push(`${indent}<button${twClasses(node, ctx)} onClick={${onClick || '() => {}'}}>${escapeJsxText(text)}</button>`);
283
417
  }
284
418
  }
419
+ function renderInput(node, ctx, indent) {
420
+ const p = getProps(node);
421
+ const isTextarea = node.type === 'textarea' || p.type === 'textarea' || p.multiline;
422
+ const tag = isTextarea ? 'textarea' : 'input';
423
+ const attrs = [];
424
+ const tw = twClasses(node, ctx);
425
+ if (p.bind) {
426
+ const bind = p.bind;
427
+ const setter = `set${bind.charAt(0).toUpperCase() + bind.slice(1)}`;
428
+ attrs.push(`value={${bind}}`);
429
+ ctx.isClient = true; // onChange requires 'use client'
430
+ if (isExpr(p.onChange))
431
+ attrs.push(`onChange={${p.onChange.code}}`);
432
+ else if (p.onChange)
433
+ attrs.push(`onChange={${p.onChange}}`);
434
+ else
435
+ attrs.push(`onChange={(e) => ${setter}(e.target.value)}`);
436
+ }
437
+ if (p.placeholder)
438
+ attrs.push(`placeholder="${p.placeholder}"`);
439
+ if (!isTextarea && p.type && p.type !== 'textarea')
440
+ attrs.push(`type="${p.type}"`);
441
+ if (p.spellcheck === 'false' || p.spellcheck === false)
442
+ attrs.push('spellCheck={false}');
443
+ const attrStr = attrs.length > 0 ? ' ' + attrs.join(' ') : '';
444
+ if (isTextarea) {
445
+ ctx.lines.push(`${indent}<${tag}${tw}${attrStr} rows={4} />`);
446
+ }
447
+ else {
448
+ ctx.lines.push(`${indent}<${tag}${tw}${attrStr} />`);
449
+ }
450
+ }
285
451
  function renderLink(node, ctx, indent) {
286
452
  const p = getProps(node);
287
453
  addDefaultImport(ctx, 'next/link', 'Link');
@@ -293,25 +459,107 @@ function renderImage(node, ctx, indent) {
293
459
  const p = getProps(node);
294
460
  addDefaultImport(ctx, 'next/image', 'Image');
295
461
  const tw = twClasses(node, ctx);
296
- const width = p.width || (getStyles(node).w) || '100';
297
- const height = p.height || (getStyles(node).h) || '100';
298
- ctx.lines.push(`${indent}<Image src="/${p.src}.png" alt="${p.alt || p.src}" width={${width}} height={${height}}${tw} />`);
462
+ const rawSrc = p.src || '';
463
+ const src = (rawSrc.startsWith('/') || rawSrc.includes('://') || rawSrc.includes('.')) ? rawSrc : `/${rawSrc}.png`;
464
+ const alt = escapeJsxAttr(String(p.alt || p.src || ''));
465
+ const fill = p.fill === 'true' || p.fill === true;
466
+ const priority = p.priority === 'true' || p.priority === true;
467
+ if (fill) {
468
+ ctx.lines.push(`${indent}<Image src="${src}" alt="${alt}"${priority ? ' priority' : ''} fill${tw} />`);
469
+ }
470
+ else {
471
+ const width = p.width || (getStyles(node).w) || '100';
472
+ const height = p.height || (getStyles(node).h) || '100';
473
+ ctx.lines.push(`${indent}<Image src="${src}" alt="${alt}" width={${width}} height={${height}}${priority ? ' priority' : ''}${tw} />`);
474
+ }
299
475
  }
300
476
  function renderSlider(node, ctx, indent) {
301
477
  const p = getProps(node);
302
478
  const bind = p.bind;
303
479
  const setter = bind ? `set${bind.charAt(0).toUpperCase() + bind.slice(1)}` : 'setValue';
480
+ ctx.isClient = true; // onChange requires 'use client'
304
481
  ctx.lines.push(`${indent}<input type="range" min={${p.min || 0}} max={${p.max || 100}} step={${p.step || 1}} value={${bind || 'value'}} onChange={(e) => ${setter}(parseFloat(e.target.value))} className="w-full h-2 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-orange-500" />`);
305
482
  }
306
483
  function renderToggle(node, ctx, indent) {
307
484
  const p = getProps(node);
308
485
  const bind = p.bind;
309
486
  const setter = bind ? `set${bind.charAt(0).toUpperCase() + bind.slice(1)}` : 'setValue';
487
+ ctx.isClient = true; // onChange requires 'use client'
310
488
  ctx.lines.push(`${indent}<label className="relative inline-flex items-center cursor-pointer">`);
311
489
  ctx.lines.push(`${indent} <input type="checkbox" className="sr-only peer" checked={${bind || 'value'}} onChange={(e) => ${setter}(e.target.checked)} />`);
312
490
  ctx.lines.push(`${indent} <div className="w-11 h-6 bg-zinc-700 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-orange-600" />`);
313
491
  ctx.lines.push(`${indent}</label>`);
314
492
  }
493
+ function renderOnHandler(node, ctx) {
494
+ const p = getProps(node);
495
+ const event = (p.event || p.name);
496
+ const handlerRef = p.handler;
497
+ const key = p.key;
498
+ const isAsync = p.async === 'true' || p.async === true;
499
+ const handlerChild = (node.children || []).find(c => c.type === 'handler');
500
+ const code = handlerChild ? (getProps(handlerChild).code || '') : '';
501
+ if (handlerRef && !code)
502
+ return;
503
+ ctx.isClient = true; // event handlers require 'use client'
504
+ const fnName = handlerRef || `handle${event.charAt(0).toUpperCase() + event.slice(1)}`;
505
+ const asyncKw = isAsync ? 'async ' : '';
506
+ const paramType = event === 'submit' ? 'e: React.FormEvent'
507
+ : event === 'click' ? 'e: React.MouseEvent'
508
+ : event === 'change' ? 'e: React.ChangeEvent'
509
+ : event === 'key' || event === 'keydown' || event === 'keyup' ? 'e: React.KeyboardEvent'
510
+ : event === 'focus' || event === 'blur' ? 'e: React.FocusEvent'
511
+ : event === 'scroll' ? 'e: React.UIEvent'
512
+ : `e: React.SyntheticEvent`;
513
+ const keyGuard = key ? ` if (e.key !== '${key}') return;\n` : '';
514
+ addNamedImport(ctx, 'react', 'useCallback');
515
+ let block = ` const ${fnName} = useCallback(${asyncKw}(${paramType}) => {\n`;
516
+ if (keyGuard)
517
+ block += keyGuard;
518
+ if (code) {
519
+ for (const line of code.split('\n')) {
520
+ block += ` ${line}\n`;
521
+ }
522
+ }
523
+ block += ` }, []);\n`;
524
+ ctx.bodyLines.push(block);
525
+ if (event === 'key' || event === 'keydown' || event === 'keyup') {
526
+ addNamedImport(ctx, 'react', 'useEffect');
527
+ const domEvent = event === 'key' ? 'keydown' : event;
528
+ let effect = ` useEffect(() => {\n`;
529
+ effect += ` const listener = (e: KeyboardEvent) => ${fnName}(e as unknown as React.KeyboardEvent);\n`;
530
+ effect += ` window.addEventListener('${domEvent}', listener);\n`;
531
+ effect += ` return () => window.removeEventListener('${domEvent}', listener);\n`;
532
+ effect += ` }, [${fnName}]);\n`;
533
+ ctx.bodyLines.push(effect);
534
+ }
535
+ if (event === 'resize') {
536
+ addNamedImport(ctx, 'react', 'useEffect');
537
+ let effect = ` useEffect(() => {\n`;
538
+ effect += ` window.addEventListener('resize', ${fnName});\n`;
539
+ effect += ` return () => window.removeEventListener('resize', ${fnName});\n`;
540
+ effect += ` }, [${fnName}]);\n`;
541
+ ctx.bodyLines.push(effect);
542
+ }
543
+ }
544
+ function renderTableCell(node, ctx, indent, tag) {
545
+ const p = getProps(node);
546
+ const tw = twClasses(node, ctx);
547
+ const rawValue = p.value;
548
+ if (isExpr(rawValue)) {
549
+ ctx.lines.push(`${indent}<${tag}${tw}>{${rawValue.code}}</${tag}>`);
550
+ }
551
+ else if (rawValue) {
552
+ ctx.lines.push(`${indent}<${tag}${tw}>${escapeJsxText(rawValue)}</${tag}>`);
553
+ }
554
+ else if (node.children && node.children.length > 0) {
555
+ ctx.lines.push(`${indent}<${tag}${tw}>`);
556
+ renderChildren(node, ctx, indent);
557
+ ctx.lines.push(`${indent}</${tag}>`);
558
+ }
559
+ else {
560
+ ctx.lines.push(`${indent}<${tag}${tw} />`);
561
+ }
562
+ }
315
563
  function renderGrid(node, ctx, indent) {
316
564
  const p = getProps(node);
317
565
  ctx.lines.push(`${indent}<div className="grid grid-cols-1 md:grid-cols-${p.cols || 1} gap-${Math.round(Number(p.gap || 16) / 4)}">`);
@@ -328,14 +576,14 @@ function renderConditional(node, ctx, indent) {
328
576
  }
329
577
  function renderComponent(node, ctx, indent) {
330
578
  const p = getProps(node);
331
- const ref = p.ref;
579
+ const ref = (p.ref || p.name);
332
580
  if (!ref)
333
581
  return;
334
582
  ctx.componentImports.add(ref);
335
583
  const hasOnChange = 'onChange' in p;
336
584
  const attrs = [];
337
585
  for (const [k, v] of Object.entries(p)) {
338
- if (['ref', 'styles', 'pseudoStyles', 'themeRefs'].includes(k))
586
+ if (['ref', 'name', 'styles', 'pseudoStyles', 'themeRefs'].includes(k))
339
587
  continue;
340
588
  if (k === 'bind') {
341
589
  attrs.push(`value={${v}}`);
@@ -355,7 +603,15 @@ function renderComponent(node, ctx, indent) {
355
603
  else
356
604
  attrs.push(`${k}={${JSON.stringify(v)}}`);
357
605
  }
358
- ctx.lines.push(`${indent}<${ref}${attrs.length ? ' ' + attrs.join(' ') : ''} />`);
606
+ const attrStr = attrs.length ? ' ' + attrs.join(' ') : '';
607
+ if (node.children && node.children.length > 0) {
608
+ ctx.lines.push(`${indent}<${ref}${attrStr}>`);
609
+ renderChildren(node, ctx, indent);
610
+ ctx.lines.push(`${indent}</${ref}>`);
611
+ }
612
+ else {
613
+ ctx.lines.push(`${indent}<${ref}${attrStr} />`);
614
+ }
359
615
  }
360
616
  function renderProgress(node, ctx, indent) {
361
617
  const p = getProps(node);
@@ -446,6 +702,8 @@ function _transpileNextjsInner(root, config) {
446
702
  generateMetadataInfo: null,
447
703
  fetchCalls: [],
448
704
  bodyLines: [],
705
+ stateDecls: [],
706
+ logicBlocks: [],
449
707
  colors: config?.colors,
450
708
  twProfile: config?.frameworkVersions ? buildTailwindProfile(config.frameworkVersions) : undefined,
451
709
  njProfile: config?.frameworkVersions ? buildNextjsProfile(config.frameworkVersions) : undefined,
@@ -461,6 +719,11 @@ function _transpileNextjsInner(root, config) {
461
719
  // If there are fetch calls, mark page as async
462
720
  if (ctx.fetchCalls.length > 0)
463
721
  ctx.isAsync = true;
722
+ // Client components cannot be async in Next.js — client wins, drop server-only patterns
723
+ if (ctx.isClient && ctx.isAsync) {
724
+ ctx.isAsync = false;
725
+ ctx.fetchCalls = [];
726
+ }
464
727
  const name = rootProps.name || 'Page';
465
728
  const isLayout = root.type === 'layout';
466
729
  const isLoading = root.type === 'loading';
@@ -490,6 +753,21 @@ function _transpileNextjsInner(root, config) {
490
753
  if (ctx.metadata && !ctx.isClient && !ctx.generateMetadataInfo) {
491
754
  addNamedImport(ctx, 'next', 'Metadata', true);
492
755
  }
756
+ // State requires useState import
757
+ if (ctx.stateDecls.length > 0) {
758
+ addNamedImport(ctx, 'react', 'useState');
759
+ }
760
+ // Detect hook imports from logic blocks before emitting imports
761
+ for (const block of ctx.logicBlocks) {
762
+ if (block.includes('useEffect'))
763
+ addNamedImport(ctx, 'react', 'useEffect');
764
+ if (block.includes('useCallback'))
765
+ addNamedImport(ctx, 'react', 'useCallback');
766
+ if (block.includes('useMemo'))
767
+ addNamedImport(ctx, 'react', 'useMemo');
768
+ if (block.includes('useRef'))
769
+ addNamedImport(ctx, 'react', 'useRef');
770
+ }
493
771
  // Emit all imports (unified, sorted)
494
772
  code.push(...emitImports(ctx));
495
773
  if (code.length > 0 && code[code.length - 1] !== '')
@@ -560,6 +838,42 @@ function _transpileNextjsInner(root, config) {
560
838
  code.push(` const ${fc.name} = await fetch('${fc.url}').then(r => r.json());`);
561
839
  }
562
840
  }
841
+ // Emit useState declarations
842
+ for (const s of ctx.stateDecls) {
843
+ const setter = `set${s.name.charAt(0).toUpperCase() + s.name.slice(1)}`;
844
+ const stateNode = root.children?.find(c => c.type === 'state' && c.props?.name === s.name);
845
+ const initProp = stateNode?.props?.initial;
846
+ const isExprInit = typeof initProp === 'object' && initProp !== null && '__expr' in initProp;
847
+ let initVal;
848
+ if (isExprInit) {
849
+ initVal = initProp.code;
850
+ }
851
+ else if (s.initial === 'true' || s.initial === 'false') {
852
+ initVal = s.initial;
853
+ }
854
+ else if (s.initial === '' || s.initial === "''") {
855
+ initVal = "''";
856
+ }
857
+ else if (!isNaN(Number(s.initial)) && s.initial !== '') {
858
+ initVal = s.initial;
859
+ }
860
+ else {
861
+ initVal = `'${s.initial}'`;
862
+ }
863
+ code.push(` const [${s.name}, ${setter}] = useState(${initVal});`);
864
+ }
865
+ // Emit logic blocks & detect hook imports
866
+ for (const block of ctx.logicBlocks) {
867
+ code.push(` ${block}`);
868
+ if (block.includes('useEffect'))
869
+ addNamedImport(ctx, 'react', 'useEffect');
870
+ if (block.includes('useCallback'))
871
+ addNamedImport(ctx, 'react', 'useCallback');
872
+ if (block.includes('useMemo'))
873
+ addNamedImport(ctx, 'react', 'useMemo');
874
+ if (block.includes('useRef'))
875
+ addNamedImport(ctx, 'react', 'useRef');
876
+ }
563
877
  // Emit body lines (notFound, redirect calls)
564
878
  for (const line of ctx.bodyLines) {
565
879
  code.push(line);
@@ -573,16 +887,19 @@ function _transpileNextjsInner(root, config) {
573
887
  const irTokenCount = countTokens(irText);
574
888
  const tsTokenCount = countTokens(output);
575
889
  const tokenReduction = tsTokenCount > 0 ? Math.round((1 - irTokenCount / tsTokenCount) * 100) : 0;
576
- // Determine output filename convention
890
+ // Determine output filename convention (route-aware)
891
+ const route = rootProps.route;
892
+ const segment = rootProps.segment;
893
+ const routePrefix = route ? routeToPath(route, segment) : (segment ? routeToPath('', segment) : '');
577
894
  const files = [];
578
895
  if (isLayout)
579
- files.push({ path: 'layout.tsx', content: output });
896
+ files.push({ path: `${routePrefix}layout.tsx`, content: output });
580
897
  else if (isLoading)
581
- files.push({ path: 'loading.tsx', content: output });
898
+ files.push({ path: `${routePrefix}loading.tsx`, content: output });
582
899
  else if (isError)
583
- files.push({ path: 'error.tsx', content: output });
900
+ files.push({ path: `${routePrefix}error.tsx`, content: output });
584
901
  else
585
- files.push({ path: 'page.tsx', content: output });
902
+ files.push({ path: `${routePrefix}page.tsx`, content: output });
586
903
  return {
587
904
  code: output,
588
905
  sourceMap: ctx.sourceMap,
@@ -605,6 +922,8 @@ function _renderNextjsFile(file, config) {
605
922
  generateMetadataInfo: null,
606
923
  fetchCalls: [],
607
924
  bodyLines: [],
925
+ stateDecls: [],
926
+ logicBlocks: [],
608
927
  colors: config.colors,
609
928
  twProfile: config.frameworkVersions ? buildTailwindProfile(config.frameworkVersions) : undefined,
610
929
  njProfile: config.frameworkVersions ? buildNextjsProfile(config.frameworkVersions) : undefined,
@@ -621,8 +940,11 @@ function _renderNextjsFile(file, config) {
621
940
  const name = file.componentName || rootProps.name || 'Component';
622
941
  const isLayout = rootNode.type === 'layout';
623
942
  const isError = rootNode.type === 'error';
624
- // 'use client' only when the component actually has client-side interactivity
625
- // (ctx.isClient is set during rendering by renderPage/renderError/client=true flag)
943
+ // Client components cannot be async in Next.js client wins, drop server-only patterns
944
+ if (ctx.isClient && ctx.isAsync) {
945
+ ctx.isAsync = false;
946
+ ctx.fetchCalls = [];
947
+ }
626
948
  const code = [];
627
949
  if (ctx.isClient) {
628
950
  code.push(`'use client';`);
@@ -648,6 +970,21 @@ function _renderNextjsFile(file, config) {
648
970
  // If there are fetch calls, mark page as async
649
971
  if (ctx.fetchCalls.length > 0)
650
972
  ctx.isAsync = true;
973
+ // State requires useState import
974
+ if (ctx.stateDecls.length > 0) {
975
+ addNamedImport(ctx, 'react', 'useState');
976
+ }
977
+ // Detect hook imports from logic blocks before emitting imports
978
+ for (const block of ctx.logicBlocks) {
979
+ if (block.includes('useEffect'))
980
+ addNamedImport(ctx, 'react', 'useEffect');
981
+ if (block.includes('useCallback'))
982
+ addNamedImport(ctx, 'react', 'useCallback');
983
+ if (block.includes('useMemo'))
984
+ addNamedImport(ctx, 'react', 'useMemo');
985
+ if (block.includes('useRef'))
986
+ addNamedImport(ctx, 'react', 'useRef');
987
+ }
651
988
  code.push(...emitImports(ctx));
652
989
  if (code.length > 0 && code[code.length - 1] !== '')
653
990
  code.push('');
@@ -706,6 +1043,43 @@ function _renderNextjsFile(file, config) {
706
1043
  else {
707
1044
  code.push(`export default function ${name}() {`);
708
1045
  }
1046
+ // Emit useState declarations
1047
+ for (const s of ctx.stateDecls) {
1048
+ const setter = `set${s.name.charAt(0).toUpperCase() + s.name.slice(1)}`;
1049
+ const rootNode = file.nodes[0];
1050
+ const stateNode = rootNode?.children?.find((c) => c.type === 'state' && c.props?.name === s.name);
1051
+ const initProp = stateNode?.props?.initial;
1052
+ const isExpr = typeof initProp === 'object' && initProp !== null && '__expr' in initProp;
1053
+ let initVal;
1054
+ if (isExpr) {
1055
+ initVal = initProp.code;
1056
+ }
1057
+ else if (s.initial === 'true' || s.initial === 'false') {
1058
+ initVal = s.initial;
1059
+ }
1060
+ else if (s.initial === '' || s.initial === "''") {
1061
+ initVal = "''";
1062
+ }
1063
+ else if (!isNaN(Number(s.initial)) && s.initial !== '') {
1064
+ initVal = s.initial;
1065
+ }
1066
+ else {
1067
+ initVal = `'${s.initial}'`;
1068
+ }
1069
+ code.push(` const [${s.name}, ${setter}] = useState(${initVal});`);
1070
+ }
1071
+ // Emit logic blocks & detect hook imports
1072
+ for (const block of ctx.logicBlocks) {
1073
+ code.push(` ${block}`);
1074
+ if (block.includes('useEffect'))
1075
+ addNamedImport(ctx, 'react', 'useEffect');
1076
+ if (block.includes('useCallback'))
1077
+ addNamedImport(ctx, 'react', 'useCallback');
1078
+ if (block.includes('useMemo'))
1079
+ addNamedImport(ctx, 'react', 'useMemo');
1080
+ if (block.includes('useRef'))
1081
+ addNamedImport(ctx, 'react', 'useRef');
1082
+ }
709
1083
  // Emit fetch calls (inside async function body, before return)
710
1084
  for (const fc of ctx.fetchCalls) {
711
1085
  if (fc.options) {