@kernlang/react 3.1.6 → 3.1.8

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,779 +1,8 @@
1
- import { stylesToTailwind, colorToTw, countTokens, serializeIR, escapeJsxText, escapeJsxAttr, escapeJsString, buildTailwindProfile, buildNextjsProfile, applyTailwindTokenRules, getProps, getStyles, buildDiagnostics, accountNode } from '@kernlang/core';
2
- import { planStructure } from './structure.js';
1
+ import { accountNode, buildDiagnostics, countTokens, getProps, serializeIR } from '@kernlang/core';
3
2
  import { buildStructuredArtifacts } from './artifact-utils.js';
4
- function isExpr(v) { return typeof v === 'object' && v !== null && '__expr' in v; }
5
- // ── Unified import helpers (from Codex) ──────────────────────────────────
6
- function addDefaultImport(ctx, source, name) {
7
- const spec = ctx.imports.get(source) || { namedImports: new Set(), typeOnlyImports: new Set() };
8
- spec.defaultImport = name;
9
- ctx.imports.set(source, spec);
10
- }
11
- function addNamedImport(ctx, source, name, typeOnly) {
12
- const spec = ctx.imports.get(source) || { namedImports: new Set(), typeOnlyImports: new Set() };
13
- if (typeOnly) {
14
- spec.typeOnlyImports.add(name);
15
- }
16
- else {
17
- spec.namedImports.add(name);
18
- }
19
- ctx.imports.set(source, spec);
20
- }
21
- function exprCode(value, fallback) {
22
- if (typeof value === 'object' && value !== null && '__expr' in value) {
23
- return value.code;
24
- }
25
- if (typeof value === 'string' && value.length > 0)
26
- return value;
27
- return fallback;
28
- }
29
- function emitImports(ctx) {
30
- const lines = [];
31
- for (const [source, spec] of [...ctx.imports.entries()].sort(([a], [b]) => a.localeCompare(b))) {
32
- // Separate type-only imports from value imports
33
- const typeImports = [...spec.typeOnlyImports].filter(n => !spec.namedImports.has(n));
34
- const valueImports = [...spec.namedImports];
35
- // Emit type-only import statement if there are type imports and no value imports sharing the source
36
- if (typeImports.length > 0 && valueImports.length === 0 && !spec.defaultImport) {
37
- lines.push(`import type { ${typeImports.sort().join(', ')} } from '${source}';`);
38
- }
39
- else {
40
- // Emit value import (with default if present)
41
- const clauses = [];
42
- if (spec.defaultImport)
43
- clauses.push(spec.defaultImport);
44
- if (valueImports.length > 0)
45
- clauses.push(`{ ${valueImports.sort().join(', ')} }`);
46
- if (clauses.length > 0) {
47
- lines.push(`import ${clauses.join(', ')} from '${source}';`);
48
- }
49
- // Emit separate type-only import if both type and value imports exist
50
- if (typeImports.length > 0) {
51
- lines.push(`import type { ${typeImports.sort().join(', ')} } from '${source}';`);
52
- }
53
- }
54
- }
55
- return lines;
56
- }
57
- function twClasses(node, ctx, extra = '') {
58
- const rawStyles = getStyles(node);
59
- // Expand semicolon-separated values that the parser merged into one entry
60
- const styles = {};
61
- for (const [k, v] of Object.entries(rawStyles)) {
62
- if (v.includes(';')) {
63
- const segs = v.split(';').map(s => s.trim()).filter(Boolean);
64
- styles[k] = segs[0];
65
- for (let i = 1; i < segs.length; i++) {
66
- const ci = segs[i].indexOf(':');
67
- if (ci > 0)
68
- styles[segs[i].slice(0, ci).trim()] = segs[i].slice(ci + 1).trim();
69
- }
70
- }
71
- else {
72
- styles[k] = v;
73
- }
74
- }
75
- // Extract className pass-through (e.g. {className:doc.page} → className={doc.page})
76
- const classNameRef = styles.className;
77
- const inlineStyles = {};
78
- const filteredStyles = {};
79
- for (const [k, v] of Object.entries(styles)) {
80
- if (k === 'className')
81
- continue;
82
- // Vendor-prefixed properties, CSS custom properties, and complex values → inline style
83
- if (k.startsWith('-') || v.includes('var(') || k === 'borderBottom' || k === 'background' || k === 'color' || k === 'fontFamily') {
84
- inlineStyles[k] = v;
85
- }
86
- else {
87
- filteredStyles[k] = v;
88
- }
89
- }
90
- let tw = stylesToTailwind(filteredStyles, ctx.colors);
91
- if (ctx.twProfile)
92
- tw = applyTailwindTokenRules(tw, ctx.twProfile);
93
- const parts = [tw, extra].filter(Boolean);
94
- const attrs = [];
95
- if (classNameRef) {
96
- // Detect if className is a JS expression (contains . or [) vs a plain CSS class string
97
- const isExpr = /[.\[\](]/.test(classNameRef);
98
- if (isExpr) {
99
- if (parts.length > 0) {
100
- attrs.push(` className={\`\${${classNameRef}} ${parts.join(' ')}\`}`);
101
- }
102
- else {
103
- attrs.push(` className={${classNameRef}}`);
104
- }
105
- }
106
- else {
107
- // Plain CSS class name(s) — quote them
108
- if (parts.length > 0) {
109
- attrs.push(` className="${classNameRef} ${parts.join(' ')}"`);
110
- }
111
- else {
112
- attrs.push(` className="${classNameRef}"`);
113
- }
114
- }
115
- }
116
- else if (parts.length > 0) {
117
- attrs.push(` className="${parts.join(' ')}"`);
118
- }
119
- if (Object.keys(inlineStyles).length > 0) {
120
- const pairs = Object.entries(inlineStyles).map(([k, v]) => {
121
- let jsKey;
122
- if (k.startsWith('-')) {
123
- // Vendor prefix: -webkit-background-clip → WebkitBackgroundClip
124
- jsKey = k.slice(1).replace(/-([a-z])/g, (_, c) => c.toUpperCase())
125
- .replace(/^[a-z]/, c => c.toUpperCase());
126
- }
127
- else {
128
- jsKey = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
129
- }
130
- return `${jsKey}: '${v}'`;
131
- });
132
- attrs.push(` style={{ ${pairs.join(', ')} }}`);
133
- }
134
- return attrs.join('');
135
- }
136
- // ── Route path helper ────────────────────────────────────────────────────
137
- function routeToPath(route, segment) {
138
- // Normalize: strip leading/trailing slashes
139
- const normalized = route.replace(/^\/+|\/+$/g, '');
140
- const parts = normalized ? normalized.split('/') : [];
141
- if (segment)
142
- parts.push(segment);
143
- return parts.length > 0 ? parts.join('/') + '/' : '';
144
- }
145
- // ── Node renderers ──────────────────────────────────────────────────────
146
- function renderNode(node, ctx, indent) {
147
- const p = getProps(node);
148
- ctx.sourceMap.push({ irLine: node.loc?.line || 0, irCol: node.loc?.col || 1, outLine: ctx.lines.length + 1, outCol: 1 });
149
- switch (node.type) {
150
- case 'page':
151
- case 'screen':
152
- renderPage(node, ctx, indent);
153
- break;
154
- case 'layout':
155
- renderLayout(node, ctx, indent);
156
- break;
157
- case 'loading':
158
- renderLoading(node, ctx, indent);
159
- break;
160
- case 'error':
161
- renderError(node, ctx, indent);
162
- break;
163
- case 'metadata':
164
- renderMetadata(node, ctx);
165
- break;
166
- case 'section':
167
- renderSection(node, ctx, indent);
168
- break;
169
- case 'card':
170
- renderCard(node, ctx, indent);
171
- break;
172
- case 'row':
173
- ctx.lines.push(`${indent}<div${twClasses(node, ctx, 'flex')}>`);
174
- renderChildren(node, ctx, indent);
175
- ctx.lines.push(`${indent}</div>`);
176
- break;
177
- case 'col':
178
- ctx.lines.push(`${indent}<div${twClasses(node, ctx, 'flex flex-col')}>`);
179
- renderChildren(node, ctx, indent);
180
- ctx.lines.push(`${indent}</div>`);
181
- break;
182
- case 'text':
183
- renderText(node, ctx, indent);
184
- break;
185
- case 'divider':
186
- ctx.lines.push(`${indent}<div${twClasses(node, ctx, 'h-px')} />`);
187
- break;
188
- case 'button':
189
- renderButton(node, ctx, indent);
190
- break;
191
- case 'link':
192
- renderLink(node, ctx, indent);
193
- break;
194
- case 'image':
195
- renderImage(node, ctx, indent);
196
- break;
197
- case 'codeblock':
198
- renderCodeBlock(node, ctx, indent);
199
- break;
200
- case 'input':
201
- case 'textarea':
202
- renderInput(node, ctx, indent);
203
- break;
204
- case 'slider':
205
- renderSlider(node, ctx, indent);
206
- break;
207
- case 'toggle':
208
- renderToggle(node, ctx, indent);
209
- break;
210
- case 'grid':
211
- renderGrid(node, ctx, indent);
212
- break;
213
- case 'conditional':
214
- renderConditional(node, ctx, indent);
215
- break;
216
- case 'component':
217
- renderComponent(node, ctx, indent);
218
- break;
219
- case 'icon':
220
- ctx.componentImports.add('Icon');
221
- ctx.lines.push(`${indent}<Icon name="${p.name}" size="sm"${twClasses(node, ctx)} />`);
222
- break;
223
- case 'svg':
224
- renderSvg(node, ctx, indent);
225
- break;
226
- case 'form':
227
- ctx.lines.push(`${indent}<form${twClasses(node, ctx)}>`);
228
- renderChildren(node, ctx, indent);
229
- ctx.lines.push(`${indent}</form>`);
230
- break;
231
- case 'list':
232
- ctx.lines.push(`${indent}<div${twClasses(node, ctx, 'space-y-2')}>`);
233
- renderChildren(node, ctx, indent);
234
- ctx.lines.push(`${indent}</div>`);
235
- break;
236
- case 'item':
237
- ctx.lines.push(`${indent}<div${twClasses(node, ctx)}>`);
238
- renderChildren(node, ctx, indent);
239
- ctx.lines.push(`${indent}</div>`);
240
- break;
241
- case 'progress':
242
- renderProgress(node, ctx, indent);
243
- break;
244
- case 'tabs':
245
- ctx.lines.push(`${indent}<nav${twClasses(node, ctx, 'flex')}>`);
246
- renderChildren(node, ctx, indent);
247
- ctx.lines.push(`${indent}</nav>`);
248
- break;
249
- case 'tab':
250
- ctx.lines.push(`${indent}<button${twClasses(node, ctx)}>${escapeJsxText(String(p.label || ''))}</button>`);
251
- break;
252
- case 'table':
253
- ctx.lines.push(`${indent}<table${twClasses(node, ctx)}>`);
254
- renderChildren(node, ctx, indent);
255
- ctx.lines.push(`${indent}</table>`);
256
- break;
257
- case 'thead':
258
- ctx.lines.push(`${indent}<thead>`);
259
- renderChildren(node, ctx, indent);
260
- ctx.lines.push(`${indent}</thead>`);
261
- break;
262
- case 'tbody':
263
- ctx.lines.push(`${indent}<tbody>`);
264
- renderChildren(node, ctx, indent);
265
- ctx.lines.push(`${indent}</tbody>`);
266
- break;
267
- case 'tr':
268
- ctx.lines.push(`${indent}<tr${twClasses(node, ctx)}>`);
269
- renderChildren(node, ctx, indent);
270
- ctx.lines.push(`${indent}</tr>`);
271
- break;
272
- case 'th':
273
- renderTableCell(node, ctx, indent, 'th');
274
- break;
275
- case 'td':
276
- renderTableCell(node, ctx, indent, 'td');
277
- break;
278
- case 'generateMetadata':
279
- renderGenerateMetadata(node, ctx);
280
- break;
281
- case 'notFound':
282
- renderNotFound(node, ctx, indent);
283
- break;
284
- case 'redirect':
285
- renderRedirect(node, ctx, indent);
286
- break;
287
- case 'import':
288
- renderImport(node, ctx);
289
- break;
290
- case 'fetch':
291
- renderFetchNode(node, ctx);
292
- break;
293
- case 'on':
294
- renderOnHandler(node, ctx);
295
- return;
296
- case 'state':
297
- ctx.stateDecls.push({ name: String(p.name || ''), initial: String(p.initial ?? '') });
298
- ctx.isClient = true; // state requires 'use client'
299
- return;
300
- case 'logic':
301
- if (p.code)
302
- ctx.logicBlocks.push(String(p.code));
303
- else if (node.children) {
304
- const handlerChild = node.children.find(c => c.type === 'handler');
305
- if (handlerChild?.props?.code)
306
- ctx.logicBlocks.push(String(handlerChild.props.code));
307
- }
308
- ctx.isClient = true;
309
- return;
310
- case 'theme': break;
311
- default:
312
- ctx.lines.push(`${indent}<div${twClasses(node, ctx)}>`);
313
- renderChildren(node, ctx, indent);
314
- ctx.lines.push(`${indent}</div>`);
315
- }
316
- }
317
- function renderChildren(node, ctx, indent) {
318
- if (node.children)
319
- for (const child of node.children)
320
- renderNode(child, ctx, indent + ' ');
321
- }
322
- function renderPage(node, ctx, indent) {
323
- const p = getProps(node);
324
- if (p.client === 'true' || p.client === true)
325
- ctx.isClient = true;
326
- if (p.async === 'true' || p.async === true)
327
- ctx.isAsync = true;
328
- ctx.lines.push(`${indent}<div${twClasses(node, ctx)}>`);
329
- renderChildren(node, ctx, indent);
330
- ctx.lines.push(`${indent}</div>`);
331
- }
332
- function renderLayout(node, ctx, indent) {
333
- const p = getProps(node);
334
- ctx.lines.push(`${indent}<html lang="${p.lang || 'en'}">`);
335
- ctx.lines.push(`${indent} <body${twClasses(node, ctx)}>`);
336
- ctx.lines.push(`${indent} {children}`);
337
- renderChildren(node, ctx, indent + ' ');
338
- ctx.lines.push(`${indent} </body>`);
339
- ctx.lines.push(`${indent}</html>`);
340
- }
341
- function renderLoading(node, ctx, indent) {
342
- ctx.lines.push(`${indent}<div${twClasses(node, ctx, 'animate-pulse')}>`);
343
- renderChildren(node, ctx, indent);
344
- ctx.lines.push(`${indent}</div>`);
345
- }
346
- function renderError(node, ctx, indent) {
347
- ctx.isClient = true;
348
- ctx.lines.push(`${indent}<div${twClasses(node, ctx)}>`);
349
- ctx.lines.push(`${indent} <h2>Something went wrong!</h2>`);
350
- ctx.lines.push(`${indent} <button onClick={() => reset()}>Try again</button>`);
351
- renderChildren(node, ctx, indent);
352
- ctx.lines.push(`${indent}</div>`);
353
- }
354
- function renderMetadata(node, ctx) {
355
- const p = getProps(node);
356
- ctx.metadata = {};
357
- if (p.title)
358
- ctx.metadata.title = p.title;
359
- if (p.description)
360
- ctx.metadata.description = p.description;
361
- if (p.keywords)
362
- ctx.metadata.keywords = p.keywords;
363
- if (p.ogImage)
364
- ctx.metadata.ogImage = p.ogImage;
365
- }
366
- function renderSection(node, ctx, indent) {
367
- const p = getProps(node);
368
- const title = p.title || '';
369
- const id = p.id;
370
- const idAttr = id ? ` id="${id}"` : '';
371
- const tw = twClasses(node, ctx);
372
- ctx.lines.push(`${indent}<section${idAttr}${tw}>`);
373
- if (title) {
374
- ctx.lines.push(`${indent} <h2 className="text-lg font-semibold mb-4">${escapeJsxText(title)}</h2>`);
375
- }
376
- renderChildren(node, ctx, indent);
377
- ctx.lines.push(`${indent}</section>`);
378
- }
379
- function renderCodeBlock(node, ctx, indent) {
380
- const p = getProps(node);
381
- const lang = p.lang || '';
382
- const langClass = lang ? ` language-${lang}` : '';
383
- const hasCustomStyle = getStyles(node).className || getStyles(node).background;
384
- const preAttrs = hasCustomStyle ? twClasses(node, ctx) : ` className="bg-zinc-900 rounded-lg p-4 overflow-x-auto"`;
385
- const codeClass = hasCustomStyle
386
- ? `className="${langClass.trim()}"` + (getStyles(node).fontFamily ? ` style={{ fontFamily: '${getStyles(node).fontFamily}' }}` : '')
387
- : `className="text-sm font-mono text-zinc-100${langClass}"`;
388
- // Content: inline value prop or body child node
389
- const rawValue = p.value;
390
- if (isExpr(rawValue)) {
391
- ctx.lines.push(`${indent}<pre${preAttrs}>`);
392
- ctx.lines.push(`${indent} <code ${codeClass}>{${rawValue.code}}</code>`);
393
- ctx.lines.push(`${indent}</pre>`);
394
- return;
395
- }
396
- let content = rawValue || '';
397
- if (!content && node.children) {
398
- const bodyNode = node.children.find(c => c.type === 'body');
399
- if (bodyNode) {
400
- const bp = getProps(bodyNode);
401
- // body value="..." OR body <<<...>>> (multiline block → code prop)
402
- content = bp.code || bp.value || '';
403
- }
404
- }
405
- // Escape for JSX template literal: backslashes, backticks, ${
406
- const escaped = content
407
- .replace(/\\/g, '\\\\')
408
- .replace(/`/g, '\\`')
409
- .replace(/\$\{/g, '\\${');
410
- ctx.lines.push(`${indent}<pre${preAttrs}>`);
411
- ctx.lines.push(`${indent} <code ${codeClass}>{\`${escaped}\`}</code>`);
412
- ctx.lines.push(`${indent}</pre>`);
413
- }
414
- function renderCard(node, ctx, indent) {
415
- const styles = { ...getStyles(node) };
416
- const border = styles.border;
417
- delete styles.border;
418
- // Use a shallow-copied styles object to avoid mutating the live IR node
419
- if (node.props) {
420
- const origStyles = node.props.styles;
421
- node.props.styles = styles;
422
- const extra = border ? `border ${colorToTw('border', border, ctx.colors)}` : '';
423
- ctx.lines.push(`${indent}<div${twClasses(node, ctx, extra)}>`);
424
- renderChildren(node, ctx, indent);
425
- ctx.lines.push(`${indent}</div>`);
426
- node.props.styles = origStyles;
427
- }
428
- else {
429
- const extra = border ? `border ${colorToTw('border', border, ctx.colors)}` : '';
430
- ctx.lines.push(`${indent}<div${twClasses(node, ctx, extra)}>`);
431
- renderChildren(node, ctx, indent);
432
- ctx.lines.push(`${indent}</div>`);
433
- }
434
- }
435
- 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' };
436
- function renderText(node, ctx, indent) {
437
- const p = getProps(node);
438
- const rawValue = p.value;
439
- const bind = p.bind;
440
- const el = TEXT_TAG_MAP[p.tag] || 'span';
441
- const tw = twClasses(node, ctx);
442
- if (isExpr(rawValue))
443
- ctx.lines.push(`${indent}<${el}${tw}>{${rawValue.code}}</${el}>`);
444
- else if (bind)
445
- ctx.lines.push(`${indent}<${el}${tw}>{${bind}}</${el}>`);
446
- else if (rawValue)
447
- ctx.lines.push(`${indent}<${el}${tw}>${escapeJsxText(rawValue)}</${el}>`);
448
- }
449
- function renderButton(node, ctx, indent) {
450
- const p = getProps(node);
451
- const text = p.text || '';
452
- const to = p.to;
453
- const rawOnClick = p.onClick;
454
- const onClick = isExpr(rawOnClick) ? rawOnClick.code : rawOnClick;
455
- if (to) {
456
- addDefaultImport(ctx, 'next/link', 'Link');
457
- ctx.lines.push(`${indent}<Link href="/${to.toLowerCase()}"${twClasses(node, ctx)}>${escapeJsxText(text)}</Link>`);
458
- }
459
- else {
460
- ctx.isClient = true; // onClick requires 'use client'
461
- ctx.lines.push(`${indent}<button${twClasses(node, ctx)} onClick={${onClick || '() => {}'}}>${escapeJsxText(text)}</button>`);
462
- }
463
- }
464
- function renderInput(node, ctx, indent) {
465
- const p = getProps(node);
466
- const isTextarea = node.type === 'textarea' || p.type === 'textarea' || p.multiline;
467
- const tag = isTextarea ? 'textarea' : 'input';
468
- const attrs = [];
469
- const tw = twClasses(node, ctx);
470
- if (p.bind) {
471
- const bind = p.bind;
472
- const setter = `set${bind.charAt(0).toUpperCase() + bind.slice(1)}`;
473
- attrs.push(`value={${bind}}`);
474
- ctx.isClient = true; // onChange requires 'use client'
475
- if (isExpr(p.onChange))
476
- attrs.push(`onChange={${p.onChange.code}}`);
477
- else if (p.onChange)
478
- attrs.push(`onChange={${p.onChange}}`);
479
- else
480
- attrs.push(`onChange={(e) => ${setter}(e.target.value)}`);
481
- }
482
- if (p.placeholder)
483
- attrs.push(`placeholder="${p.placeholder}"`);
484
- if (!isTextarea && p.type && p.type !== 'textarea')
485
- attrs.push(`type="${p.type}"`);
486
- if (p.spellcheck === 'false' || p.spellcheck === false)
487
- attrs.push('spellCheck={false}');
488
- const attrStr = attrs.length > 0 ? ' ' + attrs.join(' ') : '';
489
- if (isTextarea) {
490
- ctx.lines.push(`${indent}<${tag}${tw}${attrStr} rows={4} />`);
491
- }
492
- else {
493
- ctx.lines.push(`${indent}<${tag}${tw}${attrStr} />`);
494
- }
495
- }
496
- function renderLink(node, ctx, indent) {
497
- const p = getProps(node);
498
- addDefaultImport(ctx, 'next/link', 'Link');
499
- ctx.lines.push(`${indent}<Link href="${p.to || '/'}"${twClasses(node, ctx)}>`);
500
- renderChildren(node, ctx, indent);
501
- ctx.lines.push(`${indent}</Link>`);
502
- }
503
- function renderImage(node, ctx, indent) {
504
- const p = getProps(node);
505
- addDefaultImport(ctx, 'next/image', 'Image');
506
- const tw = twClasses(node, ctx);
507
- const rawSrc = p.src || '';
508
- const src = (rawSrc.startsWith('/') || rawSrc.includes('://') || rawSrc.includes('.')) ? rawSrc : `/${rawSrc}.png`;
509
- const alt = escapeJsxAttr(String(p.alt || p.src || ''));
510
- const fill = p.fill === 'true' || p.fill === true;
511
- const priority = p.priority === 'true' || p.priority === true;
512
- if (fill) {
513
- ctx.lines.push(`${indent}<Image src="${src}" alt="${alt}"${priority ? ' priority' : ''} fill${tw} />`);
514
- }
515
- else {
516
- const width = p.width || (getStyles(node).w) || '100';
517
- const height = p.height || (getStyles(node).h) || '100';
518
- ctx.lines.push(`${indent}<Image src="${src}" alt="${alt}" width={${width}} height={${height}}${priority ? ' priority' : ''}${tw} />`);
519
- }
520
- }
521
- function renderSlider(node, ctx, indent) {
522
- const p = getProps(node);
523
- const bind = p.bind;
524
- const setter = bind ? `set${bind.charAt(0).toUpperCase() + bind.slice(1)}` : 'setValue';
525
- ctx.isClient = true; // onChange requires 'use client'
526
- 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" />`);
527
- }
528
- function renderToggle(node, ctx, indent) {
529
- const p = getProps(node);
530
- const bind = p.bind;
531
- const setter = bind ? `set${bind.charAt(0).toUpperCase() + bind.slice(1)}` : 'setValue';
532
- ctx.isClient = true; // onChange requires 'use client'
533
- ctx.lines.push(`${indent}<label className="relative inline-flex items-center cursor-pointer">`);
534
- ctx.lines.push(`${indent} <input type="checkbox" className="sr-only peer" checked={${bind || 'value'}} onChange={(e) => ${setter}(e.target.checked)} />`);
535
- 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" />`);
536
- ctx.lines.push(`${indent}</label>`);
537
- }
538
- function renderOnHandler(node, ctx) {
539
- const p = getProps(node);
540
- const event = (p.event || p.name);
541
- const handlerRef = p.handler;
542
- const key = p.key;
543
- const isAsync = p.async === 'true' || p.async === true;
544
- const handlerChild = (node.children || []).find(c => c.type === 'handler');
545
- const code = handlerChild ? (getProps(handlerChild).code || '') : '';
546
- if (handlerRef && !code)
547
- return;
548
- ctx.isClient = true; // event handlers require 'use client'
549
- const fnName = handlerRef || `handle${event.charAt(0).toUpperCase() + event.slice(1)}`;
550
- const asyncKw = isAsync ? 'async ' : '';
551
- const paramType = event === 'submit' ? 'e: React.FormEvent'
552
- : event === 'click' ? 'e: React.MouseEvent'
553
- : event === 'change' ? 'e: React.ChangeEvent'
554
- : event === 'key' || event === 'keydown' || event === 'keyup' ? 'e: React.KeyboardEvent'
555
- : event === 'focus' || event === 'blur' ? 'e: React.FocusEvent'
556
- : event === 'scroll' ? 'e: React.UIEvent'
557
- : `e: React.SyntheticEvent`;
558
- const keyGuard = key ? ` if (e.key !== '${key}') return;\n` : '';
559
- addNamedImport(ctx, 'react', 'useCallback');
560
- let block = ` const ${fnName} = useCallback(${asyncKw}(${paramType}) => {\n`;
561
- if (keyGuard)
562
- block += keyGuard;
563
- if (code) {
564
- for (const line of code.split('\n')) {
565
- block += ` ${line}\n`;
566
- }
567
- }
568
- block += ` }, []);\n`;
569
- ctx.bodyLines.push(block);
570
- if (event === 'key' || event === 'keydown' || event === 'keyup') {
571
- addNamedImport(ctx, 'react', 'useEffect');
572
- const domEvent = event === 'key' ? 'keydown' : event;
573
- let effect = ` useEffect(() => {\n`;
574
- effect += ` const listener = (e: KeyboardEvent) => ${fnName}(e as unknown as React.KeyboardEvent);\n`;
575
- effect += ` window.addEventListener('${domEvent}', listener);\n`;
576
- effect += ` return () => window.removeEventListener('${domEvent}', listener);\n`;
577
- effect += ` }, [${fnName}]);\n`;
578
- ctx.bodyLines.push(effect);
579
- }
580
- if (event === 'resize') {
581
- addNamedImport(ctx, 'react', 'useEffect');
582
- let effect = ` useEffect(() => {\n`;
583
- effect += ` window.addEventListener('resize', ${fnName});\n`;
584
- effect += ` return () => window.removeEventListener('resize', ${fnName});\n`;
585
- effect += ` }, [${fnName}]);\n`;
586
- ctx.bodyLines.push(effect);
587
- }
588
- }
589
- function renderTableCell(node, ctx, indent, tag) {
590
- const p = getProps(node);
591
- const tw = twClasses(node, ctx);
592
- const rawValue = p.value;
593
- if (isExpr(rawValue)) {
594
- ctx.lines.push(`${indent}<${tag}${tw}>{${rawValue.code}}</${tag}>`);
595
- }
596
- else if (rawValue) {
597
- ctx.lines.push(`${indent}<${tag}${tw}>${escapeJsxText(rawValue)}</${tag}>`);
598
- }
599
- else if (node.children && node.children.length > 0) {
600
- ctx.lines.push(`${indent}<${tag}${tw}>`);
601
- renderChildren(node, ctx, indent);
602
- ctx.lines.push(`${indent}</${tag}>`);
603
- }
604
- else {
605
- ctx.lines.push(`${indent}<${tag}${tw} />`);
606
- }
607
- }
608
- function renderGrid(node, ctx, indent) {
609
- const p = getProps(node);
610
- const cols = parseInt(String(p.cols || 1), 10) || 1;
611
- const gap = parseInt(String(p.gap || 16), 10) || 16;
612
- ctx.lines.push(`${indent}<div className="grid grid-cols-1 md:grid-cols-${cols} gap-${Math.round(gap / 4)}">`);
613
- renderChildren(node, ctx, indent);
614
- ctx.lines.push(`${indent}</div>`);
615
- }
616
- const SVG_ICONS = {
617
- home: '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
618
- plus: '<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>',
619
- chart: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
620
- search: '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
621
- settings: '<circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>',
622
- heart: '<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>',
623
- profile: '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
624
- check: '<polyline points="20 6 9 17 4 12"/>',
625
- x: '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
626
- arrow: '<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>',
627
- };
628
- /** Convert raw HTML-style SVG attributes to JSX-safe syntax. */
629
- function htmlAttrsToJsx(html) {
630
- // Convert unquoted numeric attrs: width=52 → width={52}
631
- // Convert unquoted string attrs: fill=#E63946 → fill="#E63946"
632
- return html.replace(/(\w+)=([^\s"'{/>]+)/g, (_match, key, val) => {
633
- if (/^[\d.]+$/.test(val))
634
- return `${key}={${val}}`;
635
- return `${key}="${val}"`;
636
- });
637
- }
638
- function renderSvg(node, ctx, indent) {
639
- const p = getProps(node);
640
- const icon = p.icon;
641
- const size = parseInt(String(p.size || 24), 10) || 24;
642
- if (icon) {
643
- const inner = SVG_ICONS[icon] || `<circle cx="12" cy="12" r="4"/>`;
644
- ctx.lines.push(`${indent}<svg xmlns="http://www.w3.org/2000/svg" width={${size}} height={${size}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"${twClasses(node, ctx)}>${inner}</svg>`);
645
- }
646
- else {
647
- // Custom SVG — only emit attributes the user explicitly set (no Feather defaults)
648
- const viewBox = p.viewBox || '0 0 24 24';
649
- const width = parseInt(String(p.width || size), 10) || size;
650
- const height = parseInt(String(p.height || size), 10) || size;
651
- const content = htmlAttrsToJsx(p.content || '');
652
- const optAttrs = [];
653
- if (p.fill)
654
- optAttrs.push(`fill="${p.fill}"`);
655
- if (p.stroke)
656
- optAttrs.push(`stroke="${p.stroke}"`);
657
- const extra = optAttrs.length ? ' ' + optAttrs.join(' ') : '';
658
- ctx.lines.push(`${indent}<svg xmlns="http://www.w3.org/2000/svg" width={${width}} height={${height}} viewBox="${viewBox}"${extra}${twClasses(node, ctx)}>${content}</svg>`);
659
- }
660
- }
661
- function renderConditional(node, ctx, indent) {
662
- const cond = (getProps(node).if || 'true').replace(/&/g, ' && ').replace(/([a-zA-Z_]+)=([a-zA-Z_]+)/g, "$1 === '$2'");
663
- ctx.lines.push(`${indent}{${cond} && (`);
664
- ctx.lines.push(`${indent} <>`);
665
- renderChildren(node, ctx, indent + ' ');
666
- ctx.lines.push(`${indent} </>`);
667
- ctx.lines.push(`${indent})}`);
668
- }
669
- function renderComponent(node, ctx, indent) {
670
- const p = getProps(node);
671
- const ref = (p.ref || p.name);
672
- if (!ref)
673
- return;
674
- ctx.componentImports.add(ref);
675
- const hasOnChange = 'onChange' in p;
676
- const attrs = [];
677
- for (const [k, v] of Object.entries(p)) {
678
- if (['ref', 'name', 'styles', 'pseudoStyles', 'themeRefs'].includes(k))
679
- continue;
680
- if (k === 'bind') {
681
- attrs.push(`value={${v}}`);
682
- if (!hasOnChange)
683
- attrs.push(`onChange={set${v.charAt(0).toUpperCase() + v.slice(1)}}`);
684
- }
685
- else if (k === 'onChange')
686
- attrs.push(`onChange={${v}}`);
687
- else if (k === 'props') {
688
- for (const pn of v.split(','))
689
- attrs.push(`${pn.trim()}={${pn.trim()}}`);
690
- }
691
- else if (k === 'disabled')
692
- attrs.push(`disabled={${v.replace(/&/g, ' && ').replace(/([a-zA-Z_]+)=([a-zA-Z_]+)/g, "$1 === '$2'")}}`);
693
- else if (k === 'default')
694
- attrs.push(`defaultValue={${JSON.stringify(v)}}`);
695
- else
696
- attrs.push(`${k}={${JSON.stringify(v)}}`);
697
- }
698
- const attrStr = attrs.length ? ' ' + attrs.join(' ') : '';
699
- if (node.children && node.children.length > 0) {
700
- ctx.lines.push(`${indent}<${ref}${attrStr}>`);
701
- renderChildren(node, ctx, indent);
702
- ctx.lines.push(`${indent}</${ref}>`);
703
- }
704
- else {
705
- ctx.lines.push(`${indent}<${ref}${attrStr} />`);
706
- }
707
- }
708
- function renderProgress(node, ctx, indent) {
709
- const p = getProps(node);
710
- const current = Number(p.current || 0), target = Number(p.target || 100);
711
- const pct = Math.round((current / target) * 100);
712
- ctx.lines.push(`${indent}<div className="mb-3">`);
713
- ctx.lines.push(`${indent} <div className="flex justify-between text-sm mb-1"><span>${escapeJsxText(String(p.label || ''))}</span><span>${current}/${target} ${escapeJsxText(String(p.unit || ''))}</span></div>`);
714
- ctx.lines.push(`${indent} <div className="h-2 bg-zinc-700 rounded-full overflow-hidden"><div className="h-full rounded-full bg-[${p.color || '#007AFF'}]" style={{ width: '${pct}%' }} /></div>`);
715
- ctx.lines.push(`${indent}</div>`);
716
- }
717
- // ── Next.js 15 production pattern renderers ─────────────────────────────
718
- function renderGenerateMetadata(node, ctx) {
719
- // Collect handler code from children
720
- let handlerCode = '';
721
- if (node.children) {
722
- for (const child of node.children) {
723
- const cp = getProps(child);
724
- if (child.type === 'handler' && cp.code) {
725
- handlerCode = cp.code;
726
- }
727
- }
728
- }
729
- // Also check inline code prop
730
- const p = getProps(node);
731
- if (p.code)
732
- handlerCode = p.code;
733
- ctx.generateMetadataInfo = { handlerCode };
734
- }
735
- function renderNotFound(node, ctx, _indent) {
736
- addNamedImport(ctx, 'next/navigation', 'notFound');
737
- const p = getProps(node);
738
- const condition = p.if;
739
- if (condition) {
740
- ctx.bodyLines.push(` if (${exprCode(condition, 'true')}) { notFound(); }`);
741
- }
742
- else {
743
- ctx.bodyLines.push(` notFound();`);
744
- }
745
- }
746
- function renderRedirect(node, ctx, _indent) {
747
- addNamedImport(ctx, 'next/navigation', 'redirect');
748
- const p = getProps(node);
749
- const to = p.to || '/';
750
- ctx.bodyLines.push(` redirect('${to}');`);
751
- }
752
- function renderImport(node, ctx) {
753
- const p = getProps(node);
754
- const name = p.name;
755
- const from = p.from;
756
- const isDefault = p.default === 'true' || p.default === true;
757
- if (name && from) {
758
- if (isDefault) {
759
- addDefaultImport(ctx, from, name);
760
- }
761
- else {
762
- // Support comma-separated named imports: name=Foo,Bar,Baz
763
- const names = name.split(',').map(n => n.trim()).filter(Boolean);
764
- for (const n of names) {
765
- addNamedImport(ctx, from, n);
766
- }
767
- }
768
- }
769
- }
770
- function renderFetchNode(node, ctx) {
771
- const p = getProps(node);
772
- const name = p.name || 'data';
773
- const url = p.url || '/api/data';
774
- const options = p.options;
775
- ctx.fetchCalls.push({ name, url, options });
776
- }
3
+ import { renderAndAssemble } from './nextjs-assembler.js';
4
+ import { routeToPath } from './nextjs-style.js';
5
+ import { planStructure } from './structure.js';
777
6
  // ── Main export ─────────────────────────────────────────────────────────
778
7
  export function transpileNextjs(root, config) {
779
8
  // Structured output path
@@ -783,212 +12,14 @@ export function transpileNextjs(root, config) {
783
12
  return _transpileNextjsStructured(root, config, plan);
784
13
  }
785
14
  }
786
- // Flat output path (default — unchanged)
15
+ // Flat output path (default)
787
16
  return _transpileNextjsInner(root, config);
788
17
  }
18
+ // ── Flat output ─────────────────────────────────────────────────────────
789
19
  function _transpileNextjsInner(root, config) {
790
- const ctx = {
791
- lines: [],
792
- sourceMap: [],
793
- imports: new Map(),
794
- componentImports: new Set(),
795
- isClient: false,
796
- isAsync: false,
797
- metadata: null,
798
- generateMetadataInfo: null,
799
- fetchCalls: [],
800
- bodyLines: [],
801
- stateDecls: [],
802
- logicBlocks: [],
803
- colors: config?.colors,
804
- twProfile: config?.frameworkVersions ? buildTailwindProfile(config.frameworkVersions) : undefined,
805
- njProfile: config?.frameworkVersions ? buildNextjsProfile(config.frameworkVersions) : undefined,
806
- };
807
- // Check root for client flag
808
20
  const rootProps = getProps(root);
809
- if (rootProps.client === 'true' || rootProps.client === true)
810
- ctx.isClient = true;
811
- if (rootProps.async === 'true' || rootProps.async === true)
812
- ctx.isAsync = true;
813
- // renderNode already handles metadata nodes in the switch case
814
- renderNode(root, ctx, ' ');
815
- // If there are fetch calls, mark page as async
816
- if (ctx.fetchCalls.length > 0)
817
- ctx.isAsync = true;
818
- // Client components cannot be async in Next.js — client wins, drop server-only patterns
819
- if (ctx.isClient && ctx.isAsync) {
820
- ctx.isAsync = false;
821
- ctx.fetchCalls = [];
822
- }
823
21
  const name = rootProps.name || 'Page';
824
- const isLayout = root.type === 'layout';
825
- const isLoading = root.type === 'loading';
826
- const isError = root.type === 'error';
827
- const code = [];
828
- // 'use client' directive
829
- if (ctx.isClient) {
830
- code.push(`'use client';`);
831
- code.push('');
832
- }
833
- // Metadata type import for generateMetadata
834
- if (ctx.generateMetadataInfo && !ctx.isClient) {
835
- addNamedImport(ctx, 'next', 'Metadata', true);
836
- }
837
- // Component imports → add to unified import map (skip if already imported explicitly)
838
- const uiLib = config?.components?.uiLibrary ?? '@/components/ui';
839
- const compRoot = config?.components?.componentRoot ?? '@/components';
840
- if (ctx.componentImports.size > 0) {
841
- // Collect names already imported via explicit import nodes
842
- const alreadyImported = new Set();
843
- for (const spec of ctx.imports.values()) {
844
- if (spec.defaultImport)
845
- alreadyImported.add(spec.defaultImport);
846
- for (const n of spec.namedImports)
847
- alreadyImported.add(n);
848
- }
849
- const uiImports = [...ctx.componentImports].filter(c => ['Icon', 'Button'].includes(c) && !alreadyImported.has(c));
850
- const others = [...ctx.componentImports].filter(c => !['Icon', 'Button'].includes(c) && !alreadyImported.has(c));
851
- for (const name of uiImports)
852
- addNamedImport(ctx, uiLib, name);
853
- for (const name of others)
854
- addDefaultImport(ctx, `${compRoot}/${name}`, name);
855
- }
856
- // Static metadata needs Metadata type
857
- if (ctx.metadata && !ctx.generateMetadataInfo) {
858
- addNamedImport(ctx, 'next', 'Metadata', true);
859
- }
860
- // State requires useState import
861
- if (ctx.stateDecls.length > 0) {
862
- addNamedImport(ctx, 'react', 'useState');
863
- }
864
- // Detect hook imports from logic blocks before emitting imports
865
- for (const block of ctx.logicBlocks) {
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
- }
875
- // Emit all imports (unified, sorted)
876
- code.push(...emitImports(ctx));
877
- if (code.length > 0 && code[code.length - 1] !== '')
878
- code.push('');
879
- // Metadata export — static metadata
880
- // Note: In Next.js, metadata must be in a server component. If the page is client-side,
881
- // emit it anyway (user may split into layout.tsx or remove client=true).
882
- if (ctx.metadata) {
883
- const useSatisfies = ctx.njProfile?.outputRules.metadataStyle === 'satisfies';
884
- code.push(useSatisfies ? `export const metadata = {` : `export const metadata: Metadata = {`);
885
- for (const [k, v] of Object.entries(ctx.metadata)) {
886
- code.push(` ${k}: '${escapeJsString(v)}',`);
887
- }
888
- code.push(useSatisfies ? `} satisfies Metadata;` : `};`);
889
- code.push('');
890
- }
891
- // generateMetadata export (server components only)
892
- if (ctx.generateMetadataInfo && !ctx.isClient) {
893
- const usePromiseParams = !ctx.njProfile || ctx.njProfile.major >= 15;
894
- const paramsType = usePromiseParams
895
- ? '{ params }: { params: Promise<Record<string, string>> }'
896
- : '{ params }: { params: Record<string, string> }';
897
- code.push('');
898
- code.push(`export async function generateMetadata(${paramsType}): Promise<Metadata> {`);
899
- if (ctx.generateMetadataInfo.handlerCode) {
900
- // Split handler code by newlines and emit each line as-is
901
- const lines = ctx.generateMetadataInfo.handlerCode.split('\n').map(s => s.trim()).filter(Boolean);
902
- for (const line of lines) {
903
- code.push(` ${line}`);
904
- }
905
- }
906
- else {
907
- if (usePromiseParams) {
908
- code.push(` const resolvedParams = await params;`);
909
- code.push(` return { title: resolvedParams.slug ?? '' };`);
910
- }
911
- else {
912
- code.push(` return { title: params.slug ?? '' };`);
913
- }
914
- }
915
- code.push(`}`);
916
- code.push('');
917
- }
918
- // Component
919
- if (isLayout) {
920
- code.push(`export default function ${name}({ children }: { children: React.ReactNode }) {`);
921
- }
922
- else if (isError) {
923
- code.push(`export default function ${name}({ error, reset }: { error: Error; reset: () => void }) {`);
924
- }
925
- else if (ctx.isAsync) {
926
- const usePromiseParams = !ctx.njProfile || ctx.njProfile.major >= 15;
927
- if (usePromiseParams) {
928
- code.push(`export default async function ${name}(props: { params: Promise<Record<string, string>> }) {`);
929
- code.push(` const params = await props.params;`);
930
- }
931
- else {
932
- code.push(`export default async function ${name}({ params }: { params: Record<string, string> }) {`);
933
- }
934
- }
935
- else {
936
- code.push(`export default function ${name}() {`);
937
- }
938
- // Emit fetch calls (inside async function body, before return)
939
- for (const fc of ctx.fetchCalls) {
940
- if (fc.options) {
941
- code.push(` const ${fc.name} = await fetch('${fc.url}', ${fc.options}).then(r => r.json());`);
942
- }
943
- else {
944
- code.push(` const ${fc.name} = await fetch('${fc.url}').then(r => r.json());`);
945
- }
946
- }
947
- // Emit useState declarations
948
- for (const s of ctx.stateDecls) {
949
- const setter = `set${s.name.charAt(0).toUpperCase() + s.name.slice(1)}`;
950
- const stateNode = root.children?.find(c => c.type === 'state' && c.props?.name === s.name);
951
- const initProp = stateNode?.props?.initial;
952
- const isExprInit = typeof initProp === 'object' && initProp !== null && '__expr' in initProp;
953
- let initVal;
954
- if (isExprInit) {
955
- initVal = initProp.code;
956
- }
957
- else if (s.initial === 'true' || s.initial === 'false') {
958
- initVal = s.initial;
959
- }
960
- else if (s.initial === '' || s.initial === "''") {
961
- initVal = "''";
962
- }
963
- else if (!isNaN(Number(s.initial)) && s.initial !== '') {
964
- initVal = s.initial;
965
- }
966
- else {
967
- initVal = `'${s.initial}'`;
968
- }
969
- code.push(` const [${s.name}, ${setter}] = useState(${initVal});`);
970
- }
971
- // Emit logic blocks & detect hook imports
972
- for (const block of ctx.logicBlocks) {
973
- code.push(` ${block}`);
974
- if (block.includes('useEffect'))
975
- addNamedImport(ctx, 'react', 'useEffect');
976
- if (block.includes('useCallback'))
977
- addNamedImport(ctx, 'react', 'useCallback');
978
- if (block.includes('useMemo'))
979
- addNamedImport(ctx, 'react', 'useMemo');
980
- if (block.includes('useRef'))
981
- addNamedImport(ctx, 'react', 'useRef');
982
- }
983
- // Emit body lines (notFound, redirect calls)
984
- for (const line of ctx.bodyLines) {
985
- code.push(line);
986
- }
987
- code.push(' return (');
988
- code.push(...ctx.lines);
989
- code.push(' );');
990
- code.push('}');
991
- const output = code.join('\n');
22
+ const { ctx, code: output } = renderAndAssemble(root, config, name, (stateName) => root.children?.find((c) => c.type === 'state' && c.props?.name === stateName));
992
23
  const irText = serializeIR(root);
993
24
  const irTokenCount = countTokens(irText);
994
25
  const tsTokenCount = countTokens(output);
@@ -996,7 +27,10 @@ function _transpileNextjsInner(root, config) {
996
27
  // Determine output filename convention (route-aware)
997
28
  const route = rootProps.route;
998
29
  const segment = rootProps.segment;
999
- const routePrefix = route ? routeToPath(route, segment) : (segment ? routeToPath('', segment) : '');
30
+ const routePrefix = route ? routeToPath(route, segment) : segment ? routeToPath('', segment) : '';
31
+ const isLayout = root.type === 'layout';
32
+ const isLoading = root.type === 'loading';
33
+ const isError = root.type === 'error';
1000
34
  const files = [];
1001
35
  if (isLayout)
1002
36
  files.push({ path: `${routePrefix}layout.tsx`, content: output });
@@ -1011,7 +45,7 @@ function _transpileNextjsInner(root, config) {
1011
45
  const CONSUMED = new Set(['state', 'logic', 'on', 'theme', 'handler']);
1012
46
  for (const child of root.children || []) {
1013
47
  if (CONSUMED.has(child.type))
1014
- accountNode(accounted, child, 'consumed', child.type + ' pre-pass', true);
48
+ accountNode(accounted, child, 'consumed', `${child.type} pre-pass`, true);
1015
49
  }
1016
50
  return {
1017
51
  code: output,
@@ -1025,200 +59,13 @@ function _transpileNextjsInner(root, config) {
1025
59
  }
1026
60
  // ── Structured output ────────────────────────────────────────────────────
1027
61
  function _renderNextjsFile(file, config) {
1028
- const ctx = {
1029
- lines: [],
1030
- sourceMap: [],
1031
- imports: new Map(),
1032
- componentImports: new Set(),
1033
- isClient: false,
1034
- isAsync: false,
1035
- metadata: null,
1036
- generateMetadataInfo: null,
1037
- fetchCalls: [],
1038
- bodyLines: [],
1039
- stateDecls: [],
1040
- logicBlocks: [],
1041
- colors: config.colors,
1042
- twProfile: config.frameworkVersions ? buildTailwindProfile(config.frameworkVersions) : undefined,
1043
- njProfile: config.frameworkVersions ? buildNextjsProfile(config.frameworkVersions) : undefined,
1044
- };
1045
62
  const rootNode = file.rootNode;
1046
- const rootProps = rootNode.props || {};
1047
- // Check client flag
1048
- if (rootProps.client === 'true' || rootProps.client === true)
1049
- ctx.isClient = true;
1050
- if (rootProps.async === 'true' || rootProps.async === true)
1051
- ctx.isAsync = true;
1052
- // renderNode already handles metadata nodes in the switch case
1053
- renderNode(rootNode, ctx, ' ');
1054
- const name = file.componentName || rootProps.name || 'Component';
1055
- const isLayout = rootNode.type === 'layout';
1056
- const isError = rootNode.type === 'error';
1057
- // Client components cannot be async in Next.js — client wins, drop server-only patterns
1058
- if (ctx.isClient && ctx.isAsync) {
1059
- ctx.isAsync = false;
1060
- ctx.fetchCalls = [];
1061
- }
1062
- const code = [];
1063
- if (ctx.isClient) {
1064
- code.push(`'use client';`);
1065
- code.push('');
1066
- }
1067
- const uiLib = config.components?.uiLibrary ?? '@/components/ui';
1068
- const compRoot = config.components?.componentRoot ?? '@/components';
1069
- if (ctx.componentImports.size > 0) {
1070
- const alreadyImported = new Set();
1071
- for (const spec of ctx.imports.values()) {
1072
- if (spec.defaultImport)
1073
- alreadyImported.add(spec.defaultImport);
1074
- for (const n of spec.namedImports)
1075
- alreadyImported.add(n);
1076
- }
1077
- const uiImports = [...ctx.componentImports].filter(c => ['Icon', 'Button'].includes(c) && !alreadyImported.has(c));
1078
- const others = [...ctx.componentImports].filter(c => !['Icon', 'Button'].includes(c) && !alreadyImported.has(c));
1079
- for (const name of uiImports)
1080
- addNamedImport(ctx, uiLib, name);
1081
- for (const name of others)
1082
- addDefaultImport(ctx, `${compRoot}/${name}`, name);
1083
- }
1084
- // Metadata type import for generateMetadata
1085
- if (ctx.generateMetadataInfo && !ctx.isClient) {
1086
- addNamedImport(ctx, 'next', 'Metadata', true);
1087
- }
1088
- if (ctx.metadata && !ctx.generateMetadataInfo) {
1089
- addNamedImport(ctx, 'next', 'Metadata', true);
1090
- }
1091
- // If there are fetch calls, mark page as async
1092
- if (ctx.fetchCalls.length > 0)
1093
- ctx.isAsync = true;
1094
- // State requires useState import
1095
- if (ctx.stateDecls.length > 0) {
1096
- addNamedImport(ctx, 'react', 'useState');
1097
- }
1098
- // Detect hook imports from logic blocks before emitting imports
1099
- for (const block of ctx.logicBlocks) {
1100
- if (block.includes('useEffect'))
1101
- addNamedImport(ctx, 'react', 'useEffect');
1102
- if (block.includes('useCallback'))
1103
- addNamedImport(ctx, 'react', 'useCallback');
1104
- if (block.includes('useMemo'))
1105
- addNamedImport(ctx, 'react', 'useMemo');
1106
- if (block.includes('useRef'))
1107
- addNamedImport(ctx, 'react', 'useRef');
1108
- }
1109
- code.push(...emitImports(ctx));
1110
- if (code.length > 0 && code[code.length - 1] !== '')
1111
- code.push('');
1112
- // Metadata — emit even for client components (user may split into layout.tsx)
1113
- if (ctx.metadata) {
1114
- const useSatisfies = ctx.njProfile?.outputRules.metadataStyle === 'satisfies';
1115
- code.push(useSatisfies ? `export const metadata = {` : `export const metadata: Metadata = {`);
1116
- for (const [k, v] of Object.entries(ctx.metadata)) {
1117
- code.push(` ${k}: '${escapeJsString(v)}',`);
1118
- }
1119
- code.push(useSatisfies ? `} satisfies Metadata;` : `};`);
1120
- code.push('');
1121
- }
1122
- // generateMetadata export (server components only)
1123
- if (ctx.generateMetadataInfo && !ctx.isClient) {
1124
- const usePromiseParams = !ctx.njProfile || ctx.njProfile.major >= 15;
1125
- const paramsType = usePromiseParams
1126
- ? '{ params }: { params: Promise<Record<string, string>> }'
1127
- : '{ params }: { params: Record<string, string> }';
1128
- code.push('');
1129
- code.push(`export async function generateMetadata(${paramsType}): Promise<Metadata> {`);
1130
- if (ctx.generateMetadataInfo.handlerCode) {
1131
- const lines = ctx.generateMetadataInfo.handlerCode.split('\n').map(s => s.trim()).filter(Boolean);
1132
- for (const line of lines) {
1133
- code.push(` ${line}`);
1134
- }
1135
- }
1136
- else {
1137
- if (usePromiseParams) {
1138
- code.push(` const resolvedParams = await params;`);
1139
- code.push(` return { title: resolvedParams.slug ?? '' };`);
1140
- }
1141
- else {
1142
- code.push(` return { title: params.slug ?? '' };`);
1143
- }
1144
- }
1145
- code.push(`}`);
1146
- code.push('');
1147
- }
1148
- if (isLayout) {
1149
- code.push(`export default function ${name}({ children }: { children: React.ReactNode }) {`);
1150
- }
1151
- else if (isError) {
1152
- code.push(`export default function ${name}({ error, reset }: { error: Error; reset: () => void }) {`);
1153
- }
1154
- else if (ctx.isAsync) {
1155
- const usePromiseParams = !ctx.njProfile || ctx.njProfile.major >= 15;
1156
- if (usePromiseParams) {
1157
- code.push(`export default async function ${name}(props: { params: Promise<Record<string, string>> }) {`);
1158
- code.push(` const params = await props.params;`);
1159
- }
1160
- else {
1161
- code.push(`export default async function ${name}({ params }: { params: Record<string, string> }) {`);
1162
- }
1163
- }
1164
- else {
1165
- code.push(`export default function ${name}() {`);
1166
- }
1167
- // Emit useState declarations
1168
- for (const s of ctx.stateDecls) {
1169
- const setter = `set${s.name.charAt(0).toUpperCase() + s.name.slice(1)}`;
1170
- const rootNode = file.nodes[0];
1171
- const stateNode = rootNode?.children?.find((c) => c.type === 'state' && c.props?.name === s.name);
1172
- const initProp = stateNode?.props?.initial;
1173
- const isExpr = typeof initProp === 'object' && initProp !== null && '__expr' in initProp;
1174
- let initVal;
1175
- if (isExpr) {
1176
- initVal = initProp.code;
1177
- }
1178
- else if (s.initial === 'true' || s.initial === 'false') {
1179
- initVal = s.initial;
1180
- }
1181
- else if (s.initial === '' || s.initial === "''") {
1182
- initVal = "''";
1183
- }
1184
- else if (!isNaN(Number(s.initial)) && s.initial !== '') {
1185
- initVal = s.initial;
1186
- }
1187
- else {
1188
- initVal = `'${s.initial}'`;
1189
- }
1190
- code.push(` const [${s.name}, ${setter}] = useState(${initVal});`);
1191
- }
1192
- // Emit logic blocks & detect hook imports
1193
- for (const block of ctx.logicBlocks) {
1194
- code.push(` ${block}`);
1195
- if (block.includes('useEffect'))
1196
- addNamedImport(ctx, 'react', 'useEffect');
1197
- if (block.includes('useCallback'))
1198
- addNamedImport(ctx, 'react', 'useCallback');
1199
- if (block.includes('useMemo'))
1200
- addNamedImport(ctx, 'react', 'useMemo');
1201
- if (block.includes('useRef'))
1202
- addNamedImport(ctx, 'react', 'useRef');
1203
- }
1204
- // Emit fetch calls (inside async function body, before return)
1205
- for (const fc of ctx.fetchCalls) {
1206
- if (fc.options) {
1207
- code.push(` const ${fc.name} = await fetch('${fc.url}', ${fc.options}).then(r => r.json());`);
1208
- }
1209
- else {
1210
- code.push(` const ${fc.name} = await fetch('${fc.url}').then(r => r.json());`);
1211
- }
1212
- }
1213
- // Emit body lines (notFound, redirect calls)
1214
- for (const line of ctx.bodyLines) {
1215
- code.push(line);
1216
- }
1217
- code.push(' return (');
1218
- code.push(...ctx.lines);
1219
- code.push(' );');
1220
- code.push('}');
1221
- return code.join('\n');
63
+ const name = file.componentName || rootNode.props?.name || 'Component';
64
+ const { code } = renderAndAssemble(rootNode, config, name, (stateName) => {
65
+ const firstNode = file.nodes[0];
66
+ return firstNode?.children?.find((c) => c.type === 'state' && c.props?.name === stateName);
67
+ });
68
+ return code;
1222
69
  }
1223
70
  function _transpileNextjsStructured(root, config, plan) {
1224
71
  const { entryCode, artifacts } = buildStructuredArtifacts(plan, (file, cfg) => _renderNextjsFile(file, cfg), root, config);
@@ -1228,8 +75,8 @@ function _transpileNextjsStructured(root, config, plan) {
1228
75
  const tokenReduction = tsTokenCount > 0 ? Math.round((1 - irTokenCount / tsTokenCount) * 100) : 0;
1229
76
  // Convert artifacts to NextFile[] for files property
1230
77
  const files = artifacts
1231
- .filter(a => a.path.endsWith('.tsx'))
1232
- .map(a => ({ path: a.path, content: a.content }));
78
+ .filter((a) => a.path.endsWith('.tsx'))
79
+ .map((a) => ({ path: a.path, content: a.content }));
1233
80
  return {
1234
81
  code: entryCode,
1235
82
  sourceMap: [],