imxc 0.5.3 → 0.6.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.
package/dist/compile.d.ts CHANGED
@@ -3,6 +3,7 @@ export interface CompileResult {
3
3
  success: boolean;
4
4
  componentCount: number;
5
5
  errors: string[];
6
+ warnings: string[];
6
7
  }
7
8
  /**
8
9
  * Compile a list of .tsx files and write generated C++ to outputDir.
@@ -10,3 +11,10 @@ export interface CompileResult {
10
11
  */
11
12
  export declare function compile(files: string[], outputDir: string): CompileResult;
12
13
  export declare function resolveDragDropTypes(nodes: IRNode[]): void;
14
+ export interface FontDeclaration {
15
+ name: string;
16
+ src: string;
17
+ size: string;
18
+ embed: boolean;
19
+ embedKey?: string;
20
+ }
package/dist/compile.js CHANGED
@@ -14,7 +14,9 @@ export function compile(files, outputDir) {
14
14
  fs.mkdirSync(outputDir, { recursive: true });
15
15
  let hasErrors = false;
16
16
  const errorMessages = [];
17
+ const warningMessages = [];
17
18
  const compiled = [];
19
+ const allExternalInterfaces = new Map();
18
20
  // Phase 1: Parse, validate, and lower all components
19
21
  for (const file of files) {
20
22
  if (!fs.existsSync(file)) {
@@ -31,6 +33,9 @@ export function compile(files, outputDir) {
31
33
  continue;
32
34
  }
33
35
  const validation = validate(parsed);
36
+ if (validation.warnings.length > 0) {
37
+ validation.warnings.forEach(w => warningMessages.push(formatDiagnostic(w, source)));
38
+ }
34
39
  if (validation.errors.length > 0) {
35
40
  validation.errors.forEach(e => errorMessages.push(formatDiagnostic(e, source)));
36
41
  hasErrors = true;
@@ -38,6 +43,8 @@ export function compile(files, outputDir) {
38
43
  }
39
44
  // Load external interface definitions from imx.d.ts in the same directory
40
45
  const externalInterfaces = loadExternalInterfaces(path.dirname(path.resolve(file)));
46
+ for (const [k, v] of externalInterfaces)
47
+ allExternalInterfaces.set(k, v);
41
48
  const ir = lowerComponent(parsed, validation, externalInterfaces);
42
49
  const imports = extractImports(parsed.sourceFile);
43
50
  compiled.push({
@@ -53,7 +60,7 @@ export function compile(files, outputDir) {
53
60
  });
54
61
  }
55
62
  if (hasErrors) {
56
- return { success: false, componentCount: 0, errors: errorMessages };
63
+ return { success: false, componentCount: 0, errors: errorMessages, warnings: warningMessages };
57
64
  }
58
65
  // Phase 2: Build lookup of compiled components for cross-file resolution
59
66
  const componentMap = new Map();
@@ -64,25 +71,64 @@ export function compile(files, outputDir) {
64
71
  for (const comp of compiled) {
65
72
  resolveCustomComponents(comp.ir.body, componentMap);
66
73
  resolveDragDropTypes(comp.ir.body);
67
- comp.boundProps = detectBoundProps(comp.ir.body);
74
+ // Only detect bound props for custom components (inline props).
75
+ // Root components with namedPropsType receive T& directly — no pointer wrapping needed.
76
+ if (!comp.ir.namedPropsType) {
77
+ comp.boundProps = detectBoundProps(comp.ir.body);
78
+ }
79
+ }
80
+ // Propagate bound props through component call chains:
81
+ // If component X passes props.foo to a bound prop of child component Y,
82
+ // then foo is also bound in X (needs to be a pointer too).
83
+ let changed = true;
84
+ while (changed) {
85
+ changed = false;
86
+ for (const comp of compiled) {
87
+ if (comp.ir.namedPropsType)
88
+ continue;
89
+ const before = comp.boundProps.size;
90
+ propagateBoundProps(comp.ir.body, comp.boundProps, componentMap);
91
+ if (comp.boundProps.size > before)
92
+ changed = true;
93
+ }
68
94
  }
69
95
  // Build boundProps map for cross-component emitter use
70
96
  const boundPropsMap = new Map();
71
97
  for (const comp of compiled) {
72
98
  boundPropsMap.set(comp.name, comp.boundProps);
73
99
  }
100
+ const sharedPropsType = compiled.find(c => c.ir.namedPropsType)?.ir.namedPropsType;
101
+ // Resolve actual C++ types for bound props by tracing through parent interfaces.
102
+ // When a child declares `speed: number` (→ 'int'), but the parent struct has `float speed`,
103
+ // the bound prop pointer must use the parent's actual type.
104
+ const resolvedBoundPropTypes = new Map();
105
+ for (const comp of compiled) {
106
+ // Build field types for this component (either from external interface or inline params)
107
+ let fieldTypes;
108
+ if (comp.ir.namedPropsType) {
109
+ fieldTypes = allExternalInterfaces.get(comp.ir.namedPropsType);
110
+ }
111
+ else if (comp.ir.params.length > 0) {
112
+ fieldTypes = new Map();
113
+ for (const p of comp.ir.params)
114
+ fieldTypes.set(p.name, p.type);
115
+ }
116
+ if (fieldTypes) {
117
+ resolveChildBoundTypes(comp.ir.body, fieldTypes, componentMap, allExternalInterfaces, resolvedBoundPropTypes);
118
+ }
119
+ }
74
120
  for (const comp of compiled) {
75
121
  const importInfos = [];
76
122
  for (const [importedName] of comp.imports) {
77
123
  const importedComp = componentMap.get(importedName);
78
- if (importedComp && importedComp.hasProps) {
124
+ if (importedComp) {
79
125
  importInfos.push({
80
126
  name: importedName,
81
127
  headerFile: `${importedName}.gen.h`,
82
128
  });
83
129
  }
84
130
  }
85
- const cppOutput = emitComponent(comp.ir, importInfos, comp.sourceFile, comp.boundProps, boundPropsMap);
131
+ const cppOutput = emitComponent(comp.ir, importInfos, comp.sourceFile, comp.boundProps, boundPropsMap, { sourceMap: true });
86
132
  const baseName = comp.name;
87
133
  const outPath = path.join(outputDir, `${baseName}.gen.cpp`);
88
134
  fs.writeFileSync(outPath, cppOutput);
@@ -93,15 +139,28 @@ export function compile(files, outputDir) {
93
139
  const sourceDir = path.dirname(path.resolve(comp.sourcePath));
94
140
  generateEmbedHeaders(embedImages, sourceDir, outputDir);
95
141
  }
96
- // Only generate a header for inline props (not for named interface types —
97
- // those are declared in the user's main.cpp)
98
- if (comp.hasProps && !comp.ir.namedPropsType) {
99
- const headerOutput = emitComponentHeader(comp.ir, comp.sourceFile, comp.boundProps);
142
+ // Generate a header for non-root components (not for named interface types —
143
+ // those are declared in the user's main.cpp). Even propless components need
144
+ // a forward declaration so the root can call their render function.
145
+ if (!comp.ir.namedPropsType && (comp.hasProps || comp !== compiled[0])) {
146
+ const resolved = resolvedBoundPropTypes.get(comp.name);
147
+ const headerOutput = emitComponentHeader(comp.ir, comp.sourceFile, comp.boundProps, sharedPropsType, resolved);
100
148
  const headerPath = path.join(outputDir, `${baseName}.gen.h`);
101
149
  fs.writeFileSync(headerPath, headerOutput);
102
150
  console.log(` ${baseName} -> ${headerPath} (header)`);
103
151
  }
104
152
  }
153
+ // Collect font declarations from ALL components
154
+ const allFontDeclarations = [];
155
+ for (const comp of compiled) {
156
+ allFontDeclarations.push(...collectFontDeclarations(comp.ir.body));
157
+ }
158
+ const fontDeclarations = deduplicateFonts(allFontDeclarations);
159
+ // Generate embed headers for fonts
160
+ if (fontDeclarations.some(f => f.embed)) {
161
+ const sourceDir = path.dirname(path.resolve(compiled[0].sourcePath));
162
+ generateFontEmbedHeaders(fontDeclarations, sourceDir, outputDir);
163
+ }
105
164
  // Phase 4: Emit root entry point
106
165
  if (compiled.length > 0) {
107
166
  const root = compiled[0];
@@ -110,12 +169,12 @@ export function compile(files, outputDir) {
110
169
  const propsType = root.ir.namedPropsType
111
170
  ? root.ir.namedPropsType
112
171
  : root.hasProps ? root.name + 'Props' : undefined;
113
- const rootOutput = emitRoot(root.name, root.stateCount, root.bufferCount, root.sourceFile, propsType, isNamedPropsType);
172
+ const rootOutput = emitRoot(root.name, root.stateCount, root.bufferCount, root.sourceFile, propsType, isNamedPropsType, fontDeclarations);
114
173
  const rootPath = path.join(outputDir, 'app_root.gen.cpp');
115
174
  fs.writeFileSync(rootPath, rootOutput);
116
175
  console.log(` -> ${rootPath} (root entry point)`);
117
176
  }
118
- return { success: true, componentCount: compiled.length, errors: [] };
177
+ return { success: true, componentCount: compiled.length, errors: [], warnings: warningMessages };
119
178
  }
120
179
  function resolveCustomComponents(nodes, map) {
121
180
  for (const node of nodes) {
@@ -206,8 +265,15 @@ function generateEmbedHeaders(images, sourceDir, outputDir) {
206
265
  if (!img.embedKey)
207
266
  continue;
208
267
  const rawSrc = img.src.replace(/^"|"$/g, '');
209
- const imagePath = path.resolve(sourceDir, rawSrc);
268
+ let imagePath = path.resolve(sourceDir, rawSrc);
210
269
  const headerPath = path.join(outputDir, `${img.embedKey}.embed.h`);
270
+ // Try public/ subdirectory as fallback (relative to sourceDir's parent)
271
+ if (!fs.existsSync(imagePath)) {
272
+ const publicPath = path.resolve(sourceDir, '..', 'public', rawSrc);
273
+ if (fs.existsSync(publicPath)) {
274
+ imagePath = publicPath;
275
+ }
276
+ }
211
277
  // Mtime caching: skip if header exists and is newer than image
212
278
  if (fs.existsSync(headerPath) && fs.existsSync(imagePath)) {
213
279
  const imgStat = fs.statSync(imagePath);
@@ -217,7 +283,7 @@ function generateEmbedHeaders(images, sourceDir, outputDir) {
217
283
  }
218
284
  }
219
285
  if (!fs.existsSync(imagePath)) {
220
- console.warn(` warning: embedded image not found: ${imagePath}`);
286
+ console.warn(` warning: embedded image not found: ${imagePath} (also tried public/)`);
221
287
  continue;
222
288
  }
223
289
  const imageData = fs.readFileSync(imagePath);
@@ -235,11 +301,113 @@ function generateEmbedHeaders(images, sourceDir, outputDir) {
235
301
  console.log(` ${rawSrc} -> ${headerPath} (embed)`);
236
302
  }
237
303
  }
304
+ function collectFontDeclarations(nodes) {
305
+ const fonts = [];
306
+ for (const node of nodes) {
307
+ if (node.kind === 'begin_container' && node.tag === 'Font' && node.props['src']) {
308
+ const name = (node.props['name'] ?? '').replace(/^"|"$/g, '');
309
+ const src = (node.props['src'] ?? '').replace(/^"|"$/g, '');
310
+ const size = node.props['size'] ?? '16.0f';
311
+ const embed = node.props['embed'] === 'true';
312
+ let embedKey;
313
+ if (embed) {
314
+ embedKey = src.replace(/[^a-zA-Z0-9]/g, '_');
315
+ }
316
+ fonts.push({ name, src, size, embed, embedKey });
317
+ }
318
+ else if (node.kind === 'conditional') {
319
+ fonts.push(...collectFontDeclarations(node.body));
320
+ if (node.elseBody)
321
+ fonts.push(...collectFontDeclarations(node.elseBody));
322
+ }
323
+ else if (node.kind === 'list_map') {
324
+ fonts.push(...collectFontDeclarations(node.body));
325
+ }
326
+ }
327
+ return fonts;
328
+ }
329
+ function deduplicateFonts(fonts) {
330
+ const seen = new Map();
331
+ for (const f of fonts) {
332
+ if (seen.has(f.name)) {
333
+ const existing = seen.get(f.name);
334
+ if (existing.src !== f.src) {
335
+ console.error(` error: font "${f.name}" declared with different sources: "${existing.src}" vs "${f.src}"`);
336
+ }
337
+ continue;
338
+ }
339
+ seen.set(f.name, f);
340
+ }
341
+ return Array.from(seen.values());
342
+ }
343
+ function generateFontEmbedHeaders(fonts, sourceDir, outputDir) {
344
+ for (const font of fonts) {
345
+ if (!font.embed || !font.embedKey)
346
+ continue;
347
+ let fontPath = path.resolve(sourceDir, font.src);
348
+ const headerPath = path.join(outputDir, `${font.embedKey}.embed.h`);
349
+ // Mtime caching: skip if header exists and is newer than font file
350
+ if (fs.existsSync(headerPath) && fs.existsSync(fontPath)) {
351
+ const fontStat = fs.statSync(fontPath);
352
+ const hdrStat = fs.statSync(headerPath);
353
+ if (hdrStat.mtimeMs >= fontStat.mtimeMs) {
354
+ continue;
355
+ }
356
+ }
357
+ if (!fs.existsSync(fontPath)) {
358
+ // Try public/ subdirectory (relative to sourceDir's parent, for src/ layout)
359
+ const publicPath = path.resolve(sourceDir, '..', 'public', font.src);
360
+ if (fs.existsSync(publicPath)) {
361
+ fontPath = publicPath;
362
+ }
363
+ else {
364
+ console.warn(` warning: embedded font not found: ${fontPath} (also tried public/)`);
365
+ continue;
366
+ }
367
+ }
368
+ const fontData = fs.readFileSync(fontPath);
369
+ const bytes = Array.from(fontData)
370
+ .map(b => `0x${b.toString(16).padStart(2, '0')}`)
371
+ .join(', ');
372
+ const header = [
373
+ `// Generated from ${font.src} by imxc`,
374
+ `#pragma once`,
375
+ `static const unsigned char ${font.embedKey}_data[] = { ${bytes} };`,
376
+ `static const unsigned int ${font.embedKey}_size = ${fontData.length};`,
377
+ '',
378
+ ].join('\n');
379
+ fs.writeFileSync(headerPath, header);
380
+ console.log(` ${font.src} -> ${headerPath} (font embed)`);
381
+ }
382
+ }
238
383
  function detectBoundProps(nodes) {
239
384
  const bound = new Set();
240
385
  walkNodesForBinding(nodes, bound);
241
386
  return bound;
242
387
  }
388
+ function propagateBoundProps(nodes, bound, componentMap) {
389
+ for (const node of nodes) {
390
+ if (node.kind === 'custom_component') {
391
+ const child = componentMap.get(node.name);
392
+ if (child) {
393
+ for (const [propName, valueExpr] of Object.entries(node.props)) {
394
+ if (child.boundProps.has(propName) && valueExpr.startsWith('props.')) {
395
+ const parentProp = valueExpr.slice(6).split('.')[0].split('[')[0];
396
+ bound.add(parentProp);
397
+ }
398
+ }
399
+ }
400
+ }
401
+ else if (node.kind === 'conditional') {
402
+ propagateBoundProps(node.body, bound, componentMap);
403
+ if (node.elseBody)
404
+ propagateBoundProps(node.elseBody, bound, componentMap);
405
+ }
406
+ else if (node.kind === 'list_map') {
407
+ propagateBoundProps(node.body, bound, componentMap);
408
+ }
409
+ }
410
+ }
243
411
  function walkNodesForBinding(nodes, bound) {
244
412
  for (const node of nodes) {
245
413
  if ('directBind' in node && node.directBind && 'valueExpr' in node) {
@@ -249,6 +417,10 @@ function walkNodesForBinding(nodes, bound) {
249
417
  bound.add(propName);
250
418
  }
251
419
  }
420
+ // Scan all expressions for nested struct field access (props.X.Y or props.X[N]).
421
+ // This catches struct binding in callbacks, conditions, selections, text args, etc.
422
+ // — not just directBind widgets.
423
+ scanNodeExprsForBinding(node, bound);
252
424
  if (node.kind === 'conditional') {
253
425
  walkNodesForBinding(node.body, bound);
254
426
  if (node.elseBody)
@@ -259,10 +431,115 @@ function walkNodesForBinding(nodes, bound) {
259
431
  }
260
432
  }
261
433
  }
434
+ /** Scan all string-valued properties of an IR node for props.X.Y / props.X[N] patterns. */
435
+ function scanNodeExprsForBinding(node, bound) {
436
+ const obj = node;
437
+ for (const key of Object.keys(obj)) {
438
+ // Skip structural/recursive fields — they're handled by walkNodes recursion
439
+ if (key === 'kind' || key === 'body' || key === 'elseBody' || key === 'loc')
440
+ continue;
441
+ const val = obj[key];
442
+ if (typeof val === 'string') {
443
+ extractNestedPropAccess(val, bound);
444
+ }
445
+ else if (Array.isArray(val)) {
446
+ for (const item of val) {
447
+ if (typeof item === 'string')
448
+ extractNestedPropAccess(item, bound);
449
+ }
450
+ }
451
+ else if (typeof val === 'object' && val !== null) {
452
+ // Handles Record<string, string> (container props, custom component props)
453
+ // and IRItemInteraction objects
454
+ for (const v of Object.values(val)) {
455
+ if (typeof v === 'string')
456
+ extractNestedPropAccess(v, bound);
457
+ }
458
+ }
459
+ }
460
+ }
461
+ /** If expr contains props.X.Y or props.X[N], mark X as needing pointer binding.
462
+ * Skips .c_str() and .size() — these are method calls on scalar types, not struct access. */
463
+ function extractNestedPropAccess(expr, bound) {
464
+ // Match props.X. (dot access)
465
+ const dotRegex = /props\.(\w+)\./g;
466
+ let match;
467
+ while ((match = dotRegex.exec(expr)) !== null) {
468
+ const afterDot = expr.slice(match.index + match[0].length);
469
+ if (afterDot.startsWith('c_str()') || afterDot.startsWith('size()'))
470
+ continue;
471
+ bound.add(match[1]);
472
+ }
473
+ // Match props.X[ (bracket access)
474
+ const bracketRegex = /props\.(\w+)\[/g;
475
+ while ((match = bracketRegex.exec(expr)) !== null) {
476
+ bound.add(match[1]);
477
+ }
478
+ }
479
+ /**
480
+ * Walk IR nodes in a parent component, resolving actual C++ types for child bound props.
481
+ * When a child has `speed: number` (→ 'int') but the parent passes props.speed from a struct
482
+ * where speed is float, the resolved type overrides the child's inferred type.
483
+ */
484
+ function resolveChildBoundTypes(nodes, parentFieldTypes, componentMap, extIfaces, result) {
485
+ for (const node of nodes) {
486
+ if (node.kind === 'custom_component') {
487
+ const child = componentMap.get(node.name);
488
+ if (child) {
489
+ for (const [propName, valueExpr] of Object.entries(node.props)) {
490
+ if (!child.boundProps.has(propName) || !valueExpr.startsWith('props.'))
491
+ continue;
492
+ const parts = valueExpr.slice(6).split('.');
493
+ const topField = parts[0].split('[')[0];
494
+ const topType = parentFieldTypes.get(topField);
495
+ if (!topType || topType === 'callback')
496
+ continue;
497
+ let resolvedType;
498
+ if (parts.length === 1) {
499
+ // Direct scalar: props.speed → parent's type for speed
500
+ resolvedType = topType;
501
+ }
502
+ else if (parts.length === 2) {
503
+ // Nested: props.data.speed → look up speed in data's interface
504
+ const iface = extIfaces.get(topType);
505
+ if (iface) {
506
+ const ft = iface.get(parts[1].split('[')[0]);
507
+ if (ft && ft !== 'callback')
508
+ resolvedType = ft;
509
+ }
510
+ }
511
+ if (resolvedType) {
512
+ if (!result.has(node.name))
513
+ result.set(node.name, new Map());
514
+ result.get(node.name).set(propName, resolvedType);
515
+ }
516
+ }
517
+ }
518
+ }
519
+ else if (node.kind === 'conditional') {
520
+ resolveChildBoundTypes(node.body, parentFieldTypes, componentMap, extIfaces, result);
521
+ if (node.elseBody)
522
+ resolveChildBoundTypes(node.elseBody, parentFieldTypes, componentMap, extIfaces, result);
523
+ }
524
+ else if (node.kind === 'list_map') {
525
+ resolveChildBoundTypes(node.body, parentFieldTypes, componentMap, extIfaces, result);
526
+ }
527
+ }
528
+ }
262
529
  /**
263
530
  * Parse the imx.d.ts in the given directory (if present) and extract
264
531
  * all interface declarations as a map from interface name -> field name -> type.
265
532
  */
533
+ function normalizeExternalPropType(typeText) {
534
+ const trimmed = typeText.trim().replace(/\s*\|\s*undefined$/, '');
535
+ if (trimmed === 'number')
536
+ return 'float';
537
+ if (trimmed === 'boolean')
538
+ return 'bool';
539
+ if (trimmed === 'string')
540
+ return 'string';
541
+ return trimmed;
542
+ }
266
543
  function loadExternalInterfaces(dir) {
267
544
  const result = new Map();
268
545
  const dtsPath = path.join(dir, 'imx.d.ts');
@@ -285,20 +562,7 @@ function loadExternalInterfaces(dir) {
285
562
  fields.set(fieldName, 'callback');
286
563
  continue;
287
564
  }
288
- const typeText = member.type.getText(sf);
289
- if (typeText === 'number') {
290
- fields.set(fieldName, 'float');
291
- }
292
- else if (typeText === 'boolean') {
293
- fields.set(fieldName, 'bool');
294
- }
295
- else if (typeText === 'string') {
296
- fields.set(fieldName, 'string');
297
- }
298
- else {
299
- // Arrays, nested interfaces, etc. treated as opaque (non-scalar)
300
- fields.set(fieldName, 'string');
301
- }
565
+ fields.set(fieldName, normalizeExternalPropType(member.type.getText(sf)));
302
566
  }
303
567
  else if (ts.isMethodSignature(member)) {
304
568
  const mName = ts.isIdentifier(member.name) ? member.name.text : '';