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