lecodes-cli 0.7.2 → 0.8.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.
Files changed (40) hide show
  1. package/dist/index.js +2909 -725
  2. package/package.json +2 -2
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk/canvas/Canvas.ts +16 -0
  5. package/runtime/sdk/gl/Material.ts +4 -0
  6. package/runtime/sdk/inject.ts +7 -4
  7. package/runtime/sdk/plugins/service.ts +47 -0
  8. package/runtime/sdk/runtime/app.ts +21 -1
  9. package/runtime/sdk/runtime/appEvents.ts +25 -0
  10. package/runtime/sdk/runtime/service.ts +3 -2
  11. package/runtime/sdk/ui/NativeView.ts +2 -1
  12. package/runtime/sdk/ui/UI.ts +3 -1
  13. package/runtime/sdk/ui/UIButton.ts +10 -5
  14. package/runtime/sdk/ui/UIContainer.ts +16 -9
  15. package/runtime/sdk/ui/UIImage.ts +5 -2
  16. package/runtime/sdk/ui/UIInput.ts +75 -8
  17. package/runtime/sdk/ui/UIModal.ts +149 -0
  18. package/runtime/sdk/ui/UINode.ts +113 -50
  19. package/runtime/sdk/ui/UIPager.ts +297 -0
  20. package/runtime/sdk/ui/UIPopover.ts +106 -0
  21. package/runtime/sdk/ui/UIScreen.ts +11 -10
  22. package/runtime/sdk/ui/UIScrollable.ts +2 -1
  23. package/runtime/sdk/ui/UISpacer.ts +1 -1
  24. package/runtime/sdk/ui/UIText.ts +2 -1
  25. package/runtime/sdk/ui/UIVideo.ts +2 -1
  26. package/runtime/sdk/ui/UIVirtualizedList.ts +2 -1
  27. package/runtime/sdk/ui/UIWidget.ts +19 -22
  28. package/runtime/sdk/ui/theme.ts +66 -0
  29. package/runtime/sdk-types.json +1 -1
  30. package/runtime/sdk/compile/aspectMacro.ts +0 -42
  31. package/runtime/sdk/compile/assetMacro.ts +0 -45
  32. package/runtime/sdk/compile/bundler.ts +0 -207
  33. package/runtime/sdk/compile/compileProject.ts +0 -113
  34. package/runtime/sdk/compile/header.ts +0 -46
  35. package/runtime/sdk/compile/index.ts +0 -18
  36. package/runtime/sdk/compile/libraryImports.ts +0 -47
  37. package/runtime/sdk/compile/sceneEditor.ts +0 -78
  38. package/runtime/sdk/compile/sourcemap.ts +0 -25
  39. package/runtime/sdk/kit/UITabs.ts +0 -105
  40. package/runtime/sdk/ui/UIScreenHost.ts +0 -213
package/dist/index.js CHANGED
@@ -109,12 +109,15 @@ var hasImportStatement = (src) => src.includes("import") || src.includes("from")
109
109
  if (isRelativeOrAbsolute(spec))
110
110
  return spec;
111
111
  const slash = spec.indexOf("/");
112
- const slug2 = slash === -1 ? spec : spec.slice(0, slash);
113
- if (slugs.has(slug2)) {
112
+ const slug = slash === -1 ? spec : spec.slice(0, slash);
113
+ if (slugs.has(slug)) {
114
114
  const sub = slash === -1 ? "/index" : spec.slice(slash);
115
- return `/__lib/${slug2}${sub}`;
115
+ return `/__lib/${slug}${sub}`;
116
116
  }
117
- throw new Error(`Unknown library "${slug2}" — add it in Settings → Libraries, or use a relative path like "./${slug2}".`);
117
+ if (slug === "ui" || slug === "creator" || slug === "utils") {
118
+ throw new Error(`import from "${slug}": the SDK needs no imports — UIScreen, Scene, etc. are globals; remove this import line.`);
119
+ }
120
+ throw new Error(`Unknown library "${slug}" — add it in Settings → Libraries, or use a relative path like "./${slug}".`);
118
121
  };
119
122
  let out = src.replace(IMPORT_FROM_RE, (_m, boundary, head, quote, spec) => `${boundary}${head}${quote}${rewriteSpec(spec)}${quote}`);
120
123
  out = out.replace(SIDE_EFFECT_RE, (m, boundary, head, quote, spec) => isRelativeOrAbsolute(spec) ? m : `${boundary}${head}${quote}${rewriteSpec(spec)}${quote}`);
@@ -126,18 +129,18 @@ var init_libraryImports = __esm(() => {
126
129
  });
127
130
 
128
131
  // ../sdk/src/compile/bundler.ts
129
- import { existsSync as existsSync6, readdirSync as readdirSync3, readFileSync as readFileSync8, statSync } from "node:fs";
132
+ import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync } from "node:fs";
130
133
  import { spawnSync } from "node:child_process";
131
134
  import { createRequire as createRequire2 } from "node:module";
132
- import { dirname as dirname5, join as join8, relative as relative2 } from "node:path";
135
+ import { dirname as dirname4, join as join7, relative as relative2 } from "node:path";
133
136
  import { fileURLToPath as fileURLToPath2 } from "node:url";
134
137
  var resolveInject = () => {
135
138
  const override = process.env.LECODES_SDK_INJECT;
136
- if (override && existsSync6(override))
139
+ if (override && existsSync4(override))
137
140
  return override;
138
- const here = dirname5(fileURLToPath2(import.meta.url));
139
- const src = join8(here, "..", "inject.ts");
140
- if (existsSync6(src))
141
+ const here = dirname4(fileURLToPath2(import.meta.url));
142
+ const src = join7(here, "..", "inject.ts");
143
+ if (existsSync4(src))
141
144
  return src;
142
145
  throw new Error("sdk: could not locate the inject source (LECODES_SDK_INJECT / src/inject.ts)");
143
146
  }, normalize = (path) => {
@@ -156,19 +159,19 @@ var resolveInject = () => {
156
159
  if (!inject.endsWith("inject.ts")) {
157
160
  throw new Error(`chisel needs the SDK source tree (src/inject.ts); got ${inject}. Set LECODES_SDK_INJECT to the source.`);
158
161
  }
159
- return dirname5(inject);
162
+ return dirname4(inject);
160
163
  }, isSdkSource = (path) => /\.[tj]s$/.test(path) && !path.endsWith(".d.ts") && !path.includes("/compile/") && !path.includes("/tests/") && !path.includes("/__tests__/"), sdkFilesCache, collectSdkFiles = (sdkSrc) => {
161
164
  const cached = sdkFilesCache.get(sdkSrc);
162
165
  if (cached)
163
166
  return cached;
164
167
  const out = {};
165
168
  const walk = (dir) => {
166
- for (const name of readdirSync3(dir)) {
167
- const p = join8(dir, name);
169
+ for (const name of readdirSync2(dir)) {
170
+ const p = join7(dir, name);
168
171
  if (statSync(p).isDirectory())
169
172
  walk(p);
170
173
  else if (isSdkSource(p))
171
- out["/__sdk/" + relative2(sdkSrc, p).split("\\").join("/")] = readFileSync8(p, "utf8");
174
+ out["/__sdk/" + relative2(sdkSrc, p).split("\\").join("/")] = readFileSync7(p, "utf8");
172
175
  }
173
176
  };
174
177
  walk(sdkSrc);
@@ -177,13 +180,13 @@ var resolveInject = () => {
177
180
  }, resolveChiselBin = () => {
178
181
  const override = process.env.LECODES_CHISEL_BIN;
179
182
  if (override) {
180
- if (existsSync6(override))
183
+ if (existsSync4(override))
181
184
  return override;
182
185
  throw new Error(`LECODES_CHISEL_BIN points at a missing file: ${override}`);
183
186
  }
184
187
  try {
185
188
  const bin = createRequire2(import.meta.url)("@letary/chisel").binaryPath();
186
- if (bin && existsSync6(bin))
189
+ if (bin && existsSync4(bin))
187
190
  return bin;
188
191
  } catch {}
189
192
  throw new Error("chisel binary not found. Install @letary/chisel, or set LECODES_CHISEL_BIN.");
@@ -206,9 +209,9 @@ var resolveInject = () => {
206
209
  }
207
210
  if (!projFiles[entry])
208
211
  throw new Error(`Entrypoint not found: ${entry}`);
209
- for (const [slug2, libFiles] of Object.entries(libraries ?? {})) {
212
+ for (const [slug, libFiles] of Object.entries(libraries ?? {})) {
210
213
  for (const [k, v] of Object.entries(libFiles)) {
211
- const path = normalize(`/__lib/${slug2}/${k}`);
214
+ const path = normalize(`/__lib/${slug}/${k}`);
212
215
  let src = v;
213
216
  if (/\.[tj]sx?$/.test(path)) {
214
217
  if (hasAssetMacro(src))
@@ -290,6 +293,9 @@ var buildHeader = (name, code, opts = {}) => {
290
293
  localAssets.add("capture");
291
294
  if (localAssets.size > 0)
292
295
  header += `// preload: ${[...localAssets].join(";")}
296
+ `;
297
+ if (opts.fonts)
298
+ header += `// fonts: ${opts.fonts}
293
299
  `;
294
300
  if (!code.includes("_creator.createScene") && !code.includes("_creator.createEntity"))
295
301
  header += `// gl: disable
@@ -339,7 +345,7 @@ var detectEntry = (entries) => {
339
345
  const files = {};
340
346
  for (const e of opts.entries) {
341
347
  if (e.type === "resource") {
342
- if (opts.localAssets) {
348
+ if (opts.localAssets && !e.remote) {
343
349
  const name = e.path.split(/[\\/]/).pop() ?? e.path;
344
350
  files[e.path] = `export default ("id:" + _creatorUtils.fetchLocal(${JSON.stringify(name)}))`;
345
351
  } else {
@@ -365,7 +371,7 @@ var detectEntry = (entries) => {
365
371
  return map ? `${code}
366
372
  ${inlineSourceMapComment(map)}` : code;
367
373
  }
368
- const header = buildHeader(opts.name, code, { icon: opts.icon });
374
+ const header = buildHeader(opts.name, code, { icon: opts.icon, fonts: opts.fontsHeader });
369
375
  if (!map)
370
376
  return header + code;
371
377
  const headerLines = (header.match(/\n/g) ?? []).length;
@@ -377,17 +383,17 @@ var init_compileProject = __esm(() => {
377
383
  });
378
384
 
379
385
  // ../sdk/src/compile/sceneEditor.ts
380
- import { existsSync as existsSync7, readdirSync as readdirSync4, readFileSync as readFileSync9 } from "node:fs";
381
- import { join as join9 } from "node:path";
386
+ import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync8 } from "node:fs";
387
+ import { join as join8 } from "node:path";
382
388
  var SCENE_HARNESS_DIR = "./.scene-editor", SCENE_ENTRY_PATH = "./.scene-entry.ts", loadSceneHarness = (fallbackDir) => {
383
389
  const override = process.env.LECODES_SCENE_HARNESS_DIR;
384
- const dir = override && existsSync7(override) ? override : fallbackDir && existsSync7(fallbackDir) ? fallbackDir : null;
390
+ const dir = override && existsSync5(override) ? override : fallbackDir && existsSync5(fallbackDir) ? fallbackDir : null;
385
391
  if (!dir)
386
392
  throw new Error("scene-editor harness sources not found (set LECODES_SCENE_HARNESS_DIR)");
387
393
  const out = {};
388
- for (const f of readdirSync4(dir)) {
394
+ for (const f of readdirSync3(dir)) {
389
395
  if (f.endsWith(".ts"))
390
- out[f] = readFileSync9(join9(dir, f), "utf8");
396
+ out[f] = readFileSync8(join8(dir, f), "utf8");
391
397
  }
392
398
  return out;
393
399
  }, sceneEditorEntries = (scenePath, harness, projectPaths = []) => {
@@ -417,25 +423,569 @@ export {}
417
423
  };
418
424
  var init_sceneEditor = () => {};
419
425
 
426
+ // ../sdk/src/compile/fontRegistry.ts
427
+ var DEFAULT_FONT_REGISTRY_BASE = "https://cdn.le.codes/fonts/v1", fontRegistryBase = () => typeof process !== "undefined" && process.env?.LECODES_FONT_REGISTRY?.trim() || DEFAULT_FONT_REGISTRY_BASE, faceFileName = (id, weight, italic, ext2) => `${id}-${weight}${italic ? "-italic" : ""}.${ext2}`, LATIN, CYR, OFL = "OFL-1.1", f = (family, category, scripts, weights, italics, defaultWeights) => ({ family, category, scripts, weights, italics, defaultWeights, license: OFL }), FONT_REGISTRY;
428
+ var init_fontRegistry = __esm(() => {
429
+ LATIN = ["latin"];
430
+ CYR = ["latin", "cyrillic"];
431
+ FONT_REGISTRY = {
432
+ inter: f("Inter", "sans-serif", CYR, [100, 200, 300, 400, 500, 600, 700, 800, 900], [], [400, 500, 700]),
433
+ manrope: f("Manrope", "sans-serif", CYR, [200, 300, 400, 500, 600, 700, 800], [], [400, 500, 700]),
434
+ "golos-text": f("Golos Text", "sans-serif", CYR, [400, 500, 600, 700, 800, 900], [], [400, 500, 700]),
435
+ onest: f("Onest", "sans-serif", CYR, [100, 200, 300, 400, 500, 600, 700, 800, 900], [], [400, 500, 700]),
436
+ rubik: f("Rubik", "sans-serif", CYR, [300, 400, 500, 600, 700, 800, 900], [300, 400, 500, 600, 700, 800, 900], [400, 500, 700]),
437
+ nunito: f("Nunito", "sans-serif", CYR, [200, 300, 400, 500, 600, 700, 800, 900], [400, 700], [400, 600, 700]),
438
+ montserrat: f("Montserrat", "sans-serif", CYR, [100, 200, 300, 400, 500, 600, 700, 800, 900], [400, 700], [400, 500, 700]),
439
+ "ibm-plex-sans": f("IBM Plex Sans", "sans-serif", CYR, [100, 200, 300, 400, 500, 600, 700], [400, 700], [400, 500, 700]),
440
+ "pt-sans": f("PT Sans", "sans-serif", CYR, [400, 700], [400, 700], [400, 700]),
441
+ jost: f("Jost", "sans-serif", CYR, [100, 200, 300, 400, 500, 600, 700, 800, 900], [400, 700], [400, 500, 700]),
442
+ "exo-2": f("Exo 2", "sans-serif", CYR, [100, 200, 300, 400, 500, 600, 700, 800, 900], [400, 700], [400, 500, 700]),
443
+ unbounded: f("Unbounded", "display", CYR, [200, 300, 400, 500, 600, 700, 800, 900], [], [400, 700]),
444
+ "russo-one": f("Russo One", "display", CYR, [400], [], [400]),
445
+ oswald: f("Oswald", "display", CYR, [200, 300, 400, 500, 600, 700], [], [400, 700]),
446
+ comfortaa: f("Comfortaa", "display", CYR, [300, 400, 500, 600, 700], [], [400, 700]),
447
+ "space-grotesk": f("Space Grotesk", "display", LATIN, [300, 400, 500, 600, 700], [], [400, 700]),
448
+ "playfair-display": f("Playfair Display", "serif", CYR, [400, 500, 600, 700, 800, 900], [400, 700], [400, 700]),
449
+ lora: f("Lora", "serif", CYR, [400, 500, 600, 700], [400, 700], [400, 700]),
450
+ "pt-serif": f("PT Serif", "serif", CYR, [400, 700], [400, 700], [400, 700]),
451
+ cormorant: f("Cormorant", "serif", CYR, [300, 400, 500, 600, 700], [400, 700], [400, 700]),
452
+ literata: f("Literata", "serif", CYR, [200, 300, 400, 500, 600, 700, 800, 900], [400, 700], [400, 700]),
453
+ "jetbrains-mono": f("JetBrains Mono", "monospace", CYR, [100, 200, 300, 400, 500, 600, 700, 800], [400, 700], [400, 700]),
454
+ "fira-code": f("Fira Code", "monospace", CYR, [300, 400, 500, 600, 700], [], [400, 700]),
455
+ "ibm-plex-mono": f("IBM Plex Mono", "monospace", CYR, [100, 200, 300, 400, 500, 600, 700], [400, 700], [400, 700]),
456
+ caveat: f("Caveat", "handwriting", CYR, [400, 500, 600, 700], [], [400, 700]),
457
+ pacifico: f("Pacifico", "handwriting", CYR, [400], [], [400])
458
+ };
459
+ });
460
+
461
+ // ../sdk/src/compile/sfnt.ts
462
+ var TAG_TTF = 65536, TAG_OTTO = 1330926671, TAG_TTCF = 1953784678, TAG_WOFF = 2001684038, TAG_WOF2 = 2001684018, parseSfnt = (bytes) => {
463
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
464
+ if (bytes.byteLength < 12)
465
+ throw new Error("not a font file (truncated)");
466
+ const version = dv.getUint32(0);
467
+ if (version === TAG_TTCF)
468
+ throw new Error("font collections (.ttc) aren't supported — export a single face");
469
+ if (version === TAG_WOFF || version === TAG_WOF2)
470
+ throw new Error("WOFF/WOFF2 project fonts aren't supported — use the .ttf/.otf");
471
+ if (version !== TAG_TTF && version !== TAG_OTTO)
472
+ throw new Error("not a TTF/OTF font file");
473
+ const numTables = dv.getUint16(4);
474
+ const tables = new Map;
475
+ for (let i = 0;i < numTables; i++) {
476
+ const rec = 12 + i * 16;
477
+ if (rec + 16 > bytes.byteLength)
478
+ throw new Error("not a font file (truncated table directory)");
479
+ const tag = String.fromCharCode(bytes[rec], bytes[rec + 1], bytes[rec + 2], bytes[rec + 3]);
480
+ tables.set(tag, { offset: dv.getUint32(rec + 8), length: dv.getUint32(rec + 12) });
481
+ }
482
+ const family = readFamily(dv, bytes, tables.get("name"));
483
+ if (!family)
484
+ throw new Error("no family name in the font's `name` table");
485
+ let weight = 400;
486
+ let italic = false;
487
+ const os2 = tables.get("OS/2");
488
+ if (os2 && os2.offset + 64 <= bytes.byteLength) {
489
+ weight = dv.getUint16(os2.offset + 4);
490
+ italic = (dv.getUint16(os2.offset + 62) & 1) === 1;
491
+ } else {
492
+ const head = tables.get("head");
493
+ if (head && head.offset + 46 <= bytes.byteLength) {
494
+ const macStyle = dv.getUint16(head.offset + 44);
495
+ if (macStyle & 1)
496
+ weight = 700;
497
+ italic = (macStyle & 2) === 2;
498
+ }
499
+ }
500
+ if (weight < 1 || weight > 1000)
501
+ weight = 400;
502
+ return { family, weight, italic };
503
+ }, readFamily = (dv, bytes, name) => {
504
+ if (!name || name.offset + 6 > bytes.byteLength)
505
+ return "";
506
+ const base = name.offset;
507
+ const count = dv.getUint16(base + 2);
508
+ const stringOffset = base + dv.getUint16(base + 4);
509
+ let best = "";
510
+ let bestScore = -1;
511
+ for (let i = 0;i < count; i++) {
512
+ const rec = base + 6 + i * 12;
513
+ if (rec + 12 > bytes.byteLength)
514
+ break;
515
+ const platformID = dv.getUint16(rec);
516
+ const languageID = dv.getUint16(rec + 4);
517
+ const nameID = dv.getUint16(rec + 6);
518
+ const length = dv.getUint16(rec + 8);
519
+ const offset = stringOffset + dv.getUint16(rec + 10);
520
+ if (nameID !== 16 && nameID !== 1)
521
+ continue;
522
+ if (offset + length > bytes.byteLength)
523
+ continue;
524
+ let score = nameID === 16 ? 100 : 0;
525
+ if (platformID === 3)
526
+ score += languageID === 1033 ? 30 : 20;
527
+ else if (platformID === 0)
528
+ score += 10;
529
+ if (score <= bestScore)
530
+ continue;
531
+ const text = platformID === 1 ? decodeLatin1(bytes, offset, length) : decodeUtf16be(bytes, offset, length);
532
+ if (!text.trim())
533
+ continue;
534
+ best = text.trim();
535
+ bestScore = score;
536
+ }
537
+ return best;
538
+ }, decodeUtf16be = (bytes, offset, length) => {
539
+ let out = "";
540
+ for (let i = 0;i + 1 < length; i += 2)
541
+ out += String.fromCharCode(bytes[offset + i] << 8 | bytes[offset + i + 1]);
542
+ return out;
543
+ }, decodeLatin1 = (bytes, offset, length) => {
544
+ let out = "";
545
+ for (let i = 0;i < length; i++)
546
+ out += String.fromCharCode(bytes[offset + i]);
547
+ return out;
548
+ };
549
+
550
+ // ../sdk/src/compile/fontMacro.ts
551
+ var FONT_ID, CODE_FILE, DECLARES_FONT, isIdentStart = (c2) => c2 >= "A" && c2 <= "Z" || c2 >= "a" && c2 <= "z" || c2 === "_" || c2 === "$", isIdentPart = (c2) => isIdentStart(c2) || c2 >= "0" && c2 <= "9", skipString = (src, start) => {
552
+ const quote = src[start];
553
+ const n = src.length;
554
+ let i = start + 1;
555
+ while (i < n) {
556
+ const c2 = src[i];
557
+ if (c2 === "\\") {
558
+ i += 2;
559
+ continue;
560
+ }
561
+ if (quote === "`" && c2 === "$" && src[i + 1] === "{") {
562
+ i = skipBraces(src, i + 1);
563
+ continue;
564
+ }
565
+ if (c2 === quote)
566
+ return i + 1;
567
+ i++;
568
+ }
569
+ return n;
570
+ }, skipBraces = (src, open) => {
571
+ const n = src.length;
572
+ let depth = 0;
573
+ let i = open;
574
+ while (i < n) {
575
+ const c2 = src[i];
576
+ if (c2 === '"' || c2 === "'" || c2 === "`") {
577
+ i = skipString(src, i);
578
+ continue;
579
+ }
580
+ if (c2 === "{")
581
+ depth++;
582
+ else if (c2 === "}") {
583
+ depth--;
584
+ if (depth === 0)
585
+ return i + 1;
586
+ }
587
+ i++;
588
+ }
589
+ return n;
590
+ }, matchParen = (src, open) => {
591
+ const n = src.length;
592
+ let depth = 0;
593
+ let i = open;
594
+ while (i < n) {
595
+ const c2 = src[i];
596
+ if (c2 === '"' || c2 === "'" || c2 === "`") {
597
+ i = skipString(src, i);
598
+ continue;
599
+ }
600
+ if (c2 === "/" && src[i + 1] === "/") {
601
+ i += 2;
602
+ while (i < n && src[i] !== `
603
+ `)
604
+ i++;
605
+ continue;
606
+ }
607
+ if (c2 === "/" && src[i + 1] === "*") {
608
+ i += 2;
609
+ while (i < n && !(src[i] === "*" && src[i + 1] === "/"))
610
+ i++;
611
+ i += 2;
612
+ continue;
613
+ }
614
+ if (c2 === "(")
615
+ depth++;
616
+ else if (c2 === ")") {
617
+ depth--;
618
+ if (depth === 0)
619
+ return i;
620
+ }
621
+ i++;
622
+ }
623
+ return -1;
624
+ }, splitTopLevel = (text, delim) => {
625
+ const parts = [];
626
+ const n = text.length;
627
+ let depth = 0;
628
+ let last = 0;
629
+ let i = 0;
630
+ while (i < n) {
631
+ const c2 = text[i];
632
+ if (c2 === '"' || c2 === "'" || c2 === "`") {
633
+ i = skipString(text, i);
634
+ continue;
635
+ }
636
+ if (c2 === "(" || c2 === "[" || c2 === "{")
637
+ depth++;
638
+ else if (c2 === ")" || c2 === "]" || c2 === "}")
639
+ depth = Math.max(0, depth - 1);
640
+ else if (depth === 0 && c2 === delim) {
641
+ parts.push(text.slice(last, i));
642
+ last = i + 1;
643
+ }
644
+ i++;
645
+ }
646
+ parts.push(text.slice(last));
647
+ return parts;
648
+ }, stringLiteralValue = (text) => {
649
+ const t = text.trim();
650
+ if (t.length < 2)
651
+ return null;
652
+ const q = t[0];
653
+ if (q !== '"' && q !== "'" && q !== "`" || t[t.length - 1] !== q)
654
+ return null;
655
+ const inner = t.slice(1, -1);
656
+ if (q === "`" && inner.includes("${"))
657
+ return null;
658
+ if (inner.includes(q) && !inner.includes("\\" + q))
659
+ return null;
660
+ return inner.replace(/\\(.)/g, "$1");
661
+ }, prevNonSpace = (src, i) => {
662
+ let k = i - 1;
663
+ while (k >= 0 && /\s/.test(src[k]))
664
+ k--;
665
+ return k >= 0 ? src[k] : "";
666
+ }, scanFontCalls = (source) => {
667
+ const calls = [];
668
+ const n = source.length;
669
+ let i = 0;
670
+ while (i < n) {
671
+ const c2 = source[i];
672
+ if (c2 === '"' || c2 === "'" || c2 === "`") {
673
+ i = skipString(source, i);
674
+ continue;
675
+ }
676
+ if (c2 === "/" && source[i + 1] === "/") {
677
+ i += 2;
678
+ while (i < n && source[i] !== `
679
+ `)
680
+ i++;
681
+ continue;
682
+ }
683
+ if (c2 === "/" && source[i + 1] === "*") {
684
+ i += 2;
685
+ while (i < n && !(source[i] === "*" && source[i + 1] === "/"))
686
+ i++;
687
+ i += 2;
688
+ continue;
689
+ }
690
+ if (isIdentStart(c2)) {
691
+ let j = i + 1;
692
+ while (j < n && isIdentPart(source[j]))
693
+ j++;
694
+ if (source.slice(i, j) === "font" && prevNonSpace(source, i) !== ".") {
695
+ const call = parseCall(source, i, j);
696
+ if (call) {
697
+ calls.push(call);
698
+ i = call.end;
699
+ continue;
700
+ }
701
+ }
702
+ i = j;
703
+ continue;
704
+ }
705
+ i++;
706
+ }
707
+ return calls;
708
+ }, parseCall = (src, start, identEnd) => {
709
+ const n = src.length;
710
+ let i = identEnd;
711
+ while (i < n && /\s/.test(src[i]))
712
+ i++;
713
+ if (src[i] !== "(")
714
+ return null;
715
+ const close = matchParen(src, i);
716
+ if (close === -1)
717
+ return null;
718
+ const call = { start, end: close + 1, arg: "", kind: "registry" };
719
+ const args = splitTopLevel(src.slice(i + 1, close), ",");
720
+ const arg = stringLiteralValue(args[0] ?? "");
721
+ if (arg === null) {
722
+ call.error = `font(...) needs a string-literal argument — a registry id ("manrope") or a project path ("./fonts/Brand.ttf"). Got \`${(args[0] ?? "").trim() || "()"}\`.`;
723
+ return call;
724
+ }
725
+ call.arg = arg;
726
+ if (arg.startsWith("./") || arg.startsWith("../")) {
727
+ call.kind = "file";
728
+ if (!/\.(ttf|otf)$/i.test(arg)) {
729
+ call.error = `font("${arg}"): project fonts must be .ttf/.otf files.`;
730
+ return call;
731
+ }
732
+ if (args.length > 1 && args.slice(1).some((a) => a.trim())) {
733
+ call.error = `font("${arg}"): a project font takes no options — weight and style come from the file itself.`;
734
+ return call;
735
+ }
736
+ return call;
737
+ }
738
+ if (arg === "serif" || arg === "sans-serif" || arg === "monospace" || arg === "monospaced") {
739
+ call.error = `font("${arg}"): system font families need no font() — pass the string straight to fontFamily, e.g. .style({ fontFamily: "${arg}" }).`;
740
+ return call;
741
+ }
742
+ if (!FONT_ID.test(arg)) {
743
+ call.error = `font("${arg}"): expected a registry id (lowercase [a-z0-9-], e.g. "manrope") or a relative path ("./fonts/Brand.ttf").`;
744
+ return call;
745
+ }
746
+ call.kind = "registry";
747
+ call.id = arg;
748
+ const optsText = args[1]?.trim();
749
+ if (optsText) {
750
+ const err = parseOptions(optsText, call);
751
+ if (err) {
752
+ call.error = err;
753
+ return call;
754
+ }
755
+ }
756
+ return call;
757
+ }, parseOptions = (optsText, call) => {
758
+ if (!optsText.startsWith("{") || !optsText.endsWith("}")) {
759
+ return `font("${call.arg}", opts): opts must be an object literal like { weights: [400, 700] }.`;
760
+ }
761
+ for (const prop of splitTopLevel(optsText.slice(1, -1), ",")) {
762
+ if (!prop.trim())
763
+ continue;
764
+ const kv = splitTopLevel(prop, ":");
765
+ if (kv.length < 2)
766
+ return `font("${call.arg}"): couldn't parse option \`${prop.trim()}\`.`;
767
+ const key = kv[0].trim().replace(/^["'`]|["'`]$/g, "");
768
+ const raw = kv.slice(1).join(":").trim();
769
+ if (key === "weights") {
770
+ const m = /^\[\s*(\d+(?:\s*,\s*\d+)*)\s*,?\s*\]$/.exec(raw);
771
+ if (!m)
772
+ return `font("${call.arg}"): weights must be a literal number array, e.g. { weights: [400, 700] }.`;
773
+ call.weights = m[1].split(",").map((s) => parseInt(s.trim(), 10));
774
+ } else if (key === "italic") {
775
+ if (raw !== "true" && raw !== "false")
776
+ return `font("${call.arg}"): italic must be a literal true/false.`;
777
+ call.italic = raw === "true";
778
+ } else {
779
+ return `font("${call.arg}"): unknown option "${key}" (did you mean weights/italic?).`;
780
+ }
781
+ }
782
+ return null;
783
+ }, norm = (p) => p.replace(/\\/g, "/").replace(/^\//, ""), dirOf = (p) => {
784
+ const n = norm(p);
785
+ const i = n.lastIndexOf("/");
786
+ return i === -1 ? "" : n.slice(0, i);
787
+ }, baseOf = (p) => {
788
+ const n = norm(p);
789
+ return n.slice(n.lastIndexOf("/") + 1);
790
+ }, resolveRel = (dir, rel) => {
791
+ const out = dir ? dir.split("/") : [];
792
+ for (const seg of rel.split("/")) {
793
+ if (!seg || seg === ".")
794
+ continue;
795
+ if (seg === "..") {
796
+ if (out.length === 0)
797
+ return "";
798
+ out.pop();
799
+ } else
800
+ out.push(seg);
801
+ }
802
+ return out.join("/");
803
+ }, relSpecifier = (fromDir, toPath) => {
804
+ const from = fromDir ? fromDir.split("/") : [];
805
+ const to = norm(toPath).split("/");
806
+ let common = 0;
807
+ while (common < from.length && common < to.length - 1 && from[common] === to[common])
808
+ common++;
809
+ const ups = from.length - common;
810
+ const rest = to.slice(common).join("/");
811
+ return ups === 0 ? `./${rest}` : `${"../".repeat(ups)}${rest}`;
812
+ }, faceKey = (family, weight, italic) => `${family}|${weight}|${italic ? "i" : "n"}`, applyFontMacro = async (entries, opts) => {
813
+ const scanned = entries.filter((e) => e.type === "text" && CODE_FILE.test(e.path) && typeof e.text === "string" && e.text.includes("font(")).filter((e) => !DECLARES_FONT.test(e.text)).map((e) => ({ entry: e, calls: scanFontCalls(e.text) })).filter((s) => s.calls.length > 0);
814
+ if (scanned.length === 0)
815
+ return [];
816
+ const errors = scanned.flatMap((s) => s.calls.filter((c2) => c2.error).map((c2) => `${s.entry.path}: ${c2.error}`));
817
+ if (errors.length)
818
+ throw new Error([...new Set(errors)].join(`
819
+ `));
820
+ const registryBase = (opts.registryBase ?? fontRegistryBase()).replace(/\/+$/, "");
821
+ const entryByPath = new Map(entries.map((e) => [norm(e.path), e]));
822
+ const faces = new Map;
823
+ const fileFaceCache = new Map;
824
+ const resolveCall = (entryPath, call) => {
825
+ if (call.kind === "file") {
826
+ const filePath = resolveRel(dirOf(entryPath), call.arg);
827
+ const target = filePath && entryByPath.get(filePath);
828
+ if (!target)
829
+ throw new Error(`${entryPath}: font("${call.arg}"): no such file in the project (resolved to "${filePath || call.arg}").`);
830
+ call.filePath = filePath;
831
+ let info2 = fileFaceCache.get(filePath);
832
+ if (!info2) {
833
+ if (!opts.readBytes)
834
+ throw new Error(`${entryPath}: font("${call.arg}"): project-font files aren't supported in this compile context.`);
835
+ const bytes = opts.readBytes(filePath);
836
+ if (!bytes)
837
+ throw new Error(`${entryPath}: font("${call.arg}"): couldn't read "${filePath}".`);
838
+ try {
839
+ info2 = parseSfnt(bytes);
840
+ } catch (e) {
841
+ throw new Error(`${entryPath}: font("${call.arg}"): ${e.message}`);
842
+ }
843
+ fileFaceCache.set(filePath, info2);
844
+ }
845
+ return { family: info2.family, callFaces: [{ family: info2.family, weight: info2.weight, italic: info2.italic }] };
846
+ }
847
+ const meta = FONT_REGISTRY[call.id];
848
+ if (!meta) {
849
+ const known = Object.keys(FONT_REGISTRY);
850
+ throw new Error(`${entryPath}: font("${call.id}"): unknown registry font. Available: ${known.slice(0, 10).join(", ")}${known.length > 10 ? ", …" : ""}`);
851
+ }
852
+ const weights = call.weights ?? meta.defaultWeights;
853
+ const badW = weights.filter((w) => !meta.weights.includes(w));
854
+ if (badW.length)
855
+ throw new Error(`${entryPath}: font("${call.id}"): no ${badW.join("/")} face — available weights: ${meta.weights.join(", ")}.`);
856
+ if (call.italic) {
857
+ const badI = weights.filter((w) => !meta.italics.includes(w));
858
+ if (badI.length) {
859
+ throw new Error(`${entryPath}: font("${call.id}"): no italic ${badI.join("/")} face${meta.italics.length ? ` — italics exist for: ${meta.italics.join(", ")}` : " — the family ships no italics"}.`);
860
+ }
861
+ }
862
+ const callFaces = weights.flatMap((w) => call.italic ? [{ id: call.id, family: meta.family, weight: w, italic: false }, { id: call.id, family: meta.family, weight: w, italic: true }] : [{ id: call.id, family: meta.family, weight: w, italic: false }]);
863
+ return { family: meta.family, callFaces };
864
+ };
865
+ const pendingRegistry = new Map;
866
+ const work = [];
867
+ for (const { entry, calls } of scanned) {
868
+ const fw = { entry, calls: [], faceKeys: new Set };
869
+ for (const call of calls) {
870
+ const { family, callFaces } = resolveCall(norm(entry.path), call);
871
+ fw.calls.push({ call, family });
872
+ for (const cf of callFaces) {
873
+ const key = faceKey(cf.family, cf.weight, cf.italic);
874
+ fw.faceKeys.add(key);
875
+ if (faces.has(key))
876
+ continue;
877
+ if (cf.id === undefined) {
878
+ faces.set(key, { family: cf.family, weight: cf.weight, italic: cf.italic, source: { kind: "entry", path: call.filePath } });
879
+ } else {
880
+ pendingRegistry.set(key, cf);
881
+ faces.set(key, { family: cf.family, weight: cf.weight, italic: cf.italic, source: { kind: "cdn", urlBase: "" } });
882
+ }
883
+ }
884
+ }
885
+ work.push(fw);
886
+ }
887
+ for (const [key, face] of pendingRegistry) {
888
+ const ref = faces.get(key);
889
+ if (opts.mode === "cdn") {
890
+ ref.source = { kind: "cdn", urlBase: `${registryBase}/${face.id}/${face.id}-${face.weight}${face.italic ? "-italic" : ""}.` };
891
+ } else {
892
+ if (!opts.vendorFace)
893
+ throw new Error(`font("${face.id}"): registry fonts need vendoring in this compile context, but no vendor hook was provided.`);
894
+ const path = await opts.vendorFace({ ...face, fileName: faceFileName(face.id, face.weight, face.italic, "ttf") });
895
+ ref.source = { kind: "entry", path: norm(path) };
896
+ }
897
+ }
898
+ for (const fw of work) {
899
+ const entryPath = norm(fw.entry.path);
900
+ const dir = dirOf(entryPath);
901
+ const modBase = `.${baseOf(entryPath)}.__fonts.ts`;
902
+ const modPath = dir ? `${dir}/${modBase}` : modBase;
903
+ const idents = new Map;
904
+ for (const { family } of fw.calls) {
905
+ if (!idents.has(family))
906
+ idents.set(family, `__font_${idents.size}`);
907
+ }
908
+ let text = fw.entry.text;
909
+ for (const { call, family } of [...fw.calls].sort((a, b) => b.call.start - a.call.start)) {
910
+ text = text.slice(0, call.start) + idents.get(family) + text.slice(call.end);
911
+ }
912
+ text += `
913
+ import { ${[...idents.values()].join(", ")} } from ${JSON.stringify(`./${modBase}`)}`;
914
+ fw.entry.text = text;
915
+ const lines = [
916
+ "// Generated by the font() compile macro: registers this file's fonts. Imports hoist, so the",
917
+ "// faces are declared before the importing module's body runs. Hosts dedupe repeated faces."
918
+ ];
919
+ const faceImports = new Map;
920
+ const body = [];
921
+ for (const key of [...fw.faceKeys].sort()) {
922
+ const ref = faces.get(key);
923
+ let urlExpr;
924
+ if (ref.source.kind === "cdn") {
925
+ urlExpr = `${JSON.stringify(ref.source.urlBase)} + (_creatorUI.fontFormat ?? "ttf")`;
926
+ } else {
927
+ let ident = faceImports.get(ref.source.path);
928
+ if (!ident) {
929
+ ident = `__face_${faceImports.size}`;
930
+ faceImports.set(ref.source.path, ident);
931
+ lines.push(`import ${ident} from ${JSON.stringify(relSpecifier(dir, ref.source.path))}`);
932
+ }
933
+ urlExpr = ident;
934
+ }
935
+ const optionsExpr = ref.italic ? `{ weight: ${ref.weight}, style: "italic" }` : `{ weight: ${ref.weight} }`;
936
+ body.push(`_creatorUI.registerFont(${JSON.stringify(ref.family)}, ${urlExpr}, ${optionsExpr}, () => {}, () => {})`);
937
+ }
938
+ for (const [family, ident] of idents)
939
+ lines.push(`export const ${ident} = ${JSON.stringify(family)}`);
940
+ lines.push(...body);
941
+ entries.push({ path: modPath, type: "text", text: lines.join(`
942
+ `) + `
943
+ ` });
944
+ }
945
+ return [...faces.values()];
946
+ }, buildFontsHeaderValue = (faces, entryUrl) => faces.map((f2) => {
947
+ const tag = `${f2.family}@${f2.weight}${f2.italic ? "i" : ""}`;
948
+ if (f2.source.kind === "cdn")
949
+ return `${tag}=${f2.source.urlBase}{fmt}`;
950
+ const url = entryUrl?.(f2.source.path);
951
+ return url ? `${tag}=${url}` : null;
952
+ }).filter((s) => !!s).join(";");
953
+ var init_fontMacro = __esm(() => {
954
+ init_fontRegistry();
955
+ FONT_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
956
+ CODE_FILE = /\.[tj]sx?$/;
957
+ DECLARES_FONT = /\b(?:function|const|let|var|class)\s+font\b|\bimport\s+(?:\{[^}]*\bfont\b[^}]*\}|font)\s+from\b/;
958
+ });
959
+
420
960
  // ../sdk/src/compile/index.ts
421
961
  var exports_compile = {};
422
962
  __export(exports_compile, {
423
963
  sceneEditorEntries: () => sceneEditorEntries,
964
+ scanFontCalls: () => scanFontCalls,
965
+ parseSfnt: () => parseSfnt,
424
966
  offsetSourceMap: () => offsetSourceMap,
425
967
  loadSceneHarness: () => loadSceneHarness,
426
968
  inlineSourceMapComment: () => inlineSourceMapComment,
969
+ fontRegistryBase: () => fontRegistryBase,
970
+ faceFileName: () => faceFileName,
427
971
  detectProjectKind: () => detectProjectKind,
428
972
  compileProject: () => compileProject,
429
973
  bundleProjectWithMap: () => bundleProjectWithMap,
430
974
  bundleProject: () => bundleProject,
431
975
  buildHeader: () => buildHeader,
976
+ buildFontsHeaderValue: () => buildFontsHeaderValue,
977
+ applyFontMacro: () => applyFontMacro,
432
978
  SCENE_HARNESS_DIR: () => SCENE_HARNESS_DIR,
433
- SCENE_ENTRY_PATH: () => SCENE_ENTRY_PATH
979
+ SCENE_ENTRY_PATH: () => SCENE_ENTRY_PATH,
980
+ FONT_REGISTRY: () => FONT_REGISTRY,
981
+ DEFAULT_FONT_REGISTRY_BASE: () => DEFAULT_FONT_REGISTRY_BASE
434
982
  });
435
983
  var init_compile = __esm(() => {
436
984
  init_bundler();
437
985
  init_compileProject();
438
986
  init_sceneEditor();
987
+ init_fontMacro();
988
+ init_fontRegistry();
439
989
  });
440
990
 
441
991
  // ../../node_modules/.bun/qrcode-terminal@0.12.0/node_modules/qrcode-terminal/vendor/QRCode/QRMode.js
@@ -1457,7 +2007,7 @@ var require_main = __commonJS((exports, module) => {
1457
2007
  // package.json
1458
2008
  var package_default = {
1459
2009
  name: "lecodes-cli",
1460
- version: "0.7.2",
2010
+ version: "0.8.0",
1461
2011
  description: "Clone, edit and push LeCodes projects from your machine.",
1462
2012
  type: "module",
1463
2013
  license: "MIT",
@@ -1507,16 +2057,22 @@ var package_default = {
1507
2057
  },
1508
2058
  devDependencies: {
1509
2059
  "lecodes-design": "workspace:*",
1510
- "lecodes-renderer": "^0.4.1",
2060
+ "lecodes-renderer": "workspace:*",
1511
2061
  "qrcode-terminal": "^0.12.0",
1512
2062
  "lecodes-3d-editor": "workspace:*",
1513
2063
  sdk: "workspace:*"
1514
2064
  }
1515
2065
  };
1516
2066
 
1517
- // src/commands/clone.ts
1518
- import { existsSync as existsSync5, mkdirSync as mkdirSync5, readdirSync } from "node:fs";
1519
- import { resolve } from "node:path";
2067
+ // src/commands/app.ts
2068
+ import { execFileSync } from "node:child_process";
2069
+ import { cpSync, existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync10, readdirSync as readdirSync4, rmSync, statSync as statSync2, writeFileSync as writeFileSync7 } from "node:fs";
2070
+ import { homedir as homedir2 } from "node:os";
2071
+ import { basename as basename4, dirname as dirname7, join as join11, resolve as resolve2 } from "node:path";
2072
+
2073
+ // src/manifest.ts
2074
+ import { dirname, join } from "node:path";
2075
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
1520
2076
 
1521
2077
  // src/util.ts
1522
2078
  import { createHash } from "node:crypto";
@@ -1621,17 +2177,145 @@ var flagStr = (args, ...keys) => {
1621
2177
  };
1622
2178
  var flagBool = (args, ...keys) => keys.some((key) => args.flags[key] === true || args.flags[key] === "true");
1623
2179
 
1624
- // src/api.ts
1625
- var errorMessage = (body, status) => {
1626
- if (typeof body === "string" && body)
1627
- return body;
1628
- if (body && typeof body === "object") {
1629
- const b = body;
1630
- if (typeof b.error === "string")
1631
- return b.error;
1632
- if (b.error && typeof b.error === "object") {
1633
- const first = Object.values(b.error)[0];
1634
- if (typeof first === "string")
2180
+ // src/manifest.ts
2181
+ var LECODES_DIR = ".lecodes";
2182
+ var manifestPath = (root) => join(root, LECODES_DIR, "manifest.json");
2183
+ var findProjectRoot = (start) => {
2184
+ let dir = start;
2185
+ while (true) {
2186
+ if (existsSync(manifestPath(dir)))
2187
+ return dir;
2188
+ const parent = dirname(dir);
2189
+ if (parent === dir)
2190
+ throw new CliError("Not inside a lecodes project (no .lecodes/manifest.json found).");
2191
+ dir = parent;
2192
+ }
2193
+ };
2194
+ var readManifest = (root) => {
2195
+ try {
2196
+ return JSON.parse(readFileSync(manifestPath(root), "utf8"));
2197
+ } catch {
2198
+ throw new CliError("Could not read .lecodes/manifest.json — is this a lecodes project?");
2199
+ }
2200
+ };
2201
+ var writeManifest = (root, manifest) => {
2202
+ mkdirSync(join(root, LECODES_DIR), { recursive: true });
2203
+ writeFileSync(manifestPath(root), JSON.stringify(manifest, null, 2));
2204
+ };
2205
+
2206
+ // src/ignore.ts
2207
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
2208
+ import { join as join2 } from "node:path";
2209
+ var BUILTIN_PATTERNS = ["node_modules/", ".git/", "_*", "*.log", ".DS_Store"];
2210
+ var DEFAULT_IGNORE_FILE = `# Files matched here are NOT pushed to LeCodes.
2211
+ # Only untracked files are affected — assets already in the project stay tracked
2212
+ # even if they match a pattern. Built-in defaults (node_modules, _*, *.log) always apply.
2213
+
2214
+ # Render / preview outputs (the default \`lecodes render --png\` target)
2215
+ screenshot.png
2216
+ shots/
2217
+
2218
+ # Image & other binary assets ARE pushed by default — they're real project resources.
2219
+ # Uncomment a pattern below only if you want to keep some untracked ones out of pushes.
2220
+ # *.png
2221
+
2222
+ # Local notes & dumps (uncomment to ignore)
2223
+ # *.json
2224
+ # notes.md
2225
+ `;
2226
+ var translate = (glob) => {
2227
+ let re = "";
2228
+ for (let i = 0;i < glob.length; i++) {
2229
+ const ch = glob[i];
2230
+ if (ch === "*") {
2231
+ if (glob[i + 1] === "*") {
2232
+ re += ".*";
2233
+ i++;
2234
+ if (glob[i + 1] === "/")
2235
+ i++;
2236
+ } else
2237
+ re += "[^/]*";
2238
+ } else if (ch === "?")
2239
+ re += "[^/]";
2240
+ else if (ch === "/")
2241
+ re += "/";
2242
+ else if ("\\^$.|+()[]{}".includes(ch))
2243
+ re += "\\" + ch;
2244
+ else
2245
+ re += ch;
2246
+ }
2247
+ return re;
2248
+ };
2249
+ var compile = (raw) => {
2250
+ let p = raw.trim();
2251
+ if (!p || p.startsWith("#"))
2252
+ return null;
2253
+ let negate = false;
2254
+ if (p.startsWith("!")) {
2255
+ negate = true;
2256
+ p = p.slice(1);
2257
+ }
2258
+ let dirOnly = false;
2259
+ if (p.endsWith("/")) {
2260
+ dirOnly = true;
2261
+ p = p.slice(0, -1);
2262
+ }
2263
+ const anchored = p.startsWith("/") || p.includes("/");
2264
+ if (p.startsWith("/"))
2265
+ p = p.slice(1);
2266
+ if (!p)
2267
+ return null;
2268
+ const body = translate(p);
2269
+ const re = anchored ? new RegExp("^/" + body + "(/.*)?$") : new RegExp("(^|/)" + body + "(/.*)?$");
2270
+ return { re, negate, dirOnly };
2271
+ };
2272
+ var buildMatcher = (rules) => (key, isDir) => {
2273
+ let ignored = false;
2274
+ for (const r of rules) {
2275
+ if (r.dirOnly && !isDir)
2276
+ continue;
2277
+ if (r.re.test(key))
2278
+ ignored = !r.negate;
2279
+ }
2280
+ return ignored;
2281
+ };
2282
+ var buildGlobMatcher = (patterns) => {
2283
+ const rules = patterns.map((p) => compile(p.trim().replace(/\/+$/, ""))).filter((r) => r !== null);
2284
+ const match = buildMatcher(rules);
2285
+ return (path) => match(path.startsWith("/") ? path : "/" + path, false);
2286
+ };
2287
+ var loadIgnore = (root) => {
2288
+ const lines = [...BUILTIN_PATTERNS];
2289
+ const file = join2(root, ".lecodesignore");
2290
+ if (existsSync2(file)) {
2291
+ try {
2292
+ lines.push(...readFileSync2(file, "utf8").split(/\r?\n/));
2293
+ } catch {}
2294
+ }
2295
+ const rules = lines.map(compile).filter((r) => r !== null);
2296
+ return buildMatcher(rules);
2297
+ };
2298
+ var writeDefaultIgnore = (root) => {
2299
+ const file = join2(root, ".lecodesignore");
2300
+ if (!existsSync2(file))
2301
+ writeFileSync2(file, DEFAULT_IGNORE_FILE);
2302
+ };
2303
+
2304
+ // src/commands/compile.ts
2305
+ import { writeFileSync as writeFileSync6 } from "node:fs";
2306
+ import { basename as basename3, resolve } from "node:path";
2307
+
2308
+ // src/api.ts
2309
+ var errorMessage = (body, status) => {
2310
+ if (typeof body === "string" && body)
2311
+ return body;
2312
+ if (body && typeof body === "object") {
2313
+ const b = body;
2314
+ if (typeof b.error === "string")
2315
+ return b.error;
2316
+ if (b.error && typeof b.error === "object") {
2317
+ const first = Object.values(b.error)[0];
2318
+ if (typeof first === "string")
1635
2319
  return first;
1636
2320
  if (first && typeof first === "object" && typeof first.message === "string") {
1637
2321
  return first.message;
@@ -1688,15 +2372,15 @@ var downloadBlob = async (apiUrl, src) => {
1688
2372
 
1689
2373
  // src/config.ts
1690
2374
  import { homedir } from "node:os";
1691
- import { join } from "node:path";
1692
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2375
+ import { join as join3 } from "node:path";
2376
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
1693
2377
  var DEFAULT_API = "https://le.codes";
1694
- var CONFIG_DIR = join(homedir(), ".lecodes");
1695
- var CONFIG_FILE = join(CONFIG_DIR, "config.json");
2378
+ var CONFIG_DIR = join3(homedir(), ".lecodes");
2379
+ var CONFIG_FILE = join3(CONFIG_DIR, "config.json");
1696
2380
  var loadConfig = () => {
1697
2381
  let stored = {};
1698
2382
  try {
1699
- stored = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
2383
+ stored = JSON.parse(readFileSync3(CONFIG_FILE, "utf8"));
1700
2384
  } catch {}
1701
2385
  return {
1702
2386
  apiUrl: process.env.LECODES_API ?? stored.apiUrl,
@@ -1704,8 +2388,8 @@ var loadConfig = () => {
1704
2388
  };
1705
2389
  };
1706
2390
  var saveConfig = (config) => {
1707
- mkdirSync(CONFIG_DIR, { recursive: true });
1708
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
2391
+ mkdirSync2(CONFIG_DIR, { recursive: true });
2392
+ writeFileSync3(CONFIG_FILE, JSON.stringify(config, null, 2));
1709
2393
  };
1710
2394
  var normalizeApiUrl = (url) => url.trim().replace(/\/+$/, "");
1711
2395
  var requireToken = (config) => {
@@ -1719,39 +2403,124 @@ var requireApiUrl = (config) => {
1719
2403
  return normalizeApiUrl(config.apiUrl);
1720
2404
  };
1721
2405
 
1722
- // src/manifest.ts
1723
- import { dirname, join as join2 } from "node:path";
1724
- import { existsSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "node:fs";
1725
- var LECODES_DIR = ".lecodes";
1726
- var manifestPath = (root) => join2(root, LECODES_DIR, "manifest.json");
1727
- var findProjectRoot = (start) => {
1728
- let dir = start;
1729
- while (true) {
1730
- if (existsSync(manifestPath(dir)))
1731
- return dir;
1732
- const parent = dirname(dir);
1733
- if (parent === dir)
1734
- throw new CliError("Not inside a lecodes project (no .lecodes/manifest.json found).");
1735
- dir = parent;
1736
- }
2406
+ // src/compile/collect.ts
2407
+ import { readFileSync as readFileSync5 } from "node:fs";
2408
+ import { basename } from "node:path";
2409
+
2410
+ // src/localFiles.ts
2411
+ import { readdirSync, readFileSync as readFileSync4 } from "node:fs";
2412
+ import { join as join4, relative } from "node:path";
2413
+ var SKIP_DIRS = new Set([".lecodes", ".git", "node_modules"]);
2414
+ var SKIP_FILES = new Set(["tsconfig.json", "jsconfig.json", ".gitignore", ".lecodesignore", ".DS_Store"]);
2415
+ var TEXT_EXTS = new Set([
2416
+ "ts",
2417
+ "tsx",
2418
+ "js",
2419
+ "jsx",
2420
+ "mjs",
2421
+ "cjs",
2422
+ "json",
2423
+ "svg",
2424
+ "glsl",
2425
+ "vert",
2426
+ "frag",
2427
+ "txt",
2428
+ "md",
2429
+ "css",
2430
+ "html",
2431
+ "xml",
2432
+ "yml",
2433
+ "yaml",
2434
+ "csv"
2435
+ ]);
2436
+ var ext = (name) => {
2437
+ const i = name.lastIndexOf(".");
2438
+ return i < 0 ? "" : name.slice(i + 1).toLowerCase();
1737
2439
  };
1738
- var readManifest = (root) => {
1739
- try {
1740
- return JSON.parse(readFileSync2(manifestPath(root), "utf8"));
1741
- } catch {
1742
- throw new CliError("Could not read .lecodes/manifest.json — is this a lecodes project?");
1743
- }
2440
+ var classifyKind = (name) => {
2441
+ const e = ext(name);
2442
+ if (e === "mat")
2443
+ return "shader";
2444
+ if (e === "scene")
2445
+ return "scene";
2446
+ return TEXT_EXTS.has(e) ? "text" : "resource";
1744
2447
  };
1745
- var writeManifest = (root, manifest) => {
1746
- mkdirSync2(join2(root, LECODES_DIR), { recursive: true });
1747
- writeFileSync2(manifestPath(root), JSON.stringify(manifest, null, 2));
2448
+ var isTextKind = (kind) => kind !== "resource";
2449
+ var scanLocal = (root, opts = {}) => {
2450
+ const { ignore, tracked } = opts;
2451
+ const files = [];
2452
+ const dirs = [];
2453
+ const ignored = [];
2454
+ const isTracked = (key) => tracked?.has(key) ?? false;
2455
+ const hasTrackedUnder = (key) => {
2456
+ if (!tracked)
2457
+ return false;
2458
+ const prefix = key + "/";
2459
+ for (const t of tracked)
2460
+ if (t.startsWith(prefix))
2461
+ return true;
2462
+ return false;
2463
+ };
2464
+ const walk = (dir, parentIgnored) => {
2465
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
2466
+ const abs = join4(dir, entry.name);
2467
+ const key = toKey(relative(root, abs));
2468
+ if (entry.isDirectory()) {
2469
+ if (SKIP_DIRS.has(entry.name))
2470
+ continue;
2471
+ const dirIgnored = parentIgnored || !!ignore && ignore(key, true);
2472
+ if (dirIgnored && !isTracked(key) && !hasTrackedUnder(key)) {
2473
+ ignored.push(key + "/");
2474
+ continue;
2475
+ }
2476
+ dirs.push(key);
2477
+ walk(abs, dirIgnored);
2478
+ } else if (entry.isFile()) {
2479
+ if (SKIP_FILES.has(entry.name))
2480
+ continue;
2481
+ const fileIgnored = parentIgnored || !!ignore && ignore(key, false);
2482
+ if (fileIgnored && !isTracked(key)) {
2483
+ ignored.push(key);
2484
+ continue;
2485
+ }
2486
+ files.push({ path: key, absPath: abs, sha: sha256(readFileSync4(abs)) });
2487
+ }
2488
+ }
2489
+ };
2490
+ walk(root, false);
2491
+ return { files, dirs, ignored };
2492
+ };
2493
+ var scanProject = (root, manifest) => scanLocal(root, { ignore: loadIgnore(root), tracked: new Set(Object.keys(manifest.files)) });
2494
+ var diffLocal = (manifest, scan) => {
2495
+ const added = [];
2496
+ const modified = [];
2497
+ const localByPath = new Map(scan.files.map((f) => [f.path, f]));
2498
+ for (const file of scan.files) {
2499
+ const known = manifest.files[file.path];
2500
+ if (!known)
2501
+ added.push(file);
2502
+ else if (known.sha !== file.sha)
2503
+ modified.push(file);
2504
+ }
2505
+ const deleted = Object.keys(manifest.files).filter((path) => !localByPath.has(path));
2506
+ const moves = new Map;
2507
+ const claimed = new Set;
2508
+ for (const file of added) {
2509
+ const from = deleted.find((path) => !claimed.has(path) && manifest.files[path].sha === file.sha);
2510
+ if (from) {
2511
+ moves.set(file.path, from);
2512
+ claimed.add(from);
2513
+ }
2514
+ }
2515
+ return { added, modified, deleted, moves };
1748
2516
  };
2517
+ var hasChanges = (diff) => diff.added.length + diff.modified.length + diff.deleted.length > 0;
1749
2518
 
1750
2519
  // src/project.ts
1751
- import { dirname as dirname2, join as join3 } from "node:path";
1752
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
2520
+ import { dirname as dirname2, join as join5 } from "node:path";
2521
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "node:fs";
1753
2522
  var kindOf = (type) => type === "file" ? "resource" : type;
1754
- var absOf = (root, key) => join3(root, ...key.replace(/^\//, "").split("/"));
2523
+ var absOf = (root, key) => join5(root, ...key.replace(/^\//, "").split("/"));
1755
2524
  var buildPaths = (assets) => {
1756
2525
  const pathById = new Map;
1757
2526
  for (const asset of assets) {
@@ -1778,11 +2547,11 @@ var materializeAssets = async (root, project, apiUrl, token, onProgress) => {
1778
2547
  if (!asset.file?.src)
1779
2548
  continue;
1780
2549
  const bytes = await downloadBlob(apiUrl, asset.file.src);
1781
- writeFileSync3(abs, bytes);
2550
+ writeFileSync4(abs, bytes);
1782
2551
  sha = sha256(bytes);
1783
2552
  } else {
1784
2553
  const text = asset.text ?? "";
1785
- writeFileSync3(abs, text);
2554
+ writeFileSync4(abs, text);
1786
2555
  sha = sha256(text);
1787
2556
  }
1788
2557
  files[key] = { id: asset.id, type: kindOf(asset.type), sha };
@@ -1791,479 +2560,1630 @@ var materializeAssets = async (root, project, apiUrl, token, onProgress) => {
1791
2560
  return files;
1792
2561
  };
1793
2562
 
1794
- // src/types.ts
1795
- import { dirname as dirname4, join as join5 } from "node:path";
1796
- import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
1797
-
1798
- // src/distRoot.ts
1799
- import { existsSync as existsSync2 } from "node:fs";
1800
- import { dirname as dirname3, join as join4 } from "node:path";
1801
- import { fileURLToPath } from "node:url";
1802
- var distRoot = (() => {
1803
- const candidates = [
1804
- join4(dirname3(fileURLToPath(import.meta.url)), ".."),
1805
- dirname3(process.execPath)
1806
- ];
1807
- for (const c2 of candidates)
1808
- if (existsSync2(join4(c2, "runtime")))
1809
- return c2;
1810
- return candidates[0];
1811
- })();
1812
-
1813
- // src/types.ts
1814
- var TSCONFIG = {
1815
- compilerOptions: {
1816
- target: "ES2020",
1817
- module: "ESNext",
1818
- moduleResolution: "bundler",
1819
- lib: ["ES2020"],
1820
- types: [],
1821
- allowJs: true,
1822
- checkJs: false,
1823
- strict: false,
1824
- noEmit: true,
1825
- skipLibCheck: true
1826
- },
1827
- include: [`${LECODES_DIR}/types/**/*.d.ts`, "**/*.ts", "**/*.js"],
1828
- exclude: ["node_modules"]
1829
- };
1830
- var writeTypeBundle = (root, bundle) => {
1831
- const typesDir = join5(root, LECODES_DIR, "types");
1832
- for (const [path, text] of Object.entries(bundle)) {
1833
- const dest = join5(typesDir, path);
1834
- mkdirSync4(dirname4(dest), { recursive: true });
1835
- writeFileSync4(dest, text);
2563
+ // src/compile/collect.ts
2564
+ var collectEntries = (root, manifest, project, opts = {}) => {
2565
+ const pathById = buildPaths(project.assets);
2566
+ const serverByPath = new Map;
2567
+ for (const asset of project.assets) {
2568
+ if (asset.type === "folder")
2569
+ continue;
2570
+ serverByPath.set(pathById.get(asset.id), { fileSrc: asset.file?.src ?? undefined, shaders: asset.shaders ?? [] });
1836
2571
  }
1837
- };
1838
- var materializeTypes = async (root, apiUrl) => {
1839
- writeTypeBundle(root, await getTypes(apiUrl));
1840
- };
1841
- var readLocalTypeBundle = () => {
1842
- const candidates = [
1843
- join5(distRoot, "runtime", "sdk-types.json"),
1844
- join5(distRoot, "..", "sdk", "dist", "types.json")
1845
- ];
1846
- for (const path of candidates) {
1847
- if (existsSync3(path)) {
1848
- try {
1849
- return JSON.parse(readFileSync3(path, "utf8"));
1850
- } catch {}
2572
+ const entries = [];
2573
+ const warnings = [];
2574
+ const scan = scanLocal(root, { ignore: loadIgnore(root), tracked: new Set(Object.keys(manifest.files)) });
2575
+ for (const file of scan.files) {
2576
+ const type = manifest.files[file.path]?.type ?? classifyKind(basename(file.path));
2577
+ if (type === "resource") {
2578
+ const src = serverByPath.get(file.path)?.fileSrc;
2579
+ if (!src && (opts.warnMissingUrl ?? true))
2580
+ warnings.push(`No server URL for ${file.path} — push it so its URL resolves.`);
2581
+ entries.push({ path: file.path, type, fileSrc: src, absPath: file.absPath });
2582
+ } else if (type === "shader") {
2583
+ const shaders = serverByPath.get(file.path)?.shaders;
2584
+ if (!shaders?.length)
2585
+ warnings.push(`No compiled shader for ${file.path} — push it to compile the shader.`);
2586
+ entries.push({ path: file.path, type, shaders });
2587
+ } else {
2588
+ entries.push({ path: file.path, type, text: readFileSync5(file.absPath, "utf8") });
1851
2589
  }
1852
2590
  }
1853
- return null;
1854
- };
1855
- var materializeTypesLocal = (root) => {
1856
- const bundle = readLocalTypeBundle();
1857
- if (!bundle)
1858
- return false;
1859
- writeTypeBundle(root, bundle);
1860
- return true;
2591
+ return { entries, warnings };
1861
2592
  };
1862
- var writeTsconfig = (root) => {
1863
- writeFileSync4(join5(root, "tsconfig.json"), JSON.stringify(TSCONFIG, null, 2));
2593
+
2594
+ // src/compile/collectLocal.ts
2595
+ import { readFileSync as readFileSync6 } from "node:fs";
2596
+ import { basename as basename2 } from "node:path";
2597
+ var collectLocalEntries = (root, manifest, assetBase) => {
2598
+ const entries = [];
2599
+ const warnings = [];
2600
+ const scan = scanLocal(root, { ignore: loadIgnore(root), tracked: new Set(Object.keys(manifest?.files ?? {})) });
2601
+ for (const file of scan.files) {
2602
+ const type = manifest?.files[file.path]?.type ?? classifyKind(basename2(file.path));
2603
+ if (type === "resource") {
2604
+ entries.push({ path: file.path, type, fileSrc: assetBase + encodeURI(file.path), absPath: file.absPath });
2605
+ } else if (type === "shader") {
2606
+ warnings.push(`${file.path}: shaders compile on the server — push the project once for shaders to work in dev mode.`);
2607
+ entries.push({ path: file.path, type, shaders: [] });
2608
+ } else {
2609
+ entries.push({ path: file.path, type, text: readFileSync6(file.absPath, "utf8") });
2610
+ }
2611
+ }
2612
+ return { entries, warnings };
1864
2613
  };
1865
2614
 
1866
- // src/ignore.ts
1867
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "node:fs";
1868
- import { join as join6 } from "node:path";
1869
- var BUILTIN_PATTERNS = ["node_modules/", ".git/", "_*", "*.log", ".DS_Store"];
1870
- var DEFAULT_IGNORE_FILE = `# Files matched here are NOT pushed to LeCodes.
1871
- # Only untracked files are affected — assets already in the project stay tracked
1872
- # even if they match a pattern. Built-in defaults (node_modules, _*, *.log) always apply.
2615
+ // src/compile/projectCompile.ts
2616
+ import { existsSync as existsSync6 } from "node:fs";
2617
+ import { createRequire as createRequire3 } from "node:module";
2618
+ import { dirname as dirname5, join as join9 } from "node:path";
1873
2619
 
1874
- # Render / preview outputs (the default \`lecodes render --png\` target)
1875
- screenshot.png
1876
- shots/
2620
+ // src/distRoot.ts
2621
+ import { existsSync as existsSync3 } from "node:fs";
2622
+ import { dirname as dirname3, join as join6 } from "node:path";
2623
+ import { fileURLToPath } from "node:url";
2624
+ var distRoot = (() => {
2625
+ const candidates = [
2626
+ join6(dirname3(fileURLToPath(import.meta.url)), ".."),
2627
+ dirname3(process.execPath)
2628
+ ];
2629
+ for (const c2 of candidates)
2630
+ if (existsSync3(join6(c2, "runtime")))
2631
+ return c2;
2632
+ return candidates[0];
2633
+ })();
1877
2634
 
1878
- # Image & other binary assets ARE pushed by default — they're real project resources.
1879
- # Uncomment a pattern below only if you want to keep some untracked ones out of pushes.
1880
- # *.png
2635
+ // src/compile/projectCompile.ts
2636
+ init_compile();
2637
+ var vendoredInject = join9(distRoot, "runtime", "sdk", "inject.ts");
2638
+ if (existsSync6(vendoredInject))
2639
+ process.env.LECODES_SDK_INJECT = vendoredInject;
2640
+ var resolveChiselBin2 = () => {
2641
+ if (process.env.LECODES_CHISEL_BIN)
2642
+ return process.env.LECODES_CHISEL_BIN;
2643
+ try {
2644
+ const fromPkg = createRequire3(import.meta.url)("@letary/chisel").binaryPath();
2645
+ if (fromPkg)
2646
+ return fromPkg;
2647
+ } catch {}
2648
+ const local = join9(dirname5(process.execPath), process.platform === "win32" ? "chisel.exe" : "chisel");
2649
+ return existsSync6(local) ? local : null;
2650
+ };
2651
+ var chiselBin = resolveChiselBin2();
2652
+ if (chiselBin)
2653
+ process.env.LECODES_CHISEL_BIN = chiselBin;
2654
+ var compileProject2 = (opts) => compileProject({
2655
+ entries: opts.entries.map((e) => ({ path: e.path, type: e.type, text: e.text, fileSrc: e.fileSrc, remote: e.remote, shaders: e.shaders })),
2656
+ name: opts.name,
2657
+ publicUrl: opts.publicUrl,
2658
+ entryOverride: opts.entryOverride,
2659
+ header: opts.header,
2660
+ fontsHeader: opts.fontsHeader,
2661
+ format: opts.format,
2662
+ localAssets: opts.localAssets,
2663
+ minify: opts.minify,
2664
+ sourcemap: opts.sourcemap,
2665
+ editor: opts.editor
2666
+ });
1881
2667
 
1882
- # Local notes & dumps (uncomment to ignore)
1883
- # *.json
1884
- # notes.md
1885
- `;
1886
- var translate = (glob) => {
1887
- let re = "";
1888
- for (let i = 0;i < glob.length; i++) {
1889
- const ch = glob[i];
1890
- if (ch === "*") {
1891
- if (glob[i + 1] === "*") {
1892
- re += ".*";
1893
- i++;
1894
- if (glob[i + 1] === "/")
1895
- i++;
1896
- } else
1897
- re += "[^/]*";
1898
- } else if (ch === "?")
1899
- re += "[^/]";
1900
- else if (ch === "/")
1901
- re += "/";
1902
- else if ("\\^$.|+()[]{}".includes(ch))
1903
- re += "\\" + ch;
1904
- else
1905
- re += ch;
1906
- }
1907
- return re;
2668
+ // src/compile/fonts.ts
2669
+ init_compile();
2670
+ import { createHash as createHash2 } from "node:crypto";
2671
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
2672
+ import { dirname as dirname6, join as join10 } from "node:path";
2673
+ var applyProjectFonts = async (entries, opts) => {
2674
+ const registry = (opts.registry ?? fontRegistryBase()).replace(/\/+$/, "");
2675
+ const byPath = () => new Map(entries.map((e) => [e.path.replace(/^\//, ""), e]));
2676
+ const faces = await applyFontMacro(entries, {
2677
+ mode: opts.mode,
2678
+ registryBase: registry,
2679
+ readBytes: (entryPath) => {
2680
+ const entry = byPath().get(entryPath);
2681
+ const abs = entry?.absPath ?? join10(opts.root, entryPath);
2682
+ try {
2683
+ return new Uint8Array(readFileSync9(abs));
2684
+ } catch {
2685
+ return null;
2686
+ }
2687
+ },
2688
+ vendorFace: opts.mode === "vendor" ? async (face) => {
2689
+ const vendorDir = opts.vendorDir ?? opts.root;
2690
+ const rel = `assets/fonts/${face.fileName}`;
2691
+ const abs = join10(vendorDir, rel);
2692
+ const entryPath = `${opts.vendorPrefix ?? ""}/${rel}`;
2693
+ if (!existsSync7(abs))
2694
+ await downloadFace(registry, vendorDir, face, rel, abs);
2695
+ if (!byPath().has(entryPath.replace(/^\//, ""))) {
2696
+ entries.push(opts.makeResourceEntry?.(entryPath, abs) ?? { path: entryPath, type: "resource", absPath: abs });
2697
+ }
2698
+ return entryPath;
2699
+ } : undefined
2700
+ });
2701
+ if (faces.length === 0)
2702
+ return;
2703
+ return buildFontsHeaderValue(faces, (path) => {
2704
+ if (!opts.publicUrl || !/^https?:/i.test(opts.publicUrl))
2705
+ return null;
2706
+ const fileSrc = byPath().get(path.replace(/^\//, ""))?.fileSrc;
2707
+ return fileSrc ? opts.publicUrl + fileSrc : null;
2708
+ }) || undefined;
1908
2709
  };
1909
- var compile = (raw) => {
1910
- let p = raw.trim();
1911
- if (!p || p.startsWith("#"))
1912
- return null;
1913
- let negate = false;
1914
- if (p.startsWith("!")) {
1915
- negate = true;
1916
- p = p.slice(1);
1917
- }
1918
- let dirOnly = false;
1919
- if (p.endsWith("/")) {
1920
- dirOnly = true;
1921
- p = p.slice(0, -1);
2710
+ var isHttp = (s) => /^https?:/i.test(s);
2711
+ var sha12 = (b) => createHash2("sha256").update(b).digest("hex").slice(0, 12);
2712
+ var registryHome = (registry) => registry.replace(/\/v\d+$/, "");
2713
+ var readRegistry = async (registry, rel) => {
2714
+ if (isHttp(registry)) {
2715
+ const res = await fetch(`${registry}/${rel}`);
2716
+ return res.ok ? new Uint8Array(await res.arrayBuffer()) : null;
1922
2717
  }
1923
- const anchored = p.startsWith("/") || p.includes("/");
1924
- if (p.startsWith("/"))
1925
- p = p.slice(1);
1926
- if (!p)
2718
+ try {
2719
+ return new Uint8Array(readFileSync9(join10(registry, rel)));
2720
+ } catch {
1927
2721
  return null;
1928
- const body = translate(p);
1929
- const re = anchored ? new RegExp("^/" + body + "(/.*)?$") : new RegExp("(^|/)" + body + "(/.*)?$");
1930
- return { re, negate, dirOnly };
1931
- };
1932
- var buildMatcher = (rules) => (key, isDir) => {
1933
- let ignored = false;
1934
- for (const r of rules) {
1935
- if (r.dirOnly && !isDir)
1936
- continue;
1937
- if (r.re.test(key))
1938
- ignored = !r.negate;
1939
2722
  }
1940
- return ignored;
1941
2723
  };
1942
- var loadIgnore = (root) => {
1943
- const lines = [...BUILTIN_PATTERNS];
1944
- const file = join6(root, ".lecodesignore");
1945
- if (existsSync4(file)) {
1946
- try {
1947
- lines.push(...readFileSync4(file, "utf8").split(/\r?\n/));
1948
- } catch {}
2724
+ var readLock = (path) => {
2725
+ try {
2726
+ const lock = JSON.parse(readFileSync9(path, "utf8"));
2727
+ if (lock && typeof lock === "object" && lock.faces && lock.families)
2728
+ return lock;
2729
+ } catch {}
2730
+ return null;
2731
+ };
2732
+ var serializeLock = (lock) => {
2733
+ const sortKeys = (rec) => Object.fromEntries(Object.keys(rec).sort().map((k) => [k, rec[k]]));
2734
+ return JSON.stringify({ registry: lock.registry, faces: sortKeys(lock.faces), families: sortKeys(lock.families) }, null, 2) + `
2735
+ `;
2736
+ };
2737
+ var renderNotices = (lock) => {
2738
+ const ids = [...new Set(Object.values(lock.faces).map((f2) => f2.id))].sort();
2739
+ const lines = [
2740
+ "# Font attribution & notices",
2741
+ "",
2742
+ "Fonts vendored from the font registry via `font(...)`. Keep this file in your repo / product.",
2743
+ ""
2744
+ ];
2745
+ for (const id of ids) {
2746
+ const l = lock.families[id];
2747
+ if (!l)
2748
+ continue;
2749
+ let note2 = `- **${l.title || id}** — ${l.spdx}${l.url ? ` — ${l.url}` : ""}`;
2750
+ if (l.attribution)
2751
+ note2 += `
2752
+ - **Attribution required:** ${l.attribution}`;
2753
+ lines.push(note2);
1949
2754
  }
1950
- const rules = lines.map(compile).filter((r) => r !== null);
1951
- return buildMatcher(rules);
2755
+ lines.push("");
2756
+ return lines.join(`
2757
+ `);
1952
2758
  };
1953
- var writeDefaultIgnore = (root) => {
1954
- const file = join6(root, ".lecodesignore");
1955
- if (!existsSync4(file))
1956
- writeFileSync5(file, DEFAULT_IGNORE_FILE);
2759
+ var writeIfChanged = (path, text) => {
2760
+ try {
2761
+ if (readFileSync9(path, "utf8") === text)
2762
+ return;
2763
+ } catch {}
2764
+ writeFileSync5(path, text);
2765
+ };
2766
+ var downloadFace = async (registry, vendorDir, face, rel, abs) => {
2767
+ const licRaw = await readRegistry(registry, "licenses.json");
2768
+ if (!licRaw)
2769
+ throw new Error(`font: can't reach the font registry (${registry}). Check your connection, or set LECODES_FONT_REGISTRY.`);
2770
+ const licenses = JSON.parse(new TextDecoder().decode(licRaw));
2771
+ const lic = licenses[face.id];
2772
+ if (!lic)
2773
+ throw new Error(`font("${face.id}"): the registry has no license entry for this family — it may not be published yet. Browse ${registryHome(registry)}.`);
2774
+ const bytes = await readRegistry(registry, `${face.id}/${face.fileName}`);
2775
+ if (!bytes)
2776
+ throw new Error(`font("${face.id}"): face "${face.fileName}" not found in the registry (${registryHome(registry)}).`);
2777
+ mkdirSync4(dirname6(abs), { recursive: true });
2778
+ writeFileSync5(abs, bytes);
2779
+ const lockPath = join10(vendorDir, "fonts.lock.json");
2780
+ const lock = readLock(lockPath) ?? { registry, faces: {}, families: {} };
2781
+ lock.registry = registry;
2782
+ lock.faces[`${face.id}@${face.weight}${face.italic ? "i" : ""}`] = { id: face.id, family: face.family, weight: face.weight, italic: face.italic, hash: sha12(bytes), spdx: lic.spdx, path: rel };
2783
+ lock.families[face.id] = lic;
2784
+ writeIfChanged(lockPath, serializeLock(lock));
2785
+ writeIfChanged(join10(vendorDir, "FONT-NOTICES.md"), renderNotices(lock));
1957
2786
  };
1958
2787
 
1959
- // src/commands/clone.ts
1960
- var parseUuid = (input) => {
1961
- if (!input)
1962
- throw new CliError("Usage: lecodes clone <project-uuid|url> [directory]");
1963
- if (!input.includes("/"))
1964
- return input;
1965
- const clean = input.split(/[?#]/)[0].replace(/\/+$/, "");
1966
- const match = clean.match(/\/(?:projects|view)\/([^/]+)/);
1967
- return match ? match[1] : clean.slice(clean.lastIndexOf("/") + 1);
2788
+ // src/commands/compile.ts
2789
+ var compile2 = async (args) => {
2790
+ await compileImpl(args);
1968
2791
  };
1969
- var slug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
1970
- var clone = async (args) => {
1971
- const config = loadConfig();
1972
- const apiUrl = requireApiUrl(config);
1973
- const token = requireToken(config);
1974
- const uuid = parseUuid(args._[0]);
1975
- const project = await getProject(apiUrl, token, uuid);
1976
- const dir = args._[1] ?? slug(project.name);
1977
- const root = resolve(dir);
1978
- if (existsSync5(root) && readdirSync(root).length > 0) {
1979
- throw new CliError(`Directory "${dir}" already exists and is not empty.`);
2792
+ var compileShell = (args, shell) => compileImpl(args, shell);
2793
+ var compileImpl = async (args, shell) => {
2794
+ let root;
2795
+ let manifest;
2796
+ try {
2797
+ root = findProjectRoot(process.cwd());
2798
+ manifest = readManifest(root);
2799
+ } catch {
2800
+ root = process.cwd();
2801
+ manifest = null;
1980
2802
  }
1981
- mkdirSync5(root, { recursive: true });
1982
- info(c.dim(`Fetching project ${c.bold(project.name)}…`));
1983
- const files = await materializeAssets(root, project, apiUrl, token, progress);
1984
- endProgress();
1985
- if (!flagBool(args, "no-types")) {
1986
- info(c.dim("Fetching type definitions…"));
1987
- await materializeTypes(root, apiUrl);
1988
- writeTsconfig(root);
2803
+ let entries;
2804
+ let warnings;
2805
+ let name;
2806
+ let publicUrl;
2807
+ let localAssets = false;
2808
+ if (manifest) {
2809
+ const config = loadConfig();
2810
+ const apiUrl = requireApiUrl(config);
2811
+ const token = requireToken(config);
2812
+ note("Resolving project assets…");
2813
+ const project = await getProject(apiUrl, token, manifest.uuid);
2814
+ ({ entries, warnings } = collectEntries(root, manifest, project, { warnMissingUrl: !shell }));
2815
+ name = manifest.name;
2816
+ publicUrl = flagStr(args, "public-url") ?? apiUrl;
2817
+ localAssets = !!shell;
2818
+ if (shell && shell.remotePatterns.length > 0) {
2819
+ const isRemote = buildGlobMatcher(shell.remotePatterns);
2820
+ for (const e of entries) {
2821
+ if (e.type !== "resource" || !isRemote(e.path))
2822
+ continue;
2823
+ if (!e.fileSrc) {
2824
+ warnErr(`${e.path} matches resources.remote but has no server URL yet — bundling it (push, then re-sync).`);
2825
+ continue;
2826
+ }
2827
+ e.remote = true;
2828
+ }
2829
+ }
2830
+ } else {
2831
+ const publicUrlFlag = flagStr(args, "public-url");
2832
+ localAssets = shell ? true : publicUrlFlag === undefined;
2833
+ publicUrl = publicUrlFlag ?? "";
2834
+ ({ entries, warnings } = collectLocalEntries(root, null, ""));
2835
+ name = basename3(root);
2836
+ if (shell && shell.remotePatterns.length > 0) {
2837
+ warnErr("resources.remote needs a server-backed project (a streamed asset needs a server URL) — bundling everything.");
2838
+ }
1989
2839
  }
1990
- writeDefaultIgnore(root);
1991
- const commits = await getCommits(apiUrl, token, uuid);
1992
- writeManifest(root, { uuid, apiUrl, name: project.name, baseHeadId: commits.headId, files });
1993
- success(`Cloned ${c.bold(project.name)} into ${c.bold(dir)}/ (${Object.keys(files).length} files)`);
1994
- info(c.dim(` cd ${dir} — edit, then \`lecodes push -m "your message"\``));
2840
+ const fontsHeader = await applyProjectFonts(entries, manifest && !shell ? { mode: "cdn", root, publicUrl } : {
2841
+ mode: "vendor",
2842
+ root,
2843
+ vendorDir: root,
2844
+ vendorPrefix: "",
2845
+ publicUrl: shell ? "" : publicUrl,
2846
+ makeResourceEntry: (path, absPath) => ({ path, type: "resource", fileSrc: encodeURI(path), absPath })
2847
+ });
2848
+ note("Compiling…");
2849
+ const js = await compileProject2({
2850
+ entries,
2851
+ name,
2852
+ publicUrl,
2853
+ entryOverride: flagStr(args, "entry"),
2854
+ header: !flagBool(args, "no-header"),
2855
+ localAssets,
2856
+ fontsHeader,
2857
+ minify: flagBool(args, "no-minify") ? false : undefined,
2858
+ sourcemap: flagBool(args, "no-sourcemap", "no-source-map") ? false : undefined
2859
+ });
2860
+ for (const w of warnings)
2861
+ warnErr(w);
2862
+ const out = flagStr(args, "o", "out");
2863
+ if (out && isNullSink(out)) {
2864
+ note(`Discarded output (${out})`);
2865
+ } else if (out) {
2866
+ writeFileSync6(resolve(out), js);
2867
+ note(`Wrote ${out} (${js.length} bytes)`);
2868
+ } else {
2869
+ process.stdout.write(js);
2870
+ if (!js.endsWith(`
2871
+ `))
2872
+ process.stdout.write(`
2873
+ `);
2874
+ }
2875
+ return entries.filter((e) => e.type === "resource").map((e) => ({
2876
+ path: e.path.replace(/^\//, ""),
2877
+ name: e.path.split(/[\\/]/).pop() ?? e.path,
2878
+ absPath: e.absPath,
2879
+ remote: !!e.remote
2880
+ }));
2881
+ };
2882
+ var previewLegacy = async (args) => {
2883
+ warnErr("'lecodes preview' is now 'lecodes compile' — the old name still works.");
2884
+ await compile2(args);
1995
2885
  };
1996
2886
 
1997
- // src/commands/compile.ts
1998
- import { writeFileSync as writeFileSync6 } from "node:fs";
1999
- import { basename as basename3, resolve as resolve2 } from "node:path";
2887
+ // src/commands/appTemplates.ts
2888
+ var pbxStr = (s) => s.replace(/[\\"]/g, (m) => "\\" + m);
2889
+ var pbxprojTemplate = ({ displayName, bundleId, teamId, version, buildNumber }) => {
2890
+ const target = `
2891
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
2892
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
2893
+ CODE_SIGN_STYLE = Automatic;
2894
+ CURRENT_PROJECT_VERSION = ${buildNumber};
2895
+ DEVELOPMENT_TEAM = "${pbxStr(teamId)}";
2896
+ GENERATE_INFOPLIST_FILE = YES;
2897
+ INFOPLIST_FILE = "App/Info.plist";
2898
+ INFOPLIST_KEY_CFBundleDisplayName = "${pbxStr(displayName)}";
2899
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
2900
+ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
2901
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
2902
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
2903
+ IPHONEOS_DEPLOYMENT_TARGET = 15.6;
2904
+ LD_RUNPATH_SEARCH_PATHS = (
2905
+ "$(inherited)",
2906
+ "@executable_path/Frameworks",
2907
+ );
2908
+ MARKETING_VERSION = ${version};
2909
+ PRODUCT_BUNDLE_IDENTIFIER = "${pbxStr(bundleId)}";
2910
+ PRODUCT_NAME = "$(TARGET_NAME)";
2911
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
2912
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
2913
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
2914
+ SWIFT_EMIT_LOC_STRINGS = YES;
2915
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
2916
+ SWIFT_VERSION = 5.0;
2917
+ TARGETED_DEVICE_FAMILY = "1,2";`;
2918
+ const project = (debug) => `
2919
+ ALWAYS_SEARCH_USER_PATHS = NO;
2920
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
2921
+ CLANG_ANALYZER_NONNULL = YES;
2922
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
2923
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
2924
+ CLANG_ENABLE_MODULES = YES;
2925
+ CLANG_ENABLE_OBJC_ARC = YES;
2926
+ CLANG_ENABLE_OBJC_WEAK = YES;
2927
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
2928
+ CLANG_WARN_BOOL_CONVERSION = YES;
2929
+ CLANG_WARN_COMMA = YES;
2930
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
2931
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
2932
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
2933
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
2934
+ CLANG_WARN_EMPTY_BODY = YES;
2935
+ CLANG_WARN_ENUM_CONVERSION = YES;
2936
+ CLANG_WARN_INFINITE_RECURSION = YES;
2937
+ CLANG_WARN_INT_CONVERSION = YES;
2938
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
2939
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
2940
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
2941
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
2942
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
2943
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
2944
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
2945
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
2946
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
2947
+ CLANG_WARN_UNREACHABLE_CODE = YES;
2948
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
2949
+ COPY_PHASE_STRIP = NO;
2950
+ DEBUG_INFORMATION_FORMAT = ${debug ? "dwarf" : '"dwarf-with-dsym"'};
2951
+ DEVELOPMENT_TEAM = "${pbxStr(teamId)}";${debug ? "" : `
2952
+ ENABLE_NS_ASSERTIONS = NO;`}
2953
+ ENABLE_STRICT_OBJC_MSGSEND = YES;${debug ? `
2954
+ ENABLE_TESTABILITY = YES;` : ""}
2955
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
2956
+ GCC_C_LANGUAGE_STANDARD = gnu17;${debug ? `
2957
+ GCC_DYNAMIC_NO_PIC = NO;` : ""}
2958
+ GCC_NO_COMMON_BLOCKS = YES;${debug ? `
2959
+ GCC_OPTIMIZATION_LEVEL = 0;
2960
+ GCC_PREPROCESSOR_DEFINITIONS = (
2961
+ "DEBUG=1",
2962
+ "$(inherited)",
2963
+ );` : ""}
2964
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
2965
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
2966
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
2967
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
2968
+ GCC_WARN_UNUSED_FUNCTION = YES;
2969
+ GCC_WARN_UNUSED_VARIABLE = YES;
2970
+ IPHONEOS_DEPLOYMENT_TARGET = 15.6;
2971
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
2972
+ MTL_ENABLE_DEBUG_INFO = ${debug ? "INCLUDE_SOURCE" : "NO"};
2973
+ MTL_FAST_MATH = YES;${debug ? `
2974
+ ONLY_ACTIVE_ARCH = YES;` : ""}
2975
+ SDKROOT = iphoneos;${debug ? `
2976
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
2977
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";` : `
2978
+ SWIFT_COMPILATION_MODE = wholemodule;
2979
+ VALIDATE_PRODUCT = YES;`}`;
2980
+ return `// !$*UTF8*$!
2981
+ {
2982
+ archiveVersion = 1;
2983
+ classes = {
2984
+ };
2985
+ objectVersion = 77;
2986
+ objects = {
2987
+
2988
+ /* Begin PBXBuildFile section */
2989
+ B73550812FEBAC710005B4CC /* LeCodesRuntime in Frameworks */ = {isa = PBXBuildFile; productRef = B73550802FEBAC710005B4CC /* LeCodesRuntime */; };
2990
+ /* End PBXBuildFile section */
2991
+
2992
+ /* Begin PBXFileReference section */
2993
+ B73550672FEBAC0F0005B4CC /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
2994
+ /* End PBXFileReference section */
2995
+
2996
+ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
2997
+ B73550792FEBAC100005B4CC /* Exceptions for "App" folder in "App" target */ = {
2998
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
2999
+ membershipExceptions = (
3000
+ Info.plist,
3001
+ );
3002
+ target = B73550662FEBAC0F0005B4CC /* App */;
3003
+ };
3004
+ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
3005
+
3006
+ /* Begin PBXFileSystemSynchronizedRootGroup section */
3007
+ B73550692FEBAC0F0005B4CC /* App */ = {
3008
+ isa = PBXFileSystemSynchronizedRootGroup;
3009
+ exceptions = (
3010
+ B73550792FEBAC100005B4CC /* Exceptions for "App" folder in "App" target */,
3011
+ );
3012
+ path = App;
3013
+ sourceTree = "<group>";
3014
+ };
3015
+ /* End PBXFileSystemSynchronizedRootGroup section */
3016
+
3017
+ /* Begin PBXFrameworksBuildPhase section */
3018
+ B73550642FEBAC0F0005B4CC /* Frameworks */ = {
3019
+ isa = PBXFrameworksBuildPhase;
3020
+ buildActionMask = 2147483647;
3021
+ files = (
3022
+ B73550812FEBAC710005B4CC /* LeCodesRuntime in Frameworks */,
3023
+ );
3024
+ runOnlyForDeploymentPostprocessing = 0;
3025
+ };
3026
+ /* End PBXFrameworksBuildPhase section */
3027
+
3028
+ /* Begin PBXGroup section */
3029
+ B735505E2FEBAC0F0005B4CC = {
3030
+ isa = PBXGroup;
3031
+ children = (
3032
+ B73550692FEBAC0F0005B4CC /* App */,
3033
+ B73550682FEBAC0F0005B4CC /* Products */,
3034
+ );
3035
+ sourceTree = "<group>";
3036
+ };
3037
+ B73550682FEBAC0F0005B4CC /* Products */ = {
3038
+ isa = PBXGroup;
3039
+ children = (
3040
+ B73550672FEBAC0F0005B4CC /* App.app */,
3041
+ );
3042
+ name = Products;
3043
+ sourceTree = "<group>";
3044
+ };
3045
+ /* End PBXGroup section */
3046
+
3047
+ /* Begin PBXNativeTarget section */
3048
+ B73550662FEBAC0F0005B4CC /* App */ = {
3049
+ isa = PBXNativeTarget;
3050
+ buildConfigurationList = B735507A2FEBAC100005B4CC /* Build configuration list for PBXNativeTarget "App" */;
3051
+ buildPhases = (
3052
+ B73550632FEBAC0F0005B4CC /* Sources */,
3053
+ B73550642FEBAC0F0005B4CC /* Frameworks */,
3054
+ B73550652FEBAC0F0005B4CC /* Resources */,
3055
+ );
3056
+ buildRules = (
3057
+ );
3058
+ dependencies = (
3059
+ );
3060
+ fileSystemSynchronizedGroups = (
3061
+ B73550692FEBAC0F0005B4CC /* App */,
3062
+ );
3063
+ name = App;
3064
+ packageProductDependencies = (
3065
+ B73550802FEBAC710005B4CC /* LeCodesRuntime */,
3066
+ );
3067
+ productName = App;
3068
+ productReference = B73550672FEBAC0F0005B4CC /* App.app */;
3069
+ productType = "com.apple.product-type.application";
3070
+ };
3071
+ /* End PBXNativeTarget section */
3072
+
3073
+ /* Begin PBXProject section */
3074
+ B735505F2FEBAC0F0005B4CC /* Project object */ = {
3075
+ isa = PBXProject;
3076
+ attributes = {
3077
+ BuildIndependentTargetsInParallel = 1;
3078
+ LastSwiftUpdateCheck = 2650;
3079
+ LastUpgradeCheck = 2650;
3080
+ TargetAttributes = {
3081
+ B73550662FEBAC0F0005B4CC = {
3082
+ CreatedOnToolsVersion = 26.5;
3083
+ };
3084
+ };
3085
+ };
3086
+ buildConfigurationList = B73550622FEBAC0F0005B4CC /* Build configuration list for PBXProject "App" */;
3087
+ developmentRegion = en;
3088
+ hasScannedForEncodings = 0;
3089
+ knownRegions = (
3090
+ en,
3091
+ Base,
3092
+ );
3093
+ mainGroup = B735505E2FEBAC0F0005B4CC;
3094
+ minimizedProjectReferenceProxies = 1;
3095
+ packageReferences = (
3096
+ B73550822FEBAC720005B4CC /* XCLocalSwiftPackageReference "LeCodesRuntime" */,
3097
+ );
3098
+ preferredProjectObjectVersion = 77;
3099
+ productRefGroup = B73550682FEBAC0F0005B4CC /* Products */;
3100
+ projectDirPath = "";
3101
+ projectRoot = "";
3102
+ targets = (
3103
+ B73550662FEBAC0F0005B4CC /* App */,
3104
+ );
3105
+ };
3106
+ /* End PBXProject section */
3107
+
3108
+ /* Begin PBXResourcesBuildPhase section */
3109
+ B73550652FEBAC0F0005B4CC /* Resources */ = {
3110
+ isa = PBXResourcesBuildPhase;
3111
+ buildActionMask = 2147483647;
3112
+ files = (
3113
+ );
3114
+ runOnlyForDeploymentPostprocessing = 0;
3115
+ };
3116
+ /* End PBXResourcesBuildPhase section */
3117
+
3118
+ /* Begin PBXSourcesBuildPhase section */
3119
+ B73550632FEBAC0F0005B4CC /* Sources */ = {
3120
+ isa = PBXSourcesBuildPhase;
3121
+ buildActionMask = 2147483647;
3122
+ files = (
3123
+ );
3124
+ runOnlyForDeploymentPostprocessing = 0;
3125
+ };
3126
+ /* End PBXSourcesBuildPhase section */
3127
+
3128
+ /* Begin XCBuildConfiguration section */
3129
+ B735507B2FEBAC100005B4CC /* Debug */ = {
3130
+ isa = XCBuildConfiguration;
3131
+ buildSettings = {${target}
3132
+ };
3133
+ name = Debug;
3134
+ };
3135
+ B735507C2FEBAC100005B4CC /* Release */ = {
3136
+ isa = XCBuildConfiguration;
3137
+ buildSettings = {${target}
3138
+ };
3139
+ name = Release;
3140
+ };
3141
+ B735507D2FEBAC100005B4CC /* Debug */ = {
3142
+ isa = XCBuildConfiguration;
3143
+ buildSettings = {${project(true)}
3144
+ };
3145
+ name = Debug;
3146
+ };
3147
+ B735507E2FEBAC100005B4CC /* Release */ = {
3148
+ isa = XCBuildConfiguration;
3149
+ buildSettings = {${project(false)}
3150
+ };
3151
+ name = Release;
3152
+ };
3153
+ /* End XCBuildConfiguration section */
3154
+
3155
+ /* Begin XCConfigurationList section */
3156
+ B73550622FEBAC0F0005B4CC /* Build configuration list for PBXProject "App" */ = {
3157
+ isa = XCConfigurationList;
3158
+ buildConfigurations = (
3159
+ B735507D2FEBAC100005B4CC /* Debug */,
3160
+ B735507E2FEBAC100005B4CC /* Release */,
3161
+ );
3162
+ defaultConfigurationIsVisible = 0;
3163
+ defaultConfigurationName = Release;
3164
+ };
3165
+ B735507A2FEBAC100005B4CC /* Build configuration list for PBXNativeTarget "App" */ = {
3166
+ isa = XCConfigurationList;
3167
+ buildConfigurations = (
3168
+ B735507B2FEBAC100005B4CC /* Debug */,
3169
+ B735507C2FEBAC100005B4CC /* Release */,
3170
+ );
3171
+ defaultConfigurationIsVisible = 0;
3172
+ defaultConfigurationName = Release;
3173
+ };
3174
+ /* End XCConfigurationList section */
3175
+
3176
+ /* Begin XCLocalSwiftPackageReference section */
3177
+ B73550822FEBAC720005B4CC /* XCLocalSwiftPackageReference "LeCodesRuntime" */ = {
3178
+ isa = XCLocalSwiftPackageReference;
3179
+ relativePath = LeCodesRuntime;
3180
+ };
3181
+ /* End XCLocalSwiftPackageReference section */
3182
+
3183
+ /* Begin XCSwiftPackageProductDependency section */
3184
+ B73550802FEBAC710005B4CC /* LeCodesRuntime */ = {
3185
+ isa = XCSwiftPackageProductDependency;
3186
+ productName = LeCodesRuntime;
3187
+ };
3188
+ /* End XCSwiftPackageProductDependency section */
3189
+ };
3190
+ rootObject = B735505F2FEBAC0F0005B4CC /* Project object */;
3191
+ }
3192
+ `;
3193
+ };
3194
+ var SCHEME_XML = `<?xml version="1.0" encoding="UTF-8"?>
3195
+ <Scheme
3196
+ LastUpgradeVersion = "2650"
3197
+ version = "1.7">
3198
+ <BuildAction
3199
+ parallelizeBuildables = "YES"
3200
+ buildImplicitDependencies = "YES">
3201
+ <BuildActionEntries>
3202
+ <BuildActionEntry
3203
+ buildForTesting = "YES"
3204
+ buildForRunning = "YES"
3205
+ buildForProfiling = "YES"
3206
+ buildForArchiving = "YES"
3207
+ buildForAnalyzing = "YES">
3208
+ <BuildableReference
3209
+ BuildableIdentifier = "primary"
3210
+ BlueprintIdentifier = "B73550662FEBAC0F0005B4CC"
3211
+ BuildableName = "App.app"
3212
+ BlueprintName = "App"
3213
+ ReferencedContainer = "container:App.xcodeproj">
3214
+ </BuildableReference>
3215
+ </BuildActionEntry>
3216
+ </BuildActionEntries>
3217
+ </BuildAction>
3218
+ <TestAction
3219
+ buildConfiguration = "Debug"
3220
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
3221
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
3222
+ shouldUseLaunchSchemeArgsEnv = "YES">
3223
+ <Testables>
3224
+ </Testables>
3225
+ </TestAction>
3226
+ <LaunchAction
3227
+ buildConfiguration = "Debug"
3228
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
3229
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
3230
+ launchStyle = "0"
3231
+ useCustomWorkingDirectory = "NO"
3232
+ ignoresPersistentStateOnLaunch = "NO"
3233
+ debugDocumentVersioning = "YES"
3234
+ debugServiceExtension = "internal"
3235
+ allowLocationSimulation = "YES">
3236
+ <BuildableProductRunnable
3237
+ runnableDebuggingMode = "0">
3238
+ <BuildableReference
3239
+ BuildableIdentifier = "primary"
3240
+ BlueprintIdentifier = "B73550662FEBAC0F0005B4CC"
3241
+ BuildableName = "App.app"
3242
+ BlueprintName = "App"
3243
+ ReferencedContainer = "container:App.xcodeproj">
3244
+ </BuildableReference>
3245
+ </BuildableProductRunnable>
3246
+ </LaunchAction>
3247
+ <ProfileAction
3248
+ buildConfiguration = "Release"
3249
+ shouldUseLaunchSchemeArgsEnv = "YES"
3250
+ savedToolIdentifier = ""
3251
+ useCustomWorkingDirectory = "NO"
3252
+ debugDocumentVersioning = "YES">
3253
+ <BuildableProductRunnable
3254
+ runnableDebuggingMode = "0">
3255
+ <BuildableReference
3256
+ BuildableIdentifier = "primary"
3257
+ BlueprintIdentifier = "B73550662FEBAC0F0005B4CC"
3258
+ BuildableName = "App.app"
3259
+ BlueprintName = "App"
3260
+ ReferencedContainer = "container:App.xcodeproj">
3261
+ </BuildableReference>
3262
+ </BuildableProductRunnable>
3263
+ </ProfileAction>
3264
+ <AnalyzeAction
3265
+ buildConfiguration = "Debug">
3266
+ </AnalyzeAction>
3267
+ <ArchiveAction
3268
+ buildConfiguration = "Release"
3269
+ revealArchiveInOrganizer = "YES">
3270
+ </ArchiveAction>
3271
+ </Scheme>
3272
+ `;
3273
+ var APP_DELEGATE = `//
3274
+ // AppDelegate.swift
3275
+ // Generated by lecodes app init — yours to edit.
3276
+ //
2000
3277
 
2001
- // src/compile/collect.ts
2002
- import { readFileSync as readFileSync6 } from "node:fs";
2003
- import { basename } from "node:path";
3278
+ import UIKit
2004
3279
 
2005
- // src/localFiles.ts
2006
- import { readdirSync as readdirSync2, readFileSync as readFileSync5 } from "node:fs";
2007
- import { join as join7, relative } from "node:path";
2008
- var SKIP_DIRS = new Set([".lecodes", ".git", "node_modules"]);
2009
- var SKIP_FILES = new Set(["tsconfig.json", "jsconfig.json", ".gitignore", ".lecodesignore", ".DS_Store"]);
2010
- var TEXT_EXTS = new Set([
2011
- "ts",
2012
- "tsx",
2013
- "js",
2014
- "jsx",
2015
- "mjs",
2016
- "cjs",
2017
- "json",
2018
- "svg",
2019
- "glsl",
2020
- "vert",
2021
- "frag",
2022
- "txt",
2023
- "md",
2024
- "css",
2025
- "html",
2026
- "xml",
2027
- "yml",
2028
- "yaml",
2029
- "csv"
2030
- ]);
2031
- var ext = (name) => {
2032
- const i = name.lastIndexOf(".");
2033
- return i < 0 ? "" : name.slice(i + 1).toLowerCase();
2034
- };
2035
- var classifyKind = (name) => {
2036
- const e = ext(name);
2037
- if (e === "mat")
2038
- return "shader";
2039
- if (e === "scene")
2040
- return "scene";
2041
- return TEXT_EXTS.has(e) ? "text" : "resource";
2042
- };
2043
- var isTextKind = (kind) => kind !== "resource";
2044
- var scanLocal = (root, opts = {}) => {
2045
- const { ignore, tracked } = opts;
2046
- const files = [];
2047
- const dirs = [];
2048
- const ignored = [];
2049
- const isTracked = (key) => tracked?.has(key) ?? false;
2050
- const hasTrackedUnder = (key) => {
2051
- if (!tracked)
2052
- return false;
2053
- const prefix = key + "/";
2054
- for (const t of tracked)
2055
- if (t.startsWith(prefix))
2056
- return true;
2057
- return false;
2058
- };
2059
- const walk = (dir, parentIgnored) => {
2060
- for (const entry of readdirSync2(dir, { withFileTypes: true })) {
2061
- const abs = join7(dir, entry.name);
2062
- const key = toKey(relative(root, abs));
2063
- if (entry.isDirectory()) {
2064
- if (SKIP_DIRS.has(entry.name))
2065
- continue;
2066
- const dirIgnored = parentIgnored || !!ignore && ignore(key, true);
2067
- if (dirIgnored && !isTracked(key) && !hasTrackedUnder(key)) {
2068
- ignored.push(key + "/");
2069
- continue;
3280
+ @main
3281
+ class AppDelegate: UIResponder, UIApplicationDelegate {
3282
+
3283
+ func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
3284
+ return true
3285
+ }
3286
+
3287
+ // MARK: UISceneSession Lifecycle
3288
+
3289
+ func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
3290
+ return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
3291
+ }
3292
+ }
3293
+ `;
3294
+ var SCENE_DELEGATE = `//
3295
+ // SceneDelegate.swift
3296
+ // Generated by lecodes app init — yours to edit.
3297
+ //
3298
+
3299
+ import UIKit
3300
+
3301
+ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
3302
+
3303
+ var window: UIWindow?
3304
+
3305
+ func scene(
3306
+ _ scene: UIScene,
3307
+ willConnectTo session: UISceneSession,
3308
+ options connectionOptions: UIScene.ConnectionOptions
3309
+ ) {
3310
+ guard let windowScene = (scene as? UIWindowScene) else { return }
3311
+
3312
+ let window = UIWindow(windowScene: windowScene)
3313
+ window.rootViewController = ViewController()
3314
+ self.window = window
3315
+ window.makeKeyAndVisible()
3316
+ }
3317
+ }
3318
+ `;
3319
+ var VIEW_CONTROLLER = `//
3320
+ // ViewController.swift
3321
+ // Generated by lecodes app init — yours to edit.
3322
+ //
3323
+ // Runs the bundled app.js (produced by \`lecodes app sync\`) offline: everything the
3324
+ // script needs is provided by the SDK core (_creatorUI / _creatorUtils / console)
3325
+ // plus the plugins registered below via LeCodesRuntime.
3326
+ //
3327
+
3328
+ import UIKit
3329
+ import LeCodesRuntime
3330
+
3331
+ class ViewController: LeCodesViewController {
3332
+
3333
+ override func viewDidLoad() {
3334
+ super.viewDidLoad()
3335
+
3336
+ let lang = Locale.preferredLanguages.first.map { String($0.prefix(2)) } ?? "en"
3337
+ engine.setLanguage(lang)
3338
+
3339
+ // \`// preload:\` assets (images, fonts) resolve out of the app bundle.
3340
+ engine.setFetchLocal { path in
3341
+ let ns = path as NSString
3342
+ let ext = ns.pathExtension
3343
+ let name = ns.deletingPathExtension
3344
+ guard let url = Bundle.main.url(forResource: name, withExtension: ext.isEmpty ? nil : ext) else {
3345
+ return nil
3346
+ }
3347
+ return try? Data(contentsOf: url)
2070
3348
  }
2071
- dirs.push(key);
2072
- walk(abs, dirIgnored);
2073
- } else if (entry.isFile()) {
2074
- if (SKIP_FILES.has(entry.name))
2075
- continue;
2076
- const fileIgnored = parentIgnored || !!ignore && ignore(key, false);
2077
- if (fileIgnored && !isTracked(key)) {
2078
- ignored.push(key);
2079
- continue;
3349
+
3350
+ registerBundledPlugins(in: engine)
3351
+
3352
+ guard let url = Bundle.main.url(forResource: "app", withExtension: "js"),
3353
+ let code = try? String(contentsOf: url, encoding: .utf8) else {
3354
+ print("LeCodes: bundled app.js is missing — run \`lecodes app sync\`")
3355
+ return
2080
3356
  }
2081
- files.push({ path: key, absPath: abs, sha: sha256(readFileSync5(abs)) });
2082
- }
3357
+ engine.run(code)
2083
3358
  }
2084
- };
2085
- walk(root, false);
2086
- return { files, dirs, ignored };
3359
+ }
3360
+ `;
3361
+ var infoPlistTemplate = (pluginKeys) => {
3362
+ const keys = Object.keys(pluginKeys).sort();
3363
+ const pluginBlock = keys.length === 0 ? "" : `
3364
+ <!-- lecodes:plugins — merged from installed plugin manifests by \`lecodes app sync\` -->
3365
+ ${keys.map((k) => ` <key>${k}</key>
3366
+ <string>${pluginKeys[k]}</string>`).join(`
3367
+ `)}`;
3368
+ return `<?xml version="1.0" encoding="UTF-8"?>
3369
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3370
+ <plist version="1.0">
3371
+ <dict>
3372
+ <key>UIApplicationSceneManifest</key>
3373
+ <dict>
3374
+ <key>UIApplicationSupportsMultipleScenes</key>
3375
+ <false/>
3376
+ <key>UISceneConfigurations</key>
3377
+ <dict>
3378
+ <key>UIWindowSceneSessionRoleApplication</key>
3379
+ <array>
3380
+ <dict>
3381
+ <key>UISceneConfigurationName</key>
3382
+ <string>Default Configuration</string>
3383
+ <key>UISceneDelegateClassName</key>
3384
+ <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
3385
+ </dict>
3386
+ </array>
3387
+ </dict>
3388
+ </dict>${pluginBlock}
3389
+ </dict>
3390
+ </plist>
3391
+ `;
2087
3392
  };
2088
- var scanProject = (root, manifest) => scanLocal(root, { ignore: loadIgnore(root), tracked: new Set(Object.keys(manifest.files)) });
2089
- var diffLocal = (manifest, scan) => {
2090
- const added = [];
2091
- const modified = [];
2092
- const localByPath = new Map(scan.files.map((f) => [f.path, f]));
2093
- for (const file of scan.files) {
2094
- const known = manifest.files[file.path];
2095
- if (!known)
2096
- added.push(file);
2097
- else if (known.sha !== file.sha)
2098
- modified.push(file);
3393
+ var LAUNCH_STORYBOARD = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
3394
+ <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
3395
+ <dependencies>
3396
+ <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
3397
+ <capability name="Safe area layout guides" minToolsVersion="9.0"/>
3398
+ <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
3399
+ </dependencies>
3400
+ <scenes>
3401
+ <!--View Controller-->
3402
+ <scene sceneID="EHf-IW-A2E">
3403
+ <objects>
3404
+ <viewController id="01J-lp-oVM" sceneMemberID="viewController">
3405
+ <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
3406
+ <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
3407
+ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
3408
+ <color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
3409
+ <viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
3410
+ </view>
3411
+ </viewController>
3412
+ <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
3413
+ </objects>
3414
+ <point key="canvasLocation" x="53" y="375"/>
3415
+ </scene>
3416
+ </scenes>
3417
+ </document>
3418
+ `;
3419
+ var ASSETS_ROOT_JSON = `{
3420
+ "info" : {
3421
+ "author" : "xcode",
3422
+ "version" : 1
2099
3423
  }
2100
- const deleted = Object.keys(manifest.files).filter((path) => !localByPath.has(path));
2101
- const moves = new Map;
2102
- const claimed = new Set;
2103
- for (const file of added) {
2104
- const from = deleted.find((path) => !claimed.has(path) && manifest.files[path].sha === file.sha);
2105
- if (from) {
2106
- moves.set(file.path, from);
2107
- claimed.add(from);
3424
+ }
3425
+ `;
3426
+ var ACCENT_COLOR_JSON = `{
3427
+ "colors" : [
3428
+ {
3429
+ "idiom" : "universal"
2108
3430
  }
3431
+ ],
3432
+ "info" : {
3433
+ "author" : "xcode",
3434
+ "version" : 1
2109
3435
  }
2110
- return { added, modified, deleted, moves };
3436
+ }
3437
+ `;
3438
+ var APP_ICON_JSON = `{
3439
+ "images" : [
3440
+ {
3441
+ "idiom" : "universal",
3442
+ "platform" : "ios",
3443
+ "size" : "1024x1024"
3444
+ },
3445
+ {
3446
+ "appearances" : [
3447
+ {
3448
+ "appearance" : "luminosity",
3449
+ "value" : "dark"
3450
+ }
3451
+ ],
3452
+ "idiom" : "universal",
3453
+ "platform" : "ios",
3454
+ "size" : "1024x1024"
3455
+ },
3456
+ {
3457
+ "appearances" : [
3458
+ {
3459
+ "appearance" : "luminosity",
3460
+ "value" : "tinted"
3461
+ }
3462
+ ],
3463
+ "idiom" : "universal",
3464
+ "platform" : "ios",
3465
+ "size" : "1024x1024"
3466
+ }
3467
+ ],
3468
+ "info" : {
3469
+ "author" : "xcode",
3470
+ "version" : 1
3471
+ }
3472
+ }
3473
+ `;
3474
+ var runtimePackageTemplate = (sdkVersion, variant, plugins) => {
3475
+ const product = `LeCodesSDK-${variant}`;
3476
+ const pluginTargets = plugins.map((p) => `
3477
+ // plugin: ${p.id} (vendored — see plugins.lock.json)
3478
+ .target(
3479
+ name: "${p.module}",
3480
+ dependencies: [.product(name: "${product}", package: "lecodes-ios-sdk")],
3481
+ path: "Sources/${p.module}"
3482
+ ),`).join("");
3483
+ const runtimeDeps = [`.product(name: "${product}", package: "lecodes-ios-sdk")`, ...plugins.map((p) => `"${p.module}"`)];
3484
+ return `// swift-tools-version:5.9
3485
+ //
3486
+ // GENERATED by lecodes — regenerated by \`lecodes app sync\`; do not edit.
3487
+ // Owns the app's native dependency graph: the LeCodesSDK variant (app.json ios.variant)
3488
+ // and one vendored target per installed plugin (see plugins.lock.json).
3489
+
3490
+ import PackageDescription
3491
+
3492
+ let package = Package(
3493
+ name: "LeCodesRuntime",
3494
+ platforms: [.iOS(.v15)],
3495
+ products: [
3496
+ .library(name: "LeCodesRuntime", targets: ["LeCodesRuntime"]),
3497
+ ],
3498
+ dependencies: [
3499
+ .package(url: "https://github.com/letary/lecodes-ios-sdk.git", from: "${sdkVersion}"),
3500
+ ],
3501
+ targets: [
3502
+ .target(
3503
+ name: "LeCodesRuntime",
3504
+ dependencies: [
3505
+ ${runtimeDeps.join(`,
3506
+ `)},
3507
+ ],
3508
+ path: "Sources/LeCodesRuntime"
3509
+ ),${pluginTargets}
3510
+ ]
3511
+ )
3512
+ `;
2111
3513
  };
2112
- var hasChanges = (diff) => diff.added.length + diff.modified.length + diff.deleted.length > 0;
3514
+ var runtimeSwiftTemplate = (plugins) => {
3515
+ const imports = plugins.map((p) => `import ${p.module}
3516
+ `).join("");
3517
+ const calls = plugins.length === 0 ? `
3518
+ ` : plugins.map((p) => ` ${p.register}.register(in: engine)
3519
+ `).join("");
3520
+ return `//
3521
+ // LeCodesRuntime.swift
3522
+ // GENERATED by lecodes — regenerated by \`lecodes app sync\`; do not edit.
3523
+ //
3524
+ // Re-exports the SDK (so app code needs only \`import LeCodesRuntime\`) and registers
3525
+ // every plugin installed in app.json.
3526
+ //
3527
+
3528
+ @_exported import LeCodesSDK
3529
+ ${imports}
3530
+ public func registerBundledPlugins(in engine: LeCodesEngine) {
3531
+ ${calls}}
3532
+ `;
3533
+ };
3534
+ var IOS_README = `# ios/ — native Xcode shell
2113
3535
 
2114
- // src/compile/collect.ts
2115
- var collectEntries = (root, manifest, project) => {
2116
- const pathById = buildPaths(project.assets);
2117
- const serverByPath = new Map;
2118
- for (const asset of project.assets) {
2119
- if (asset.type === "folder")
2120
- continue;
2121
- serverByPath.set(pathById.get(asset.id), { fileSrc: asset.file?.src ?? undefined, shaders: asset.shaders ?? [] });
3536
+ Generated by \`lecodes app init ios\` for the LeCodes project one folder up. The app runs the
3537
+ compiled bundle in \`App/Resources/app.js\` — fully offline, nothing fetched at launch. This
3538
+ folder is ignored by the platform (\`ios/\` in \`.lecodesignore\`) and is never pushed to LeCodes.
3539
+
3540
+ ## Daily workflow
3541
+
3542
+ Edit the app's code at the project root (\`main.ts\` …), then:
3543
+
3544
+ \`\`\`
3545
+ lecodes app sync # recompile app.js + assets into the shell, vendor plugins
3546
+ lecodes app open # open App.xcodeproj in Xcode — build & run from there
3547
+ \`\`\`
3548
+
3549
+ \`lecodes app doctor\` preflights builds; \`lecodes app build ios\` archives and exports an
3550
+ .ipa; \`lecodes app upload ios\` sends the archive to App Store Connect.
3551
+
3552
+ ## Who owns what
3553
+
3554
+ Yours — \`lecodes app sync\` never touches these:
3555
+
3556
+ - \`App/*.swift\` (AppDelegate, SceneDelegate, ViewController) — customize freely, add files.
3557
+ App-local native code goes here too: add a Swift file that calls
3558
+ \`engine.registerCallback\` / \`registerService\`, and invoke it from ViewController.
3559
+ - \`App/Assets.xcassets/\` — drop a 1024×1024 PNG into AppIcon.appiconset/ for the app icon.
3560
+ - \`App/Base.lproj/LaunchScreen.storyboard\`
3561
+
3562
+ CLI-owned — regenerated on every sync, hand edits are overwritten:
3563
+
3564
+ - \`App/Resources/\` — the compiled app.js + every asset it references (fonts included), so
3565
+ the app runs fully offline. Globs in app.json \`"resources": {"remote": [...]}\` opt heavy
3566
+ assets (videos, big models) back into streaming from the server instead.
3567
+ - \`App/Info.plist\` — template + the installed plugins' usage keys
3568
+ - \`LeCodesRuntime/\` — local Swift package: the LeCodesSDK dependency, vendored plugin
3569
+ sources (Sources/<Module>/), and the registerBundledPlugins entry point
3570
+ - the version values inside \`App.xcodeproj/project.pbxproj\` (from app.json)
3571
+ - this README
3572
+
3573
+ ## Config
3574
+
3575
+ Identity lives in \`../app.json\` (name, version, plugins, ios.bundleId / teamId / variant /
3576
+ buildNumber) — edit it there, then re-run \`lecodes app sync\`. Plugins are managed with
3577
+ \`lecodes app plugin add <id | github:owner/repo[@tag]>\` and pinned in \`../plugins.lock.json\`.
3578
+ `;
3579
+
3580
+ // src/commands/app.ts
3581
+ var SDK_PACKAGE_VERSION = "1.0.4";
3582
+ var FIRST_PARTY_PLUGINS = {
3583
+ camera: "letary/lecodes-plugins",
3584
+ "qr-scanner": "letary/lecodes-plugins"
3585
+ };
3586
+ var readJson = (path) => {
3587
+ if (!existsSync8(path))
3588
+ return null;
3589
+ try {
3590
+ return JSON.parse(readFileSync10(path, "utf8"));
3591
+ } catch {
3592
+ throw new CliError(`Invalid JSON in ${path}`);
2122
3593
  }
2123
- const entries = [];
2124
- const warnings = [];
2125
- for (const file of scanLocal(root).files) {
2126
- const type = manifest.files[file.path]?.type ?? classifyKind(basename(file.path));
2127
- if (type === "resource") {
2128
- const src = serverByPath.get(file.path)?.fileSrc;
2129
- if (!src)
2130
- warnings.push(`No server URL for ${file.path} — push it so its URL resolves.`);
2131
- entries.push({ path: file.path, type, fileSrc: src, absPath: file.absPath });
2132
- } else if (type === "shader") {
2133
- const shaders = serverByPath.get(file.path)?.shaders;
2134
- if (!shaders?.length)
2135
- warnings.push(`No compiled shader for ${file.path} — push it to compile the shader.`);
2136
- entries.push({ path: file.path, type, shaders });
2137
- } else {
2138
- entries.push({ path: file.path, type, text: readFileSync6(file.absPath, "utf8") });
3594
+ };
3595
+ var writeJson = (path, value) => writeFileSync7(path, JSON.stringify(value, null, 2) + `
3596
+ `);
3597
+ var projectRoot = () => {
3598
+ try {
3599
+ return findProjectRoot(process.cwd());
3600
+ } catch {
3601
+ return process.cwd();
3602
+ }
3603
+ };
3604
+ var git = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
3605
+ var ensureRepo = (repo, wantTag, lock) => {
3606
+ const override = process.env.LECODES_PLUGINS_DIR;
3607
+ if (override) {
3608
+ note(`Using local plugin checkout ${override} (LECODES_PLUGINS_DIR)`);
3609
+ return { dir: resolve2(override), tag: "local", commit: "local" };
3610
+ }
3611
+ const cache = join11(homedir2(), ".lecodes", "plugins", repo.replace("/", "__"));
3612
+ const url = `https://github.com/${repo}.git`;
3613
+ if (!existsSync8(join11(cache, ".git"))) {
3614
+ mkdirSync5(dirname7(cache), { recursive: true });
3615
+ note(`Cloning ${url}…`);
3616
+ git(dirname7(cache), "clone", "--quiet", url, cache);
3617
+ }
3618
+ let tag = lock?.tag ?? wantTag;
3619
+ let commit = lock?.commit;
3620
+ if (commit) {
3621
+ try {
3622
+ git(cache, "cat-file", "-e", `${commit}^{commit}`);
3623
+ } catch {
3624
+ git(cache, "fetch", "--quiet", "--tags", "origin");
3625
+ }
3626
+ } else {
3627
+ git(cache, "fetch", "--quiet", "--tags", "origin");
3628
+ if (!tag) {
3629
+ tag = git(cache, "tag", "--list", "v*", "--sort=-version:refname").split(`
3630
+ `)[0] ?? "";
3631
+ if (!tag)
3632
+ throw new CliError(`${repo} has no v* version tags to install from`);
3633
+ }
3634
+ commit = git(cache, "rev-list", "-n", "1", tag);
3635
+ }
3636
+ git(cache, "checkout", "--quiet", "--detach", commit);
3637
+ return { dir: cache, tag: tag ?? "unknown", commit };
3638
+ };
3639
+ var resolvePlugin = (id, spec, lock) => {
3640
+ let repo;
3641
+ let subdir;
3642
+ let wantTag = null;
3643
+ if (spec.startsWith("github:")) {
3644
+ const [repoPart, tagPart] = spec.slice("github:".length).split("@");
3645
+ repo = repoPart;
3646
+ wantTag = tagPart ?? null;
3647
+ subdir = "";
3648
+ } else {
3649
+ repo = FIRST_PARTY_PLUGINS[id];
3650
+ if (!repo) {
3651
+ throw new CliError(`Unknown plugin "${id}" — known: ${Object.keys(FIRST_PARTY_PLUGINS).join(", ")}, or use a github:owner/repo spec.`);
3652
+ }
3653
+ subdir = `plugins/${id}`;
3654
+ }
3655
+ const checkout = ensureRepo(repo, wantTag, lock?.repo === repo ? lock : undefined);
3656
+ const dir = subdir ? join11(checkout.dir, subdir) : checkout.dir;
3657
+ const manifest = readJson(join11(dir, "lecodes-plugin.json"));
3658
+ if (!manifest)
3659
+ throw new CliError(`Plugin "${id}": no lecodes-plugin.json in ${dir}`);
3660
+ if (manifest.id !== id)
3661
+ throw new CliError(`Plugin "${id}": manifest declares id "${manifest.id}"`);
3662
+ if (!manifest.ios)
3663
+ throw new CliError(`Plugin "${id}" does not support iOS (manifest "ios" is null)`);
3664
+ if (spec.startsWith("github:")) {
3665
+ const names = [...manifest.provides?.views ?? [], ...manifest.provides?.services ?? []];
3666
+ for (const name of names) {
3667
+ if (!name.includes("."))
3668
+ warn(`Plugin "${id}" provides bare channel name "${name}" — third-party names should be vendor-prefixed (e.g. "acme.${name}").`);
2139
3669
  }
2140
3670
  }
2141
- return { entries, warnings };
3671
+ return {
3672
+ id,
3673
+ dir,
3674
+ manifest,
3675
+ lock: { repo, tag: checkout.tag, commit: checkout.commit, version: manifest.version ?? "unknown" }
3676
+ };
2142
3677
  };
2143
-
2144
- // src/compile/collectLocal.ts
2145
- import { readFileSync as readFileSync7 } from "node:fs";
2146
- import { basename as basename2 } from "node:path";
2147
- var collectLocalEntries = (root, manifest, assetBase) => {
2148
- const entries = [];
2149
- const warnings = [];
2150
- const scan = scanLocal(root, { ignore: loadIgnore(root), tracked: new Set(Object.keys(manifest?.files ?? {})) });
2151
- for (const file of scan.files) {
2152
- const type = manifest?.files[file.path]?.type ?? classifyKind(basename2(file.path));
2153
- if (type === "resource") {
2154
- entries.push({ path: file.path, type, fileSrc: assetBase + encodeURI(file.path), absPath: file.absPath });
2155
- } else if (type === "shader") {
2156
- warnings.push(`${file.path}: shaders compile on the server — push the project once for shaders to work in dev mode.`);
2157
- entries.push({ path: file.path, type, shaders: [] });
3678
+ var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "app";
3679
+ var appInit = async (args) => {
3680
+ const platform = args._[1];
3681
+ if (platform !== "ios") {
3682
+ throw new CliError(platform ? `Unsupported platform "${platform}" — only "ios" for now.` : "Usage: lecodes app init ios [--name <display name>] [--bundle-id <id>] [--team <team id>] [--variant core|full]");
3683
+ }
3684
+ const root = projectRoot();
3685
+ const iosDir = join11(root, "ios");
3686
+ if (existsSync8(iosDir)) {
3687
+ throw new CliError("ios/ already exists — run `lecodes app sync` to update it.");
3688
+ }
3689
+ const existing = readJson(join11(root, "app.json")) ?? {};
3690
+ const variant = flagStr(args, "variant") ?? existing.ios?.variant ?? "core";
3691
+ if (variant !== "core" && variant !== "full")
3692
+ throw new CliError(`--variant must be "core" or "full" (got "${variant}")`);
3693
+ const name = flagStr(args, "name") ?? existing.name ?? basename4(root);
3694
+ const config = {
3695
+ ...existing,
3696
+ name,
3697
+ version: existing.version ?? "1.0.0",
3698
+ plugins: existing.plugins ?? {},
3699
+ ios: {
3700
+ bundleId: flagStr(args, "bundle-id") ?? existing.ios?.bundleId ?? `com.example.${slugify(name)}`,
3701
+ teamId: flagStr(args, "team") ?? existing.ios?.teamId ?? "",
3702
+ variant,
3703
+ buildNumber: existing.ios?.buildNumber ?? 1
3704
+ }
3705
+ };
3706
+ writeJson(join11(root, "app.json"), config);
3707
+ log(`${c.green("written")} app.json`);
3708
+ const ignorePath = join11(root, ".lecodesignore");
3709
+ const ignore = existsSync8(ignorePath) ? readFileSync10(ignorePath, "utf8") : DEFAULT_IGNORE_FILE;
3710
+ if (!/^ios\/$/m.test(ignore)) {
3711
+ writeFileSync7(ignorePath, ignore.replace(/\n*$/, `
3712
+ `) + `
3713
+ # Native app shells — generated by \`lecodes app init\`, never pushed to LeCodes.
3714
+ ios/
3715
+ `);
3716
+ log(`${c.green("updated")} .lecodesignore (+ ios/)`);
3717
+ }
3718
+ const identity = {
3719
+ displayName: name,
3720
+ bundleId: config.ios.bundleId,
3721
+ teamId: config.ios.teamId,
3722
+ version: config.version,
3723
+ buildNumber: config.ios.buildNumber
3724
+ };
3725
+ const write = (rel, content) => {
3726
+ const abs = join11(iosDir, rel);
3727
+ mkdirSync5(dirname7(abs), { recursive: true });
3728
+ writeFileSync7(abs, content);
3729
+ log(`${c.green("created")} ios/${rel}`);
3730
+ };
3731
+ write("README.md", IOS_README);
3732
+ write("App.xcodeproj/project.pbxproj", pbxprojTemplate(identity));
3733
+ write("App.xcodeproj/xcshareddata/xcschemes/App.xcscheme", SCHEME_XML);
3734
+ write("App/AppDelegate.swift", APP_DELEGATE);
3735
+ write("App/SceneDelegate.swift", SCENE_DELEGATE);
3736
+ write("App/ViewController.swift", VIEW_CONTROLLER);
3737
+ write("App/Info.plist", infoPlistTemplate({}));
3738
+ write("App/Base.lproj/LaunchScreen.storyboard", LAUNCH_STORYBOARD);
3739
+ write("App/Assets.xcassets/Contents.json", ASSETS_ROOT_JSON);
3740
+ write("App/Assets.xcassets/AccentColor.colorset/Contents.json", ACCENT_COLOR_JSON);
3741
+ write("App/Assets.xcassets/AppIcon.appiconset/Contents.json", APP_ICON_JSON);
3742
+ write("LeCodesRuntime/Package.swift", runtimePackageTemplate(SDK_PACKAGE_VERSION, variant, []));
3743
+ write("LeCodesRuntime/Sources/LeCodesRuntime/LeCodesRuntime.swift", runtimeSwiftTemplate([]));
3744
+ log("");
3745
+ success(`iOS shell for ${c.bold(name)} ready.`);
3746
+ log(` ${c.bold("lecodes app sync")} embed the compiled bundle (+ plugins) into the shell`);
3747
+ log(` ${c.bold("open ios/App.xcodeproj")} then run on your device from Xcode`);
3748
+ log(` App icon: drop a 1024×1024 PNG into ios/App/Assets.xcassets/AppIcon.appiconset/`);
3749
+ };
3750
+ var appSync = async (_args) => {
3751
+ const root = projectRoot();
3752
+ const config = readJson(join11(root, "app.json"));
3753
+ const iosDir = join11(root, "ios");
3754
+ if (!config?.ios || !existsSync8(iosDir)) {
3755
+ throw new CliError("No iOS shell here — run `lecodes app init ios` first.");
3756
+ }
3757
+ const variant = config.ios.variant === "full" ? "full" : "core";
3758
+ const lockPath = join11(root, "plugins.lock.json");
3759
+ const oldLock = readJson(lockPath)?.plugins ?? {};
3760
+ const pluginSpecs = Object.entries(config.plugins ?? {});
3761
+ const runtimePlugins = [];
3762
+ const infoValues = {};
3763
+ const newLock = {};
3764
+ const sourcesDir = join11(iosDir, "LeCodesRuntime", "Sources");
3765
+ for (const [id, spec] of pluginSpecs) {
3766
+ const plugin = resolvePlugin(id, spec, oldLock[id]);
3767
+ const ios = plugin.manifest.ios;
3768
+ const module = ios.module;
3769
+ const register = ios.register;
3770
+ if (!module || !register)
3771
+ throw new CliError(`Plugin "${id}": manifest ios.module / ios.register missing`);
3772
+ const src = join11(plugin.dir, ios.sources ?? "ios");
3773
+ if (!existsSync8(src))
3774
+ throw new CliError(`Plugin "${id}": sources dir ${src} missing`);
3775
+ const dest = join11(sourcesDir, module);
3776
+ rmSync(dest, { recursive: true, force: true });
3777
+ cpSync(src, dest, { recursive: true });
3778
+ for (const [key, value] of Object.entries(ios.infoPlist ?? {})) {
3779
+ (infoValues[key] ??= []).push(value);
3780
+ }
3781
+ runtimePlugins.push({ id, module, register });
3782
+ newLock[id] = plugin.lock;
3783
+ log(`${c.green("plugin")} ${id}@${plugin.lock.version} (${plugin.lock.tag} → Sources/${module})`);
3784
+ }
3785
+ const keep = new Set(["LeCodesRuntime", ...runtimePlugins.map((p) => p.module)]);
3786
+ for (const entry of existsSync8(sourcesDir) ? readdirSync4(sourcesDir) : []) {
3787
+ if (!keep.has(entry) && statSync2(join11(sourcesDir, entry)).isDirectory()) {
3788
+ rmSync(join11(sourcesDir, entry), { recursive: true });
3789
+ log(`${c.yellow("removed")} ios/LeCodesRuntime/Sources/${entry} (plugin uninstalled)`);
3790
+ }
3791
+ }
3792
+ writeFileSync7(join11(iosDir, "README.md"), IOS_README);
3793
+ writeFileSync7(join11(iosDir, "LeCodesRuntime", "Package.swift"), runtimePackageTemplate(SDK_PACKAGE_VERSION, variant, runtimePlugins));
3794
+ writeFileSync7(join11(sourcesDir, "LeCodesRuntime", "LeCodesRuntime.swift"), runtimeSwiftTemplate(runtimePlugins));
3795
+ const mergedKeys = {};
3796
+ for (const [key, values] of Object.entries(infoValues))
3797
+ mergedKeys[key] = [...new Set(values)].join(" ");
3798
+ writeFileSync7(join11(iosDir, "App", "Info.plist"), infoPlistTemplate(mergedKeys));
3799
+ if (pluginSpecs.length > 0 || existsSync8(lockPath))
3800
+ writeJson(lockPath, { plugins: newLock });
3801
+ const pbxPath = join11(iosDir, "App.xcodeproj", "project.pbxproj");
3802
+ const pbx = readFileSync10(pbxPath, "utf8").replace(/MARKETING_VERSION = [^;]+;/g, `MARKETING_VERSION = ${config.version ?? "1.0.0"};`).replace(/CURRENT_PROJECT_VERSION = [^;]+;/g, `CURRENT_PROJECT_VERSION = ${config.ios.buildNumber ?? 1};`);
3803
+ writeFileSync7(pbxPath, pbx);
3804
+ const remotePatterns = config.resources?.remote ?? [];
3805
+ if (!Array.isArray(remotePatterns) || remotePatterns.some((p) => typeof p !== "string")) {
3806
+ throw new CliError('app.json "resources.remote" must be an array of glob strings (e.g. ["videos/**"]).');
3807
+ }
3808
+ const resourcesDir = join11(iosDir, "App", "Resources");
3809
+ rmSync(resourcesDir, { recursive: true, force: true });
3810
+ mkdirSync5(resourcesDir, { recursive: true });
3811
+ const resources = await compileShell({ _: [], flags: { o: join11(resourcesDir, "app.js") } }, { remotePatterns });
3812
+ const bundleJs = readFileSync10(join11(resourcesDir, "app.js"), "utf8");
3813
+ const preload = /^\/\/ preload: (.*)$/m.exec(bundleJs)?.[1].split(";").map((s) => s.trim()).filter(Boolean) ?? [];
3814
+ const IMPLICIT_PRELOADS = new Set(["uberarchive", "capture"]);
3815
+ const byName = new Map;
3816
+ for (const r of resources) {
3817
+ if (r.remote || !r.absPath)
3818
+ continue;
3819
+ const list = byName.get(r.name) ?? [];
3820
+ list.push(r);
3821
+ byName.set(r.name, list);
3822
+ }
3823
+ let embedded = 0;
3824
+ for (const name of preload) {
3825
+ if (IMPLICIT_PRELOADS.has(name))
3826
+ continue;
3827
+ const candidates = byName.get(name) ?? [];
3828
+ if (candidates.length === 0) {
3829
+ warn(`bundle needs local asset "${name}" but no project file provides it — it won't load on device`);
3830
+ continue;
3831
+ }
3832
+ if (candidates.length > 1)
3833
+ warn(`asset name collision: ${name} (${candidates.map((r) => r.path).join(", ")}) — embedding ${candidates[0].path}`);
3834
+ cpSync(candidates[0].absPath, join11(resourcesDir, name));
3835
+ embedded++;
3836
+ }
3837
+ if (embedded > 0)
3838
+ log(`${c.green("assets")} ${embedded} file(s) embedded → ios/App/Resources/`);
3839
+ const streamed = resources.filter((r) => r.remote);
3840
+ if (streamed.length > 0)
3841
+ log(`${c.green("remote")} ${streamed.length} resource(s) stay on the server (resources.remote): ${streamed.map((r) => r.path).join(", ")}`);
3842
+ log("");
3843
+ success(`Shell synced: app.js + ${runtimePlugins.length} plugin(s), variant ${variant}, v${config.version ?? "1.0.0"} (${config.ios.buildNumber ?? 1}).`);
3844
+ note("Build & run from Xcode (open ios/App.xcodeproj) — user files in ios/App/*.swift were not touched.");
3845
+ };
3846
+ var appPlugin = async (args) => {
3847
+ const sub = args._[1];
3848
+ const root = projectRoot();
3849
+ const configPath = join11(root, "app.json");
3850
+ const config = readJson(configPath);
3851
+ if (!config)
3852
+ throw new CliError("No app.json here — run `lecodes app init ios` first.");
3853
+ config.plugins ??= {};
3854
+ if (sub === "list" || sub === undefined) {
3855
+ const entries = Object.entries(config.plugins);
3856
+ if (entries.length === 0) {
3857
+ log("No plugins installed.");
3858
+ return;
3859
+ }
3860
+ const lock = readJson(join11(root, "plugins.lock.json"))?.plugins ?? {};
3861
+ for (const [id2, spec] of entries) {
3862
+ const pin = lock[id2] ? c.dim(` ${lock[id2].repo} ${lock[id2].tag} (${lock[id2].commit.slice(0, 7)})`) : c.dim(" (not synced)");
3863
+ log(`${c.bold(id2)} ${spec}${pin}`);
3864
+ }
3865
+ return;
3866
+ }
3867
+ const id = args._[2];
3868
+ if (!id)
3869
+ throw new CliError(`Usage: lecodes app plugin ${sub} <id | github:owner/repo[@tag]>`);
3870
+ if (sub === "add") {
3871
+ if (id.startsWith("github:")) {
3872
+ const bare = id.slice("github:".length).split("@")[0].split("/").pop();
3873
+ config.plugins[bare] = id;
3874
+ log(`${c.green("added")} ${bare} → ${id} ${c.dim("(third-party native code — review it before shipping)")}`);
3875
+ } else if (FIRST_PARTY_PLUGINS[id]) {
3876
+ config.plugins[id] = "*";
3877
+ log(`${c.green("added")} ${id}`);
2158
3878
  } else {
2159
- entries.push({ path: file.path, type, text: readFileSync7(file.absPath, "utf8") });
3879
+ throw new CliError(`Unknown plugin "${id}" — known: ${Object.keys(FIRST_PARTY_PLUGINS).join(", ")}, or use github:owner/repo[@tag].`);
2160
3880
  }
3881
+ writeJson(configPath, config);
3882
+ note("Run `lecodes app sync` to vendor it into the shell.");
3883
+ return;
2161
3884
  }
2162
- return { entries, warnings };
3885
+ if (sub === "remove") {
3886
+ if (!(id in config.plugins))
3887
+ throw new CliError(`Plugin "${id}" is not installed.`);
3888
+ delete config.plugins[id];
3889
+ writeJson(configPath, config);
3890
+ log(`${c.yellow("removed")} ${id}`);
3891
+ note("Run `lecodes app sync` to drop its sources from the shell.");
3892
+ return;
3893
+ }
3894
+ throw new CliError(`Unknown subcommand "plugin ${sub}" — use list, add or remove.`);
2163
3895
  };
2164
-
2165
- // src/compile/projectCompile.ts
2166
- import { existsSync as existsSync8 } from "node:fs";
2167
- import { createRequire as createRequire3 } from "node:module";
2168
- import { dirname as dirname6, join as join10 } from "node:path";
2169
- init_compile();
2170
- var vendoredInject = join10(distRoot, "runtime", "sdk", "inject.ts");
2171
- if (existsSync8(vendoredInject))
2172
- process.env.LECODES_SDK_INJECT = vendoredInject;
2173
- var resolveChiselBin2 = () => {
2174
- if (process.env.LECODES_CHISEL_BIN)
2175
- return process.env.LECODES_CHISEL_BIN;
3896
+ var runTool = (cwd, cmd, args) => {
2176
3897
  try {
2177
- const fromPkg = createRequire3(import.meta.url)("@letary/chisel").binaryPath();
2178
- if (fromPkg)
2179
- return fromPkg;
3898
+ return execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 128 * 1024 * 1024 });
3899
+ } catch (e) {
3900
+ const err = e;
3901
+ const tail = ((err.stdout ?? "") + `
3902
+ ` + (err.stderr ?? "")).split(`
3903
+ `).filter((l) => l.trim()).slice(-25).join(`
3904
+ `);
3905
+ throw new CliError(`${cmd} ${args[0] ?? ""} failed:
3906
+ ${tail}`);
3907
+ }
3908
+ };
3909
+ var exportOptionsPlist = (method, teamId, destination) => `<?xml version="1.0" encoding="UTF-8"?>
3910
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3911
+ <plist version="1.0">
3912
+ <dict>
3913
+ <key>method</key>
3914
+ <string>${method}</string>
3915
+ <key>signingStyle</key>
3916
+ <string>automatic</string>${teamId ? `
3917
+ <key>teamID</key>
3918
+ <string>${teamId}</string>` : ""}${destination ? `
3919
+ <key>destination</key>
3920
+ <string>${destination}</string>` : ""}
3921
+ </dict>
3922
+ </plist>
3923
+ `;
3924
+ var requireShell = () => {
3925
+ const root = projectRoot();
3926
+ const config = readJson(join11(root, "app.json"));
3927
+ if (!config?.ios || !existsSync8(join11(root, "ios"))) {
3928
+ throw new CliError("No iOS shell here — run `lecodes app init ios` first.");
3929
+ }
3930
+ return { root, config };
3931
+ };
3932
+ var appDoctor = async (_args) => {
3933
+ const { root, config } = requireShell();
3934
+ let failed = 0;
3935
+ const check = (ok, label, hint = "", fatal = true) => {
3936
+ log(`${ok ? c.green("✓") : fatal ? c.red("✗") : c.yellow("!")} ${label}${ok || !hint ? "" : c.dim(" — " + hint)}`);
3937
+ if (!ok && fatal)
3938
+ failed++;
3939
+ };
3940
+ let xcodeVersion = "";
3941
+ try {
3942
+ xcodeVersion = runTool(root, "xcodebuild", ["-version"]).split(`
3943
+ `)[0];
2180
3944
  } catch {}
2181
- const local = join10(dirname6(process.execPath), process.platform === "win32" ? "chisel.exe" : "chisel");
2182
- return existsSync8(local) ? local : null;
3945
+ check(xcodeVersion !== "", `Xcode CLI (${xcodeVersion || "not found"})`, "install Xcode + command line tools");
3946
+ check(!!config.ios.teamId, "Development team set", "lecodes app init wrote app.json — fill ios.teamId", false);
3947
+ const iconDir = join11(root, "ios", "App", "Assets.xcassets", "AppIcon.appiconset");
3948
+ const hasIcon = existsSync8(iconDir) && readdirSync4(iconDir).some((f2) => f2.endsWith(".png"));
3949
+ check(hasIcon, "App icon present", "drop a 1024×1024 PNG into ios/App/Assets.xcassets/AppIcon.appiconset/ (App Store requires it)", false);
3950
+ const bundle = join11(root, "ios", "App", "Resources", "app.js");
3951
+ check(existsSync8(bundle), "Bundled app.js", "run `lecodes app sync`");
3952
+ const lock = readJson(join11(root, "plugins.lock.json"))?.plugins ?? {};
3953
+ const wanted = Object.keys(config.plugins ?? {});
3954
+ const inSync = wanted.every((id) => lock[id]) && Object.keys(lock).every((id) => wanted.includes(id));
3955
+ check(wanted.length === 0 || inSync, "Plugins synced", "run `lecodes app sync`");
3956
+ const asc = readJson(join11(homedir2(), ".lecodes", "asc.json"));
3957
+ check(!!asc?.keyPath, "App Store Connect API key configured", "pass --key-path/--key-id/--issuer-id to `lecodes app upload` once to store it", false);
3958
+ log("");
3959
+ if (failed > 0)
3960
+ throw new CliError(`${failed} check(s) failed.`);
3961
+ success("Ready to build.");
3962
+ };
3963
+ var ARCHIVE_PATH = "ios/build/App.xcarchive";
3964
+ var appBuild = async (args) => {
3965
+ const { root, config } = requireShell();
3966
+ const configuration = flagStr(args, "configuration") ?? "Release";
3967
+ const method = flagStr(args, "method") ?? "app-store-connect";
3968
+ if (!flagBool(args, "no-sync"))
3969
+ await appSync({ _: [], flags: {} });
3970
+ note(`Archiving (${configuration})… this takes a few minutes`);
3971
+ runTool(root, "xcodebuild", [
3972
+ "-project",
3973
+ "ios/App.xcodeproj",
3974
+ "-scheme",
3975
+ "App",
3976
+ "-destination",
3977
+ "generic/platform=iOS",
3978
+ "-configuration",
3979
+ configuration,
3980
+ "archive",
3981
+ "-archivePath",
3982
+ ARCHIVE_PATH,
3983
+ "-allowProvisioningUpdates"
3984
+ ]);
3985
+ success(`Archive: ios/build/App.xcarchive`);
3986
+ if (flagBool(args, "no-export"))
3987
+ return;
3988
+ const optionsPath = join11(root, "ios", "build", "ExportOptions.plist");
3989
+ writeFileSync7(optionsPath, exportOptionsPlist(method, config.ios.teamId ?? ""));
3990
+ note(`Exporting (.ipa, method ${method})…`);
3991
+ runTool(root, "xcodebuild", [
3992
+ "-exportArchive",
3993
+ "-archivePath",
3994
+ ARCHIVE_PATH,
3995
+ "-exportOptionsPlist",
3996
+ optionsPath,
3997
+ "-exportPath",
3998
+ "ios/build/export",
3999
+ "-allowProvisioningUpdates"
4000
+ ]);
4001
+ success("Export: ios/build/export/ — upload with `lecodes app upload ios`");
4002
+ };
4003
+ var appUpload = async (args) => {
4004
+ const { root, config } = requireShell();
4005
+ if (!existsSync8(join11(root, ARCHIVE_PATH))) {
4006
+ throw new CliError("No archive at ios/build/App.xcarchive — run `lecodes app build ios` first.");
4007
+ }
4008
+ const ascPath = join11(homedir2(), ".lecodes", "asc.json");
4009
+ const stored = readJson(ascPath) ?? {};
4010
+ const keyPath = flagStr(args, "key-path") ?? stored.keyPath;
4011
+ const keyId = flagStr(args, "key-id") ?? stored.keyId;
4012
+ const issuerId = flagStr(args, "issuer-id") ?? stored.issuerId;
4013
+ if (!keyPath || !keyId || !issuerId) {
4014
+ throw new CliError(`App Store Connect API key needed: --key-path <AuthKey.p8> --key-id <id> --issuer-id <id>
4015
+ (create one at appstoreconnect.apple.com → Users and Access → Integrations; stored in ~/.lecodes/asc.json after first use)`);
4016
+ }
4017
+ if (!existsSync8(keyPath))
4018
+ throw new CliError(`API key file not found: ${keyPath}`);
4019
+ if (flagStr(args, "key-path")) {
4020
+ mkdirSync5(dirname7(ascPath), { recursive: true });
4021
+ writeJson(ascPath, { keyPath, keyId, issuerId });
4022
+ note("Stored the key reference in ~/.lecodes/asc.json");
4023
+ }
4024
+ const optionsPath = join11(root, "ios", "build", "UploadOptions.plist");
4025
+ writeFileSync7(optionsPath, exportOptionsPlist("app-store-connect", config.ios.teamId ?? "", "upload"));
4026
+ note("Uploading to App Store Connect… (processing continues on Apple's side)");
4027
+ runTool(root, "xcodebuild", [
4028
+ "-exportArchive",
4029
+ "-archivePath",
4030
+ ARCHIVE_PATH,
4031
+ "-exportOptionsPlist",
4032
+ optionsPath,
4033
+ "-exportPath",
4034
+ "ios/build/upload",
4035
+ "-authenticationKeyPath",
4036
+ resolve2(keyPath),
4037
+ "-authenticationKeyID",
4038
+ keyId,
4039
+ "-authenticationKeyIssuerID",
4040
+ issuerId,
4041
+ "-allowProvisioningUpdates"
4042
+ ]);
4043
+ const configPath = join11(root, "app.json");
4044
+ const fresh = readJson(configPath);
4045
+ fresh.ios.buildNumber = (fresh.ios.buildNumber ?? 1) + 1;
4046
+ writeJson(configPath, fresh);
4047
+ success(`Uploaded build ${(fresh.ios.buildNumber ?? 2) - 1} — check App Store Connect / TestFlight once processing finishes.`);
4048
+ note(`ios.buildNumber bumped to ${fresh.ios.buildNumber} for the next build.`);
4049
+ };
4050
+ var appOpen = async (args) => {
4051
+ const root = projectRoot();
4052
+ const platform = args._[1] ?? "ios";
4053
+ if (platform === "ios") {
4054
+ const proj = join11(root, "ios", "App.xcodeproj");
4055
+ if (!existsSync8(proj))
4056
+ throw new CliError("No iOS shell here — run `lecodes app init ios` first.");
4057
+ if (process.platform !== "darwin")
4058
+ throw new CliError("Opening Xcode requires macOS.");
4059
+ execFileSync("open", [proj], { stdio: "ignore" });
4060
+ success("Opened ios/App.xcodeproj in Xcode.");
4061
+ return;
4062
+ }
4063
+ if (platform === "android") {
4064
+ throw new CliError("Android shells don't exist yet — `lecodes app open` covers ios for now.");
4065
+ }
4066
+ throw new CliError(`Unknown platform "${platform}" — use ios.`);
4067
+ };
4068
+ var app = async (args) => {
4069
+ const sub = args._[0];
4070
+ if (sub === "init")
4071
+ return appInit(args);
4072
+ if (sub === "sync")
4073
+ return appSync(args);
4074
+ if (sub === "plugin")
4075
+ return appPlugin(args);
4076
+ if (sub === "doctor")
4077
+ return appDoctor(args);
4078
+ if (sub === "build")
4079
+ return appBuild(args);
4080
+ if (sub === "upload")
4081
+ return appUpload(args);
4082
+ if (sub === "open")
4083
+ return appOpen(args);
4084
+ throw new CliError("Usage: lecodes app <init ios | sync | open | plugin list|add|remove | doctor | build ios | upload ios> — see `lecodes help`.");
2183
4085
  };
2184
- var chiselBin = resolveChiselBin2();
2185
- if (chiselBin)
2186
- process.env.LECODES_CHISEL_BIN = chiselBin;
2187
- var compileProject2 = (opts) => compileProject({
2188
- entries: opts.entries.map((e) => ({ path: e.path, type: e.type, text: e.text, fileSrc: e.fileSrc, shaders: e.shaders })),
2189
- name: opts.name,
2190
- publicUrl: opts.publicUrl,
2191
- entryOverride: opts.entryOverride,
2192
- header: opts.header,
2193
- format: opts.format,
2194
- localAssets: opts.localAssets,
2195
- minify: opts.minify,
2196
- sourcemap: opts.sourcemap,
2197
- editor: opts.editor
2198
- });
2199
4086
 
2200
- // src/commands/compile.ts
2201
- var compile2 = async (args) => {
2202
- let root;
2203
- let manifest;
2204
- try {
2205
- root = findProjectRoot(process.cwd());
2206
- manifest = readManifest(root);
2207
- } catch {
2208
- root = process.cwd();
2209
- manifest = null;
2210
- }
2211
- let entries;
2212
- let warnings;
2213
- let name;
2214
- let publicUrl;
2215
- let localAssets = false;
2216
- if (manifest) {
2217
- const config = loadConfig();
2218
- const apiUrl = requireApiUrl(config);
2219
- const token = requireToken(config);
2220
- note("Resolving project assets…");
2221
- const project = await getProject(apiUrl, token, manifest.uuid);
2222
- ({ entries, warnings } = collectEntries(root, manifest, project));
2223
- name = manifest.name;
2224
- publicUrl = flagStr(args, "public-url") ?? apiUrl;
2225
- } else {
2226
- const publicUrlFlag = flagStr(args, "public-url");
2227
- localAssets = publicUrlFlag === undefined;
2228
- publicUrl = publicUrlFlag ?? "";
2229
- ({ entries, warnings } = collectLocalEntries(root, null, publicUrl));
2230
- name = basename3(root);
4087
+ // src/commands/clone.ts
4088
+ import { existsSync as existsSync10, mkdirSync as mkdirSync7, readdirSync as readdirSync5 } from "node:fs";
4089
+ import { resolve as resolve3 } from "node:path";
4090
+
4091
+ // src/types.ts
4092
+ import { dirname as dirname8, join as join12 } from "node:path";
4093
+ import { existsSync as existsSync9, mkdirSync as mkdirSync6, readFileSync as readFileSync11, writeFileSync as writeFileSync8 } from "node:fs";
4094
+ var TSCONFIG = {
4095
+ compilerOptions: {
4096
+ target: "ES2020",
4097
+ module: "ESNext",
4098
+ moduleResolution: "bundler",
4099
+ lib: ["ES2020"],
4100
+ types: [],
4101
+ allowJs: true,
4102
+ checkJs: false,
4103
+ strict: false,
4104
+ noEmit: true,
4105
+ skipLibCheck: true
4106
+ },
4107
+ include: [`${LECODES_DIR}/types/**/*.d.ts`, "**/*.ts", "**/*.js"],
4108
+ exclude: ["node_modules"]
4109
+ };
4110
+ var writeTypeBundle = (root, bundle) => {
4111
+ const typesDir = join12(root, LECODES_DIR, "types");
4112
+ for (const [path, text] of Object.entries(bundle)) {
4113
+ const dest = join12(typesDir, path);
4114
+ mkdirSync6(dirname8(dest), { recursive: true });
4115
+ writeFileSync8(dest, text);
2231
4116
  }
2232
- note("Compiling…");
2233
- const js = await compileProject2({
2234
- entries,
2235
- name,
2236
- publicUrl,
2237
- entryOverride: flagStr(args, "entry"),
2238
- header: !flagBool(args, "no-header"),
2239
- localAssets,
2240
- minify: flagBool(args, "no-minify") ? false : undefined,
2241
- sourcemap: flagBool(args, "no-sourcemap", "no-source-map") ? false : undefined
2242
- });
2243
- for (const w of warnings)
2244
- warnErr(w);
2245
- const out = flagStr(args, "o", "out");
2246
- if (out && isNullSink(out)) {
2247
- note(`Discarded output (${out})`);
2248
- } else if (out) {
2249
- writeFileSync6(resolve2(out), js);
2250
- note(`Wrote ${out} (${js.length} bytes)`);
2251
- } else {
2252
- process.stdout.write(js);
2253
- if (!js.endsWith(`
2254
- `))
2255
- process.stdout.write(`
2256
- `);
4117
+ };
4118
+ var materializeTypes = async (root, apiUrl) => {
4119
+ writeTypeBundle(root, await getTypes(apiUrl));
4120
+ };
4121
+ var readLocalTypeBundle = () => {
4122
+ const candidates = [
4123
+ join12(distRoot, "runtime", "sdk-types.json"),
4124
+ join12(distRoot, "..", "sdk", "dist", "types.json")
4125
+ ];
4126
+ for (const path of candidates) {
4127
+ if (existsSync9(path)) {
4128
+ try {
4129
+ return JSON.parse(readFileSync11(path, "utf8"));
4130
+ } catch {}
4131
+ }
2257
4132
  }
4133
+ return null;
2258
4134
  };
2259
- var previewLegacy = async (args) => {
2260
- warnErr("'lecodes preview' is now 'lecodes compile' — the old name still works.");
2261
- await compile2(args);
4135
+ var materializeTypesLocal = (root) => {
4136
+ const bundle = readLocalTypeBundle();
4137
+ if (!bundle)
4138
+ return false;
4139
+ writeTypeBundle(root, bundle);
4140
+ return true;
4141
+ };
4142
+ var writeTsconfig = (root) => {
4143
+ writeFileSync8(join12(root, "tsconfig.json"), JSON.stringify(TSCONFIG, null, 2));
4144
+ };
4145
+
4146
+ // src/commands/clone.ts
4147
+ var parseUuid = (input) => {
4148
+ if (!input)
4149
+ throw new CliError("Usage: lecodes clone <project-uuid|url> [directory]");
4150
+ if (!input.includes("/"))
4151
+ return input;
4152
+ const clean = input.split(/[?#]/)[0].replace(/\/+$/, "");
4153
+ const match = clean.match(/\/(?:projects|view)\/([^/]+)/);
4154
+ return match ? match[1] : clean.slice(clean.lastIndexOf("/") + 1);
4155
+ };
4156
+ var slug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
4157
+ var clone = async (args) => {
4158
+ const config = loadConfig();
4159
+ const apiUrl = requireApiUrl(config);
4160
+ const token = requireToken(config);
4161
+ const uuid = parseUuid(args._[0]);
4162
+ const project = await getProject(apiUrl, token, uuid);
4163
+ const dir = args._[1] ?? slug(project.name);
4164
+ const root = resolve3(dir);
4165
+ if (existsSync10(root) && readdirSync5(root).length > 0) {
4166
+ throw new CliError(`Directory "${dir}" already exists and is not empty.`);
4167
+ }
4168
+ mkdirSync7(root, { recursive: true });
4169
+ info(c.dim(`Fetching project ${c.bold(project.name)}…`));
4170
+ const files = await materializeAssets(root, project, apiUrl, token, progress);
4171
+ endProgress();
4172
+ if (!flagBool(args, "no-types")) {
4173
+ info(c.dim("Fetching type definitions…"));
4174
+ await materializeTypes(root, apiUrl);
4175
+ writeTsconfig(root);
4176
+ }
4177
+ writeDefaultIgnore(root);
4178
+ const commits = await getCommits(apiUrl, token, uuid);
4179
+ writeManifest(root, { uuid, apiUrl, name: project.name, baseHeadId: commits.headId, files });
4180
+ success(`Cloned ${c.bold(project.name)} into ${c.bold(dir)}/ (${Object.keys(files).length} files)`);
4181
+ info(c.dim(` cd ${dir} — edit, then \`lecodes push -m "your message"\``));
2262
4182
  };
2263
4183
 
2264
4184
  // src/commands/create.ts
2265
- import { existsSync as existsSync9, mkdirSync as mkdirSync6, readdirSync as readdirSync5, writeFileSync as writeFileSync7 } from "node:fs";
2266
- import { join as join11, resolve as resolve3 } from "node:path";
4185
+ import { existsSync as existsSync11, mkdirSync as mkdirSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync9 } from "node:fs";
4186
+ import { join as join13, resolve as resolve4 } from "node:path";
2267
4187
  var slug2 = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
2268
4188
  var STARTER = `// Welcome to le.codes — no imports needed, the whole SDK is available as globals.
2269
4189
  const scene = new Scene2D({ background: '#10131a' })
@@ -2281,18 +4201,18 @@ var create = async (args) => {
2281
4201
  if (!name)
2282
4202
  throw new CliError("Usage: lecodes create <name> [directory]");
2283
4203
  const dir = args._[1] ?? slug2(name);
2284
- const root = resolve3(dir);
2285
- if (existsSync9(root) && readdirSync5(root).length > 0) {
4204
+ const root = resolve4(dir);
4205
+ if (existsSync11(root) && readdirSync6(root).length > 0) {
2286
4206
  throw new CliError(`Directory "${dir}" already exists and is not empty.`);
2287
4207
  }
2288
- mkdirSync6(root, { recursive: true });
4208
+ mkdirSync8(root, { recursive: true });
2289
4209
  info(c.dim(`Creating project ${c.bold(name)}…`));
2290
4210
  const { uuid } = await createProject(apiUrl, token, name, flagStr(args, "team"));
2291
4211
  info(c.dim("Fetching type definitions…"));
2292
4212
  await materializeTypes(root, apiUrl);
2293
4213
  writeTsconfig(root);
2294
4214
  writeDefaultIgnore(root);
2295
- writeFileSync7(join11(root, "main.ts"), STARTER);
4215
+ writeFileSync9(join13(root, "main.ts"), STARTER);
2296
4216
  const commits = await getCommits(apiUrl, token, uuid);
2297
4217
  writeManifest(root, { uuid, apiUrl, name, baseHeadId: commits.headId, files: {} });
2298
4218
  success(`Created ${c.bold(name)} in ${c.bold(dir)}/`);
@@ -2300,22 +4220,22 @@ var create = async (args) => {
2300
4220
  };
2301
4221
 
2302
4222
  // src/commands/design.ts
2303
- import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync12, readdirSync as readdirSync6, writeFileSync as writeFileSync9, appendFileSync } from "node:fs";
2304
- import { basename as basename5, join as join13 } from "node:path";
4223
+ import { existsSync as existsSync13, mkdirSync as mkdirSync10, readFileSync as readFileSync14, readdirSync as readdirSync7, writeFileSync as writeFileSync11, appendFileSync } from "node:fs";
4224
+ import { basename as basename6, join as join15 } from "node:path";
2305
4225
 
2306
4226
  // src/compile/designCompile.ts
2307
- import { readFileSync as readFileSync11 } from "node:fs";
2308
- import { basename as basename4 } from "node:path";
4227
+ import { readFileSync as readFileSync13 } from "node:fs";
4228
+ import { basename as basename5 } from "node:path";
2309
4229
 
2310
4230
  // src/compile/assetIcons.ts
2311
- import { createHash as createHash2 } from "node:crypto";
2312
- import { existsSync as existsSync10, mkdirSync as mkdirSync7, readFileSync as readFileSync10, writeFileSync as writeFileSync8 } from "node:fs";
2313
- import { dirname as dirname7, join as join12 } from "node:path";
4231
+ import { createHash as createHash3 } from "node:crypto";
4232
+ import { existsSync as existsSync12, mkdirSync as mkdirSync9, readFileSync as readFileSync12, writeFileSync as writeFileSync10 } from "node:fs";
4233
+ import { dirname as dirname9, join as join14 } from "node:path";
2314
4234
  var DEFAULT_REGISTRY = process.env.LECODES_ICON_REGISTRY?.trim() || "https://icon-registry.jt3.ru/v1";
2315
4235
  var ICON_ID = /^([a-z0-9]+(?:-[a-z0-9]+)*):([a-z0-9]+(?:-[a-z0-9]+)*)$/;
2316
- var isIdentStart = (c2) => c2 >= "A" && c2 <= "Z" || c2 >= "a" && c2 <= "z" || c2 === "_" || c2 === "$";
2317
- var isIdentPart = (c2) => isIdentStart(c2) || c2 >= "0" && c2 <= "9";
2318
- var skipString = (src, start) => {
4236
+ var isIdentStart2 = (c2) => c2 >= "A" && c2 <= "Z" || c2 >= "a" && c2 <= "z" || c2 === "_" || c2 === "$";
4237
+ var isIdentPart2 = (c2) => isIdentStart2(c2) || c2 >= "0" && c2 <= "9";
4238
+ var skipString2 = (src, start) => {
2319
4239
  const quote = src[start];
2320
4240
  const n = src.length;
2321
4241
  let i = start + 1;
@@ -2326,7 +4246,7 @@ var skipString = (src, start) => {
2326
4246
  continue;
2327
4247
  }
2328
4248
  if (quote === "`" && c2 === "$" && src[i + 1] === "{") {
2329
- i = skipBraces(src, i + 1);
4249
+ i = skipBraces2(src, i + 1);
2330
4250
  continue;
2331
4251
  }
2332
4252
  if (c2 === quote)
@@ -2335,14 +4255,14 @@ var skipString = (src, start) => {
2335
4255
  }
2336
4256
  return n;
2337
4257
  };
2338
- var skipBraces = (src, open) => {
4258
+ var skipBraces2 = (src, open) => {
2339
4259
  const n = src.length;
2340
4260
  let depth = 0;
2341
4261
  let i = open;
2342
4262
  while (i < n) {
2343
4263
  const c2 = src[i];
2344
4264
  if (c2 === '"' || c2 === "'" || c2 === "`") {
2345
- i = skipString(src, i);
4265
+ i = skipString2(src, i);
2346
4266
  continue;
2347
4267
  }
2348
4268
  if (c2 === "{")
@@ -2356,14 +4276,14 @@ var skipBraces = (src, open) => {
2356
4276
  }
2357
4277
  return n;
2358
4278
  };
2359
- var matchParen = (src, open) => {
4279
+ var matchParen2 = (src, open) => {
2360
4280
  const n = src.length;
2361
4281
  let depth = 0;
2362
4282
  let i = open;
2363
4283
  while (i < n) {
2364
4284
  const c2 = src[i];
2365
4285
  if (c2 === '"' || c2 === "'" || c2 === "`") {
2366
- i = skipString(src, i);
4286
+ i = skipString2(src, i);
2367
4287
  continue;
2368
4288
  }
2369
4289
  if (c2 === "/" && src[i + 1] === "/") {
@@ -2391,7 +4311,7 @@ var matchParen = (src, open) => {
2391
4311
  }
2392
4312
  return -1;
2393
4313
  };
2394
- var splitTopLevel = (text, delim) => {
4314
+ var splitTopLevel2 = (text, delim) => {
2395
4315
  const parts = [];
2396
4316
  const n = text.length;
2397
4317
  let depth = 0;
@@ -2400,7 +4320,7 @@ var splitTopLevel = (text, delim) => {
2400
4320
  while (i < n) {
2401
4321
  const c2 = text[i];
2402
4322
  if (c2 === '"' || c2 === "'" || c2 === "`") {
2403
- i = skipString(text, i);
4323
+ i = skipString2(text, i);
2404
4324
  continue;
2405
4325
  }
2406
4326
  if (c2 === "(" || c2 === "[" || c2 === "{")
@@ -2416,7 +4336,7 @@ var splitTopLevel = (text, delim) => {
2416
4336
  parts.push(text.slice(last));
2417
4337
  return parts;
2418
4338
  };
2419
- var stringLiteralValue = (text) => {
4339
+ var stringLiteralValue2 = (text) => {
2420
4340
  const t = text.trim();
2421
4341
  if (t.length < 2)
2422
4342
  return null;
@@ -2437,7 +4357,7 @@ var scanAssetIconCalls = (source) => {
2437
4357
  while (i < n) {
2438
4358
  const c2 = source[i];
2439
4359
  if (c2 === '"' || c2 === "'" || c2 === "`") {
2440
- i = skipString(source, i);
4360
+ i = skipString2(source, i);
2441
4361
  continue;
2442
4362
  }
2443
4363
  if (c2 === "/" && source[i + 1] === "/") {
@@ -2454,12 +4374,12 @@ var scanAssetIconCalls = (source) => {
2454
4374
  i += 2;
2455
4375
  continue;
2456
4376
  }
2457
- if (isIdentStart(c2)) {
4377
+ if (isIdentStart2(c2)) {
2458
4378
  let j = i + 1;
2459
- while (j < n && isIdentPart(source[j]))
4379
+ while (j < n && isIdentPart2(source[j]))
2460
4380
  j++;
2461
- if (source.slice(i, j) === "assetIcon" && prevNonSpace(source, i) !== ".") {
2462
- const call = parseCall(source, i, j);
4381
+ if (source.slice(i, j) === "assetIcon" && prevNonSpace2(source, i) !== ".") {
4382
+ const call = parseCall2(source, i, j);
2463
4383
  if (call) {
2464
4384
  calls.push(call);
2465
4385
  i = call.end;
@@ -2473,25 +4393,25 @@ var scanAssetIconCalls = (source) => {
2473
4393
  }
2474
4394
  return calls;
2475
4395
  };
2476
- var prevNonSpace = (src, i) => {
4396
+ var prevNonSpace2 = (src, i) => {
2477
4397
  let k = i - 1;
2478
4398
  while (k >= 0 && /\s/.test(src[k]))
2479
4399
  k--;
2480
4400
  return k >= 0 ? src[k] : "";
2481
4401
  };
2482
- var parseCall = (src, start, identEnd) => {
4402
+ var parseCall2 = (src, start, identEnd) => {
2483
4403
  const n = src.length;
2484
4404
  let i = identEnd;
2485
4405
  while (i < n && /\s/.test(src[i]))
2486
4406
  i++;
2487
4407
  if (src[i] !== "(")
2488
4408
  return null;
2489
- const close = matchParen(src, i);
4409
+ const close = matchParen2(src, i);
2490
4410
  if (close === -1)
2491
4411
  return null;
2492
4412
  const call = { start, end: close + 1, id: "", pack: "", name: "" };
2493
- const args = splitTopLevel(src.slice(i + 1, close), ",");
2494
- const id = stringLiteralValue(args[0] ?? "");
4413
+ const args = splitTopLevel2(src.slice(i + 1, close), ",");
4414
+ const id = stringLiteralValue2(args[0] ?? "");
2495
4415
  if (id === null) {
2496
4416
  call.error = `assetIcon(...) needs a string-literal id like "lucide:bell" (got \`${(args[0] ?? "").trim() || "()"}\`).`;
2497
4417
  return call;
@@ -2522,8 +4442,8 @@ var extractColor = (optsText) => {
2522
4442
  if (!optsText.startsWith("{") || !optsText.endsWith("}")) {
2523
4443
  return { error: `assetIcon(id, opts): opts must be an object literal like { color: "#5b8cff" }.` };
2524
4444
  }
2525
- for (const prop of splitTopLevel(optsText.slice(1, -1), ",")) {
2526
- const kv = splitTopLevel(prop, ":");
4445
+ for (const prop of splitTopLevel2(optsText.slice(1, -1), ",")) {
4446
+ const kv = splitTopLevel2(prop, ":");
2527
4447
  if (kv.length < 2)
2528
4448
  continue;
2529
4449
  const key = kv[0].trim().replace(/^["'`]|["'`]$/g, "");
@@ -2532,7 +4452,7 @@ var extractColor = (optsText) => {
2532
4452
  const raw = kv.slice(1).join(":").trim();
2533
4453
  if (!raw)
2534
4454
  continue;
2535
- const literal = stringLiteralValue(raw);
4455
+ const literal = stringLiteralValue2(raw);
2536
4456
  return literal !== null ? { bake: literal } : { tint: raw };
2537
4457
  }
2538
4458
  return {};
@@ -2553,34 +4473,34 @@ var iconExpr = (svg, call) => {
2553
4473
  return call.tint ? `Object.assign(${src}, { tintColor: (${call.tint}) })` : src;
2554
4474
  };
2555
4475
  var escapeTemplate = (s) => s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
2556
- var isHttp = (s) => /^https?:/i.test(s);
2557
- var sha12 = (s) => createHash2("sha256").update(s).digest("hex").slice(0, 12);
2558
- var registryHome = (registry) => registry.replace(/\/v\d+$/, "");
4476
+ var isHttp2 = (s) => /^https?:/i.test(s);
4477
+ var sha122 = (s) => createHash3("sha256").update(s).digest("hex").slice(0, 12);
4478
+ var registryHome2 = (registry) => registry.replace(/\/v\d+$/, "");
2559
4479
  var readText = async (registry, rel) => {
2560
- if (isHttp(registry)) {
4480
+ if (isHttp2(registry)) {
2561
4481
  const res = await fetch(`${registry}/${rel}`);
2562
4482
  return res.ok ? await res.text() : null;
2563
4483
  }
2564
4484
  try {
2565
- return readFileSync10(join12(registry, rel), "utf8");
4485
+ return readFileSync12(join14(registry, rel), "utf8");
2566
4486
  } catch {
2567
4487
  return null;
2568
4488
  }
2569
4489
  };
2570
- var readLock = (path) => {
4490
+ var readLock2 = (path) => {
2571
4491
  try {
2572
- const lock = JSON.parse(readFileSync10(path, "utf8"));
4492
+ const lock = JSON.parse(readFileSync12(path, "utf8"));
2573
4493
  if (lock && typeof lock === "object" && lock.icons && lock.packs)
2574
4494
  return lock;
2575
4495
  } catch {}
2576
4496
  return null;
2577
4497
  };
2578
- var serializeLock = (lock) => {
4498
+ var serializeLock2 = (lock) => {
2579
4499
  const sortKeys = (rec) => Object.fromEntries(Object.keys(rec).sort().map((k) => [k, rec[k]]));
2580
4500
  return JSON.stringify({ registry: lock.registry, icons: sortKeys(lock.icons), packs: sortKeys(lock.packs) }, null, 2) + `
2581
4501
  `;
2582
4502
  };
2583
- var renderNotices = (lock) => {
4503
+ var renderNotices2 = (lock) => {
2584
4504
  const packs = [...new Set(Object.values(lock.icons).map((i) => i.pack))].sort();
2585
4505
  const lines = [
2586
4506
  "# Icon attribution & notices",
@@ -2605,29 +4525,29 @@ var renderNotices = (lock) => {
2605
4525
  return lines.join(`
2606
4526
  `);
2607
4527
  };
2608
- var writeIfChanged = (path, text) => {
2609
- const current = existsSync10(path) ? safeRead(path) : null;
4528
+ var writeIfChanged2 = (path, text) => {
4529
+ const current = existsSync12(path) ? safeRead(path) : null;
2610
4530
  if (current === text)
2611
4531
  return false;
2612
- writeFileSync8(path, text);
4532
+ writeFileSync10(path, text);
2613
4533
  return true;
2614
4534
  };
2615
4535
  var safeRead = (path) => {
2616
4536
  try {
2617
- return readFileSync10(path, "utf8");
4537
+ return readFileSync12(path, "utf8");
2618
4538
  } catch {
2619
4539
  return null;
2620
4540
  }
2621
4541
  };
2622
4542
  var vendorIcons = async (designDir, ids, opts = {}) => {
2623
4543
  const registry = (opts.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, "");
2624
- const lockPath = join12(designDir, "icons.lock.json");
2625
- const lock = readLock(lockPath) ?? { registry, icons: {}, packs: {} };
4544
+ const lockPath = join14(designDir, "icons.lock.json");
4545
+ const lock = readLock2(lockPath) ?? { registry, icons: {}, packs: {} };
2626
4546
  const svgById = new Map;
2627
4547
  const missing = [];
2628
4548
  for (const id of ids) {
2629
4549
  const li = lock.icons[id];
2630
- const cached = li && safeRead(join12(designDir, li.path));
4550
+ const cached = li && safeRead(join14(designDir, li.path));
2631
4551
  if (cached)
2632
4552
  svgById.set(id, cached);
2633
4553
  else
@@ -2643,23 +4563,23 @@ var vendorIcons = async (designDir, ids, opts = {}) => {
2643
4563
  const [, pack, name] = m;
2644
4564
  const lic = licenses[pack];
2645
4565
  if (!lic)
2646
- throw new Error(`assetIcon: unknown icon pack "${pack}" (in "${id}"). Browse packs at ${registryHome(registry)}.`);
4566
+ throw new Error(`assetIcon: unknown icon pack "${pack}" (in "${id}"). Browse packs at ${registryHome2(registry)}.`);
2647
4567
  const svg = await readText(registry, `${pack}/${name}.svg`);
2648
4568
  if (svg === null)
2649
- throw new Error(`assetIcon: icon "${id}" not found in the registry. Check the name at ${registryHome(registry)}.`);
4569
+ throw new Error(`assetIcon: icon "${id}" not found in the registry. Check the name at ${registryHome2(registry)}.`);
2650
4570
  const rel = `assets/icons/${pack}/${name}.svg`;
2651
- const abs = join12(designDir, rel);
2652
- mkdirSync7(dirname7(abs), { recursive: true });
2653
- writeFileSync8(abs, svg);
2654
- lock.icons[id] = { pack, name, hash: sha12(svg), spdx: lic.spdx, path: rel };
4571
+ const abs = join14(designDir, rel);
4572
+ mkdirSync9(dirname9(abs), { recursive: true });
4573
+ writeFileSync10(abs, svg);
4574
+ lock.icons[id] = { pack, name, hash: sha122(svg), spdx: lic.spdx, path: rel };
2655
4575
  lock.packs[pack] = { spdx: lic.spdx, title: lic.title, url: lic.url, attribution: lic.attribution, modifiedNotice: lic.modifiedNotice };
2656
4576
  svgById.set(id, svg);
2657
4577
  }
2658
4578
  lock.registry = registry;
2659
- writeIfChanged(lockPath, serializeLock(lock));
4579
+ writeIfChanged2(lockPath, serializeLock2(lock));
2660
4580
  }
2661
4581
  if (Object.keys(lock.icons).length)
2662
- writeIfChanged(join12(designDir, "ICON-NOTICES.md"), renderNotices(lock));
4582
+ writeIfChanged2(join14(designDir, "ICON-NOTICES.md"), renderNotices2(lock));
2663
4583
  return svgById;
2664
4584
  };
2665
4585
  var applyAssetIcons = async (entries, designDir, opts = {}) => {
@@ -2683,7 +4603,7 @@ var compileDesignScreen = async (opts) => {
2683
4603
  const entries = [];
2684
4604
  const scan = scanLocal(root, { ignore: loadIgnore(root), tracked: new Set(Object.keys(manifest?.files ?? {})) });
2685
4605
  for (const file of scan.files) {
2686
- const type = manifest?.files[file.path]?.type ?? classifyKind(basename4(file.path));
4606
+ const type = manifest?.files[file.path]?.type ?? classifyKind(basename5(file.path));
2687
4607
  if (type === "resource") {
2688
4608
  entries.push({
2689
4609
  path: file.path,
@@ -2694,7 +4614,7 @@ var compileDesignScreen = async (opts) => {
2694
4614
  } else if (type === "shader") {
2695
4615
  entries.push({ path: file.path, type, shaders: [] });
2696
4616
  } else {
2697
- entries.push({ path: file.path, type, text: readFileSync11(file.absPath, "utf8") });
4617
+ entries.push({ path: file.path, type, text: readFileSync13(file.absPath, "utf8") });
2698
4618
  }
2699
4619
  }
2700
4620
  const invoke = state !== undefined ? `screen(${JSON.stringify(state)})` : "screen()";
@@ -2708,6 +4628,18 @@ var compileDesignScreen = async (opts) => {
2708
4628
  `
2709
4629
  });
2710
4630
  await applyAssetIcons(entries, designDir);
4631
+ await applyProjectFonts(entries, {
4632
+ mode: "vendor",
4633
+ root,
4634
+ vendorDir: designDir,
4635
+ vendorPrefix: designKey,
4636
+ makeResourceEntry: (path, absPath) => ({
4637
+ path,
4638
+ type: "resource",
4639
+ fileSrc: opts.assets === "disk" ? encodeURIComponent(absPath) : encodeURI(path),
4640
+ absPath
4641
+ })
4642
+ });
2711
4643
  return compileProject2({
2712
4644
  entries,
2713
4645
  name: opts.name,
@@ -2754,10 +4686,10 @@ var openBrowser = (url) => {
2754
4686
  };
2755
4687
  var browserLogin = async (webBase, timeoutMs = 5 * 60 * 1000, open = openBrowser) => {
2756
4688
  const state = randomBytes(16).toString("hex");
2757
- let resolve4;
4689
+ let resolve5;
2758
4690
  let reject;
2759
4691
  const tokenPromise = new Promise((res, rej) => {
2760
- resolve4 = res;
4692
+ resolve5 = res;
2761
4693
  reject = rej;
2762
4694
  });
2763
4695
  const server = createServer((req, res) => {
@@ -2775,7 +4707,7 @@ var browserLogin = async (webBase, timeoutMs = 5 * 60 * 1000, open = openBrowser
2775
4707
  }
2776
4708
  res.writeHead(200, { "content-type": "text/html" });
2777
4709
  res.end(SUCCESS_HTML);
2778
- resolve4(token);
4710
+ resolve5(token);
2779
4711
  });
2780
4712
  await new Promise((res, rej) => {
2781
4713
  server.once("error", rej);
@@ -2839,6 +4771,9 @@ Behaviour that isn't visible in a single screen — validation, permissions, not
2839
4771
  cases — and the decisions you've made and why, so they aren't re-litigated later.
2840
4772
  `;
2841
4773
  var TOKENS_TS = `// Design tokens shared by every screen. Colors are hex strings (the engine parses hex only).
4774
+ // App font: one theme() call here sets the default text font everywhere. font("id") installs a
4775
+ // registry family (vendored at compile time) and returns its name — no loading code needed.
4776
+ // theme({ fontFamily: font("manrope") })
2842
4777
  export const colors = {
2843
4778
  bg: "#101114",
2844
4779
  card: "#1c1e24",
@@ -2852,6 +4787,57 @@ export const spacing = { xs: 4, sm: 8, md: 16, lg: 24 }
2852
4787
 
2853
4788
  export const radius = { sm: 8, md: 12, lg: 20 }
2854
4789
  `;
4790
+ var TABS_TS = `// The app's tab bar, declared ONCE — the design canvas discovers it from this file.
4791
+ //
4792
+ // export const mainTabs = defineTabs({
4793
+ // home: { label: "Home", icon: assetIcon("lucide:house") },
4794
+ // news: { label: "News", icon: assetIcon("lucide:newspaper") },
4795
+ // })
4796
+ //
4797
+ // The KEYS are screen ids — each tab's root screen (screens/<key>.ts), in tab order, literals only.
4798
+ // Every screen that shows the bar mounts it with a literal tab id — tab roots AND pushed details
4799
+ // that keep the bar:
4800
+ //
4801
+ // export default () => mainTabs.screen("news", [ ...content ])
4802
+ //
4803
+ // A screen that doesn't mount it is a full-screen push (bar hidden). Never draw meta.json edges
4804
+ // between tab screens — the bar already navigates; the board renders it as a rail + lanes.
4805
+ // Restyle the bar below freely: only the defineTabs({ keys }) declaration and the
4806
+ // .screen("<tab>", …) / .bar("<tab>") call shapes are parsed.
4807
+
4808
+ type TabDef = { label: string, icon?: { svg: string, tintColor: string | null } }
4809
+
4810
+ // Theme-variable driven — theme({ primaryColor, mutedColor, tabbarBg, screenBg }) anywhere
4811
+ // restyles every screen's bar at once; the fallbacks keep an untouched scaffold looking right.
4812
+ const ACTIVE = "var(--primaryColor, #5b8cff)"
4813
+ const INACTIVE = "var(--mutedColor, #8a919e)"
4814
+ const BAR_BG = "var(--tabbarBg, #15171c)"
4815
+ const SCREEN_BG = "var(--screenBg, #101114)"
4816
+
4817
+ export const defineTabs = <T extends Record<string, TabDef>>(tabs: T) => {
4818
+ const ids = Object.keys(tabs) as (keyof T & string)[]
4819
+
4820
+ /** The bar alone — mount it yourself when the screen owns its whole layout. */
4821
+ const bar = (active: keyof T & string) =>
4822
+ UIRow(ids.map((id) => {
4823
+ const color = id === active ? ACTIVE : INACTIVE
4824
+ return UIButton([
4825
+ ...(tabs[id].icon ? [UIImage(tabs[id].icon!).style({ width: 22, height: 22, tintColor: color })] : []),
4826
+ UIText(tabs[id].label).style({ fontSize: 10, color }),
4827
+ ]).style({ name: \`tab-\${id}\`, flexDirection: "column", gap: 3, flexGrow: 1, flexBasis: 0, pt: 8, pb: 6 })
4828
+ })).style({ bgColor: BAR_BG, pb: "safe-bottom" })
4829
+
4830
+ /** A screen that sits ON the bar (the common case): [ content, bar ] — the same column the real
4831
+ * app composes as [ UIPager, bar ]. */
4832
+ const screen = (active: keyof T & string, children: UINodeChild[]) =>
4833
+ UIScreen([
4834
+ UIColumn(children).style({ flexGrow: 1, p: 16, pt: "safe-top" }),
4835
+ bar(active),
4836
+ ]).style({ bgColor: SCREEN_BG })
4837
+
4838
+ return { ids, bar, screen }
4839
+ }
4840
+ `;
2855
4841
  var HOME_SCREEN_TS = `import { colors, spacing, radius } from "../shared/tokens"
2856
4842
 
2857
4843
  // Mock data: the domain shown on realistic values. Inline, per screen.
@@ -2930,6 +4916,12 @@ positions or descriptions.
2930
4916
 
2931
4917
  - An edge's optional \`activator\` names the element (its \`name\` style option) that triggers the
2932
4918
  transition. Give a \`name\` to interactive elements an edge refers to — nothing else needs names.
4919
+ - **Edges are forward intent only.** Never draw back/dismiss/logout edges — the navigation stack
4920
+ implies return paths. And never draw edges between tab screens — the tab bar already navigates
4921
+ (see "The tab bar" below); the board flags bar-implied edges for deletion.
4922
+ - **An edge from a screen to itself** (\`"from": "home", "to": "home@empty"\`) is a *state link*: the
4923
+ board draws it as a chip on the activator element (clicking it switches the tile's state), never
4924
+ as an arrow. Use it to record "tapping X puts this screen into state Y".
2933
4925
  - **Group each screen** you create by its user-facing area — \`"group": "Onboarding"\` — 2–6 screens
2934
4926
  per group (Onboarding, Checkout, Settings…). Groups render as labeled regions and \`arrange\` lays
2935
4927
  each out as its own sub-flow, so grouping keeps big boards legible. Semantic clustering is your
@@ -2977,6 +4969,25 @@ keep in the chat.
2977
4969
  typing a component's return value or a container's children — with no imports. Trust the types;
2978
4970
  don't fall back to \`any\`.
2979
4971
 
4972
+ ## The tab bar
4973
+
4974
+ Most apps have one — declare it ONCE, in source, and never draw edges for it:
4975
+
4976
+ - \`shared/tabs.ts\` ships a \`defineTabs\` helper. Declare the bar there:
4977
+ \`export const mainTabs = defineTabs({ home: { label: "Home", icon: assetIcon("lucide:house") }, … })\`
4978
+ — the **keys are screen ids** (each tab's root screen, in tab order), literals only.
4979
+ - Every screen that shows the bar mounts it with a **literal** tab id:
4980
+ \`export default () => mainTabs.screen("news", [ …content ])\` — tab roots AND pushed detail
4981
+ screens that keep the bar (the news lane's article screen mounts \`"news"\` too). A screen that
4982
+ doesn't mount it is a full-screen push (bar hidden), e.g. a photo viewer.
4983
+ - The canvas discovers all of this from the source (like screen states) and draws the bar as a
4984
+ **rail + lane bands** — tab switching needs NO edges, and \`design_state\` serves the discovered
4985
+ \`hubs\`. In the live prototype, tapping a tab button jumps the board to that tab's root screen.
4986
+ - Style through theme variables: the bar reads \`var(--primaryColor)\`, \`var(--mutedColor)\`,
4987
+ \`var(--tabbarBg)\`, \`var(--screenBg)\` (with fallbacks) — one \`theme({ primaryColor: … })\` call
4988
+ restyles it app-wide. You may restyle \`shared/tabs.ts\` itself; keep the \`defineTabs({ … })\`
4989
+ declaration and \`.screen("<tab>", …)\` call shapes intact (the canvas parses them).
4990
+
2980
4991
  ## Screen states (variants)
2981
4992
 
2982
4993
  A screen often has more than one look — a list vs. its empty state, a form vs. its error state.
@@ -2984,10 +4995,9 @@ Instead of a second file per variant, make the default export a **function of a
2984
4995
  type is a string-literal union** — the union IS the state list (no separate schema):
2985
4996
 
2986
4997
  \`\`\`ts
2987
- export default (state: "default" | "empty" = "default") => UIScreen([
4998
+ export default (state: "default" | "empty" = "default") => mainTabs.screen("home", [
2988
4999
  Header(child),
2989
5000
  state === "empty" ? EmptyState("Nothing here yet") : UIScrollable(items.map(Card)),
2990
- TabBar(tabs, "home"),
2991
5001
  ])
2992
5002
  \`\`\`
2993
5003
 
@@ -3005,6 +5015,35 @@ export default (state: "default" | "empty" = "default") => UIScreen([
3005
5015
  \`"home@empty"\`) — still one node per screen. \`lecodes design check\` errors on an edge pointing at
3006
5016
  a state the screen doesn't declare.
3007
5017
 
5018
+ ## Overlays (sheets & dialogs)
5019
+
5020
+ A bottom sheet or confirm dialog is a **\`UIWidget\`**, not an absolutely-positioned child of the
5021
+ screen. Its \`overlayColor\` is the scrim — a real modal layer: it blocks everything beneath it
5022
+ (taps AND the canvas's hover/hints), and a tap on it fires \`onOverlayTap\`. \`show()\` it from the
5023
+ state branch instead of embedding it:
5024
+
5025
+ \`\`\`ts
5026
+ const FilterSheet = () => {
5027
+ const sheet = UIWidget(
5028
+ { left: 0, right: 0, bottom: 0, p: 16, gap: 8, overlayColor: "#0008" },
5029
+ [ SheetCard([ … ]), PrimaryButton("Apply", { name: "apply" }) ],
5030
+ )
5031
+ return sheet.onOverlayTap(() => sheet.hide())
5032
+ }
5033
+
5034
+ export default (state: "default" | "filters" = "default") => {
5035
+ if (state === "filters") FilterSheet().show()
5036
+ return UIScreen([ … ])
5037
+ }
5038
+ \`\`\`
5039
+
5040
+ - Size the widget box to the **content only** (bottom-anchor a sheet, or center a dialog card
5041
+ inside a full-inset box) — the scrim is the \`overlayColor\`, painted full-screen behind it.
5042
+ - A tap lands **outside the widget box** → \`onOverlayTap\` (dismiss a sheet there). A confirm
5043
+ dialog that demands an explicit choice uses a full-inset box and no \`onOverlayTap\`.
5044
+ - Headless snapshots render widgets as \`overlays\` in the JSON and composite them in the PNG,
5045
+ and the edge \`activator\` names on widget elements (\`"apply"\`, \`"confirm"\`) work as usual.
5046
+
3008
5047
  ## Icons
3009
5048
 
3010
5049
  Use real icons instead of emoji. \`assetIcon("pack:name")\` is a **compile-time macro**: it vendors
@@ -3027,6 +5066,28 @@ UIImage(assetIcon("lucide:check")).style({ width: 20, height: 20, tintColor: col
3027
5066
  (attribution) — **commit these**; they make rebuilds offline. An unknown \`pack:name\` is a clear
3028
5067
  compile error.
3029
5068
 
5069
+ ## Fonts
5070
+
5071
+ Real typography sells a design. \`font("id")\` installs a font from the registry AND returns its
5072
+ family name — no loading code, no awaiting, ever:
5073
+
5074
+ \`\`\`ts
5075
+ theme({ fontFamily: font("manrope") }) // app font, in shared/tokens.ts
5076
+ UIText("Hero").style({ fontFamily: font("unbounded"), fontSize: 34 }) // display accents per node
5077
+ font("ibm-plex-sans", { weights: [400, 600] }) // pick exact weights
5078
+ \`\`\`
5079
+
5080
+ - Registry ids (all OFL, cyrillic-capable unless noted):
5081
+ sans \`inter manrope golos-text onest rubik nunito montserrat ibm-plex-sans pt-sans jost exo-2\`;
5082
+ display \`unbounded russo-one oswald comfortaa space-grotesk\` (latin only);
5083
+ serif \`playfair-display lora pt-serif cormorant literata\`;
5084
+ mono \`jetbrains-mono fira-code ibm-plex-mono\`; handwriting \`caveat pacifico\`.
5085
+ - Default weights are ~400/500/700 — install the weights you use (\`fontWeight: 600\` wants a 600
5086
+ face). The id must be a **string literal**; an unknown one is a clear compile error.
5087
+ - Your own file works too: \`font("./fonts/Brand.ttf")\` — family/weight are read from the file.
5088
+ - Vendored faces land in \`assets/fonts/\`, tracked by \`fonts.lock.json\` + \`FONT-NOTICES.md\` —
5089
+ **commit these**; they make rebuilds offline and they're what the platform board renders from.
5090
+
3030
5091
  ## See what you built
3031
5092
 
3032
5093
  The developer watches the canvas live (screens hot-reload on save). To see a screen yourself:
@@ -3077,6 +5138,15 @@ var DESIGN_MACROS_DTS = `// Generated by \`lecodes design init\`. Globals the de
3077
5138
  * string literal (resolved, and the SVG vendored, at compile time).
3078
5139
  */
3079
5140
  declare function assetIcon(id: string, opts?: { color?: string }): { readonly svg: string, tintColor: string | null }
5141
+
5142
+ /**
5143
+ * Install a font and get its family name back — using a font IS declaring it. A registry id
5144
+ * (\`font("manrope")\`) vendors faces from the font registry at compile time ({ weights, italic }
5145
+ * narrows the set); a project path (\`font("./fonts/Brand.ttf")\`) reads family/weight/italic from
5146
+ * the file itself. The argument must be a string literal. No awaiting — pass the result straight
5147
+ * to \`theme({ fontFamily: font("manrope") })\` or a \`fontFamily\` style.
5148
+ */
5149
+ declare function font(id: string, opts?: { weights?: number[], italic?: boolean }): string
3080
5150
  `;
3081
5151
  var README_HINT = `# design/
3082
5152
 
@@ -3101,33 +5171,33 @@ var getContext = (args) => {
3101
5171
  return {
3102
5172
  root,
3103
5173
  manifest,
3104
- name: manifest?.name ?? basename5(root),
5174
+ name: manifest?.name ?? basename6(root),
3105
5175
  dirName,
3106
- designDir: join13(root, dirName),
5176
+ designDir: join15(root, dirName),
3107
5177
  designKey: "/" + dirName.replace(/\\/g, "/")
3108
5178
  };
3109
5179
  };
3110
5180
  var listScreens = (designDir) => {
3111
- const dir = join13(designDir, "screens");
3112
- if (!existsSync11(dir))
5181
+ const dir = join15(designDir, "screens");
5182
+ if (!existsSync13(dir))
3113
5183
  return [];
3114
- return readdirSync6(dir).filter((f) => f.endsWith(".ts") && !f.startsWith(".")).map((f) => f.slice(0, -3)).sort();
5184
+ return readdirSync7(dir).filter((f2) => f2.endsWith(".ts") && !f2.startsWith(".")).map((f2) => f2.slice(0, -3)).sort();
3115
5185
  };
3116
5186
  var readMetaSizes = (designDir) => {
3117
5187
  try {
3118
- const meta = JSON.parse(readFileSync12(join13(designDir, "meta.json"), "utf8"));
5188
+ const meta = JSON.parse(readFileSync14(join15(designDir, "meta.json"), "utf8"));
3119
5189
  return meta?.screens ?? {};
3120
5190
  } catch {
3121
5191
  return {};
3122
5192
  }
3123
5193
  };
3124
5194
  var writeMcpConfig = (ctx, port) => {
3125
- const path = join13(ctx.root, ".mcp.json");
5195
+ const path = join15(ctx.root, ".mcp.json");
3126
5196
  const entry = { type: "http", url: `http://localhost:${port}/mcp` };
3127
5197
  let config = {};
3128
- if (existsSync11(path)) {
5198
+ if (existsSync13(path)) {
3129
5199
  try {
3130
- config = JSON.parse(readFileSync12(path, "utf8"));
5200
+ config = JSON.parse(readFileSync14(path, "utf8"));
3131
5201
  } catch {
3132
5202
  warnErr(".mcp.json exists but isn't valid JSON — left it untouched. Add the lecodes-design server by hand.");
3133
5203
  return;
@@ -3140,7 +5210,7 @@ var writeMcpConfig = (ctx, port) => {
3140
5210
  return;
3141
5211
  }
3142
5212
  config.mcpServers["lecodes-design"] = entry;
3143
- writeFileSync9(path, JSON.stringify(config, null, 2) + `
5213
+ writeFileSync11(path, JSON.stringify(config, null, 2) + `
3144
5214
  `);
3145
5215
  log(`${c.green(existing ? "updated" : "created")} .mcp.json (lecodes-design MCP → ${entry.url})`);
3146
5216
  };
@@ -3150,25 +5220,26 @@ var init = async (ctx, args) => {
3150
5220
  ["spec.md", SPEC_MD],
3151
5221
  ["README.md", README_HINT],
3152
5222
  ["CLAUDE.md", CLAUDE_MD],
3153
- [join13("shared", "tokens.ts"), TOKENS_TS],
3154
- [join13("screens", "home.ts"), HOME_SCREEN_TS]
5223
+ [join15("shared", "tokens.ts"), TOKENS_TS],
5224
+ [join15("shared", "tabs.ts"), TABS_TS],
5225
+ [join15("screens", "home.ts"), HOME_SCREEN_TS]
3155
5226
  ];
3156
- mkdirSync8(join13(ctx.designDir, "screens"), { recursive: true });
3157
- mkdirSync8(join13(ctx.designDir, "shared"), { recursive: true });
5227
+ mkdirSync10(join15(ctx.designDir, "screens"), { recursive: true });
5228
+ mkdirSync10(join15(ctx.designDir, "shared"), { recursive: true });
3158
5229
  let created = 0;
3159
5230
  for (const [rel, content] of files) {
3160
- const abs = join13(ctx.designDir, rel);
3161
- if (existsSync11(abs)) {
5231
+ const abs = join15(ctx.designDir, rel);
5232
+ if (existsSync13(abs)) {
3162
5233
  note(`kept ${ctx.dirName}/${rel.replace(/\\/g, "/")}`);
3163
5234
  continue;
3164
5235
  }
3165
- writeFileSync9(abs, content);
5236
+ writeFileSync11(abs, content);
3166
5237
  log(`${c.green("created")} ${ctx.dirName}/${rel.replace(/\\/g, "/")}`);
3167
5238
  created++;
3168
5239
  }
3169
- const ignorePath = join13(ctx.root, ".lecodesignore");
5240
+ const ignorePath = join15(ctx.root, ".lecodesignore");
3170
5241
  const ignoreLine = `${ctx.dirName}/.snapshots/`;
3171
- const current = existsSync11(ignorePath) ? readFileSync12(ignorePath, "utf8") : "";
5242
+ const current = existsSync13(ignorePath) ? readFileSync14(ignorePath, "utf8") : "";
3172
5243
  if (!current.split(/\r?\n/).includes(ignoreLine)) {
3173
5244
  appendFileSync(ignorePath, (current && !current.endsWith(`
3174
5245
  `) ? `
@@ -3179,8 +5250,8 @@ var init = async (ctx, args) => {
3179
5250
  writeMcpConfig(ctx, Number(flagStr(args, "port")) || DEFAULT_PORT);
3180
5251
  const wroteTypes = materializeTypesLocal(ctx.designDir);
3181
5252
  writeTsconfig(ctx.designDir);
3182
- mkdirSync8(join13(ctx.designDir, ".lecodes", "types"), { recursive: true });
3183
- writeFileSync9(join13(ctx.designDir, ".lecodes", "types", "design-macros.d.ts"), DESIGN_MACROS_DTS);
5253
+ mkdirSync10(join15(ctx.designDir, ".lecodes", "types"), { recursive: true });
5254
+ writeFileSync11(join15(ctx.designDir, ".lecodes", "types", "design-macros.d.ts"), DESIGN_MACROS_DTS);
3184
5255
  log(`${c.green("created")} ${ctx.dirName}/tsconfig.json${wroteTypes ? ` + ${ctx.dirName}/.lecodes/types/` : ""} (IDE types)`);
3185
5256
  if (!wroteTypes)
3186
5257
  warnErr("Bundled SDK types not found — tsconfig written, but globals may show unresolved until the CLI is rebuilt with vendored types.");
@@ -3229,16 +5300,16 @@ var snapshot = async (ctx, args) => {
3229
5300
  killer.unref?.();
3230
5301
  const png = flagBool(args, "png") || flagStr(args, "png") !== undefined;
3231
5302
  const sizes = readMetaSizes(ctx.designDir);
3232
- const outDir = flagStr(args, "out-dir") ?? join13(ctx.designDir, ".snapshots");
5303
+ const outDir = flagStr(args, "out-dir") ?? join15(ctx.designDir, ".snapshots");
3233
5304
  const single = targets.length === 1 && requested.length === 1 && !png && !allStates && !flagStr(args, "out-dir");
3234
5305
  const onConsole = flagBool(args, "logs") ? (level, text) => logErr(`${c.dim(`[${level}]`)} ${text}`) : undefined;
3235
5306
  if (!single)
3236
- mkdirSync8(outDir, { recursive: true });
5307
+ mkdirSync10(outDir, { recursive: true });
3237
5308
  for (const id of targets) {
3238
5309
  const [width, height] = sizes[id]?.size ?? [390, 844];
3239
5310
  let plan = [{ state: undefined, bases: [id] }];
3240
5311
  if (discover) {
3241
- const { states, canonical } = discover(readFileSync12(join13(ctx.designDir, "screens", `${id}.ts`), "utf8"));
5312
+ const { states, canonical } = discover(readFileSync14(join15(ctx.designDir, "screens", `${id}.ts`), "utf8"));
3242
5313
  if (stateFlag !== undefined) {
3243
5314
  if (!states.includes(stateFlag)) {
3244
5315
  if (requested.length === 1)
@@ -3279,18 +5350,18 @@ var snapshot = async (ctx, args) => {
3279
5350
  for (const w of pngResult.warnings)
3280
5351
  warnErr(`${tag}: ${w}`);
3281
5352
  for (const base of bases) {
3282
- writeFileSync9(join13(outDir, `${base}.json`), result.text);
3283
- log(`${c.green("✓")} ${tag} → ${join13(outDir, `${base}.json`)}`);
5353
+ writeFileSync11(join15(outDir, `${base}.json`), result.text);
5354
+ log(`${c.green("✓")} ${tag} → ${join15(outDir, `${base}.json`)}`);
3284
5355
  if (pngResult) {
3285
- writeFileSync9(join13(outDir, `${base}.png`), pngResult.png);
3286
- log(`${c.green("✓")} ${tag} → ${join13(outDir, `${base}.png`)}`);
5356
+ writeFileSync11(join15(outDir, `${base}.png`), pngResult.png);
5357
+ log(`${c.green("✓")} ${tag} → ${join15(outDir, `${base}.png`)}`);
3287
5358
  }
3288
5359
  }
3289
5360
  }
3290
5361
  }
3291
5362
  };
3292
5363
  var serve = async (ctx, args) => {
3293
- if (!existsSync11(ctx.designDir)) {
5364
+ if (!existsSync13(ctx.designDir)) {
3294
5365
  throw new CliError(`No ${ctx.dirName}/ folder here. Run "lecodes design init" to scaffold it.`);
3295
5366
  }
3296
5367
  let designServer;
@@ -3363,7 +5434,7 @@ var serve = async (ctx, args) => {
3363
5434
  openBrowser(server.url);
3364
5435
  };
3365
5436
  var arrange = async (ctx, args) => {
3366
- if (!existsSync11(ctx.designDir)) {
5437
+ if (!existsSync13(ctx.designDir)) {
3367
5438
  throw new CliError(`No ${ctx.dirName}/ folder here. Run "lecodes design init" to scaffold it.`);
3368
5439
  }
3369
5440
  let designServer;
@@ -3399,7 +5470,7 @@ var share = async (ctx, args) => {
3399
5470
  note("Turn it off with: lecodes design share --off");
3400
5471
  };
3401
5472
  var check = async (ctx, args) => {
3402
- if (!existsSync11(ctx.designDir)) {
5473
+ if (!existsSync13(ctx.designDir)) {
3403
5474
  throw new CliError(`No ${ctx.dirName}/ folder here. Run "lecodes design init" to scaffold it.`);
3404
5475
  }
3405
5476
  let designServer;
@@ -3436,6 +5507,14 @@ var check = async (ctx, args) => {
3436
5507
  log(` ${c.yellow("!")} ${s.id}: ${s.statesWarning}`);
3437
5508
  log("");
3438
5509
  }
5510
+ if (state.hubs.length) {
5511
+ log(c.bold("Tab bars"));
5512
+ for (const h of state.hubs) {
5513
+ const mounted = Object.keys(h.lanes).length;
5514
+ log(` ${h.name} (${h.file}): ${h.tabs.join(" · ")} — ${mounted} screen${mounted === 1 ? "" : "s"} on the bar`);
5515
+ }
5516
+ log("");
5517
+ }
3439
5518
  const hard = state.warnings.length;
3440
5519
  if (hard === 0) {
3441
5520
  const n = state.screens.length;
@@ -3464,15 +5543,15 @@ var design = async (args) => {
3464
5543
  };
3465
5544
 
3466
5545
  // src/commands/dev.ts
3467
- import { existsSync as existsSync13, mkdirSync as mkdirSync9, readFileSync as readFileSync13, watch, writeFileSync as writeFileSync10 } from "node:fs";
5546
+ import { existsSync as existsSync15, mkdirSync as mkdirSync11, readFileSync as readFileSync15, watch, writeFileSync as writeFileSync12 } from "node:fs";
3468
5547
  import { randomBytes as randomBytes2 } from "node:crypto";
3469
5548
  import { networkInterfaces } from "node:os";
3470
- import { basename as basename6, join as join15 } from "node:path";
5549
+ import { basename as basename7, join as join17 } from "node:path";
3471
5550
 
3472
5551
  // src/dev/devServer.ts
3473
5552
  import { createServer as createServer2 } from "node:http";
3474
- import { createReadStream, existsSync as existsSync12, statSync as statSync2 } from "node:fs";
3475
- import { join as join14 } from "node:path";
5553
+ import { createReadStream, existsSync as existsSync14, statSync as statSync3 } from "node:fs";
5554
+ import { join as join16 } from "node:path";
3476
5555
 
3477
5556
  // src/dev/clientTemplates.ts
3478
5557
  var DEV_SCREEN_TS = `const title = "LeCodes Dev"
@@ -3587,9 +5666,9 @@ var renderPrelude = (p) => DEV_PRELUDE_JS.split("__DEV_ORIGIN__").join(p.origin)
3587
5666
  var renderDevScreen = (name) => DEV_SCREEN_TS.split("__DEV_NAME__").join(JSON.stringify(name));
3588
5667
 
3589
5668
  // src/dev/wsServer.ts
3590
- import { createHash as createHash3 } from "node:crypto";
5669
+ import { createHash as createHash4 } from "node:crypto";
3591
5670
  var WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
3592
- var acceptKey = (key) => createHash3("sha1").update(key + WS_GUID).digest("base64");
5671
+ var acceptKey = (key) => createHash4("sha1").update(key + WS_GUID).digest("base64");
3593
5672
  var encodeTextFrame = (text) => {
3594
5673
  const payload = Buffer.from(text, "utf8");
3595
5674
  const len = payload.length;
@@ -3736,14 +5815,14 @@ var sendText = (res, status, type, body) => {
3736
5815
  res.end(body);
3737
5816
  };
3738
5817
  var sendJson = (res, status, body) => sendText(res, status, "application/json", JSON.stringify(body));
3739
- var readBody = (req) => new Promise((resolve4) => {
5818
+ var readBody = (req) => new Promise((resolve5) => {
3740
5819
  let body = "";
3741
5820
  req.on("data", (c2) => {
3742
5821
  if (body.length < 256 * 1024)
3743
5822
  body += c2.toString("utf8");
3744
5823
  });
3745
- req.on("end", () => resolve4(body));
3746
- req.on("error", () => resolve4(body));
5824
+ req.on("end", () => resolve5(body));
5825
+ req.on("error", () => resolve5(body));
3747
5826
  });
3748
5827
  var startDevServer = async (options) => {
3749
5828
  const { root, manifest, name, origin, token, entryOverride } = options;
@@ -3756,7 +5835,15 @@ var startDevServer = async (options) => {
3756
5835
  const compileOnce = async () => {
3757
5836
  try {
3758
5837
  const { entries, warnings } = collectLocalEntries(root, manifest, `${base}/assets`);
3759
- const js = await compileProject2({ entries, name, publicUrl: origin, entryOverride, header: true });
5838
+ const fontsHeader = await applyProjectFonts(entries, {
5839
+ mode: "vendor",
5840
+ root,
5841
+ vendorDir: root,
5842
+ vendorPrefix: "",
5843
+ publicUrl: origin,
5844
+ makeResourceEntry: (path, absPath) => ({ path, type: "resource", fileSrc: `${base}/assets` + encodeURI(path), absPath })
5845
+ });
5846
+ const js = await compileProject2({ entries, name, publicUrl: origin, entryOverride, header: true, fontsHeader });
3760
5847
  const changed = bundleJs === null || stripVolatile(js) !== stripVolatile(bundleJs);
3761
5848
  if (changed) {
3762
5849
  bundleJs = js;
@@ -3828,8 +5915,8 @@ var startDevServer = async (options) => {
3828
5915
  } catch {
3829
5916
  return sendText(res, 400, "text/plain", "Bad path");
3830
5917
  }
3831
- const abs = join14(root, key.slice(1));
3832
- if (!existsSync12(abs) || !statSync2(abs).isFile())
5918
+ const abs = join16(root, key.slice(1));
5919
+ if (!existsSync14(abs) || !statSync3(abs).isFile())
3833
5920
  return sendText(res, 404, "text/plain", "Not found");
3834
5921
  res.writeHead(200, { "content-type": contentType(key), "cache-control": "no-store" });
3835
5922
  createReadStream(abs).pipe(res);
@@ -3872,11 +5959,11 @@ var startDevServer = async (options) => {
3872
5959
  if (conn)
3873
5960
  clients.add(conn);
3874
5961
  });
3875
- await new Promise((resolve4, reject) => {
5962
+ await new Promise((resolve5, reject) => {
3876
5963
  server.once("error", reject);
3877
5964
  server.listen(options.port, options.bind ?? "0.0.0.0", () => {
3878
5965
  server.removeListener("error", reject);
3879
- resolve4();
5966
+ resolve5();
3880
5967
  });
3881
5968
  });
3882
5969
  const address = server.address();
@@ -3912,24 +5999,24 @@ var pickLanAddress = () => {
3912
5999
  return null;
3913
6000
  };
3914
6001
  var loadDevToken = (root) => {
3915
- const path = join15(root, LECODES_DIR, "dev.json");
6002
+ const path = join17(root, LECODES_DIR, "dev.json");
3916
6003
  try {
3917
- const token2 = JSON.parse(readFileSync13(path, "utf8")).token;
6004
+ const token2 = JSON.parse(readFileSync15(path, "utf8")).token;
3918
6005
  if (token2 && /^[a-z0-9]{8,}$/.test(token2))
3919
6006
  return token2;
3920
6007
  } catch {}
3921
6008
  const token = randomBytes2(6).toString("hex");
3922
- mkdirSync9(join15(root, LECODES_DIR), { recursive: true });
3923
- writeFileSync10(path, JSON.stringify({ token }, null, 2) + `
6009
+ mkdirSync11(join17(root, LECODES_DIR), { recursive: true });
6010
+ writeFileSync12(path, JSON.stringify({ token }, null, 2) + `
3924
6011
  `);
3925
6012
  return token;
3926
6013
  };
3927
6014
  var printQr = async (url) => {
3928
6015
  try {
3929
6016
  const qrcode = (await Promise.resolve().then(() => __toESM(require_main(), 1))).default;
3930
- await new Promise((resolve4) => qrcode.generate(url, { small: true }, (out) => {
6017
+ await new Promise((resolve5) => qrcode.generate(url, { small: true }, (out) => {
3931
6018
  log(out);
3932
- resolve4();
6019
+ resolve5();
3933
6020
  }));
3934
6021
  } catch {
3935
6022
  note("(couldn't render a QR code — open the URL below on the device)");
@@ -3945,7 +6032,7 @@ var dev = async (args) => {
3945
6032
  root = process.cwd();
3946
6033
  manifest = null;
3947
6034
  }
3948
- const name = manifest?.name ?? basename6(root);
6035
+ const name = manifest?.name ?? basename7(root);
3949
6036
  if (typeof globalThis.Bun !== "undefined") {
3950
6037
  warnErr("Running under Bun: the websocket reload channel doesn't work here — run `lecodes dev` with Node (or use ?t=poll on the device URL).");
3951
6038
  }
@@ -4032,15 +6119,15 @@ ${result.error}`);
4032
6119
  await printQr(server.url);
4033
6120
  log(`Scan the QR with the LeCodes app, or open: ${c.bold(server.url)}`);
4034
6121
  note(`Older desktop builds (no websocket bridge): ${server.url}?t=poll`);
4035
- if (existsSync13(join15(root, LECODES_DIR, "manifest.json")))
6122
+ if (existsSync15(join17(root, LECODES_DIR, "manifest.json")))
4036
6123
  note(`Token stored in ${LECODES_DIR}/dev.json (delete it to rotate the URL).`);
4037
6124
  log("");
4038
6125
  log(`Watching ${root} — save a file to hot-reload. Ctrl+C to stop.`);
4039
6126
  };
4040
6127
 
4041
6128
  // src/serverDiff.ts
4042
- import { readFileSync as readFileSync14 } from "node:fs";
4043
- import { basename as basename7 } from "node:path";
6129
+ import { readFileSync as readFileSync16 } from "node:fs";
6130
+ import { basename as basename8 } from "node:path";
4044
6131
  var isTextAsset = (t) => t === "text" || t === "shader" || t === "scene";
4045
6132
  var computeServerDiff = (project, scan) => {
4046
6133
  const pathById = buildPaths(project.assets);
@@ -4050,15 +6137,15 @@ var computeServerDiff = (project, scan) => {
4050
6137
  continue;
4051
6138
  serverByPath.set(pathById.get(asset.id), asset);
4052
6139
  }
4053
- const localByPath = new Map(scan.files.map((f) => [f.path, f]));
6140
+ const localByPath = new Map(scan.files.map((f2) => [f2.path, f2]));
4054
6141
  const entries = [];
4055
6142
  const paths = new Set([...serverByPath.keys(), ...localByPath.keys()]);
4056
6143
  for (const path of [...paths].sort()) {
4057
6144
  const server = serverByPath.get(path);
4058
6145
  const local = localByPath.get(path);
4059
6146
  if (local && !server) {
4060
- const text = isTextKind(classifyKind(basename7(path)));
4061
- entries.push({ path, status: "added", binary: !text, serverText: "", localText: text ? readFileSync14(local.absPath, "utf8") : undefined });
6147
+ const text = isTextKind(classifyKind(basename8(path)));
6148
+ entries.push({ path, status: "added", binary: !text, serverText: "", localText: text ? readFileSync16(local.absPath, "utf8") : undefined });
4062
6149
  continue;
4063
6150
  }
4064
6151
  if (server && !local) {
@@ -4069,12 +6156,12 @@ var computeServerDiff = (project, scan) => {
4069
6156
  if (server && local) {
4070
6157
  if (isTextAsset(server.type)) {
4071
6158
  const serverText = server.text ?? "";
4072
- const localText = readFileSync14(local.absPath, "utf8");
6159
+ const localText = readFileSync16(local.absPath, "utf8");
4073
6160
  if (serverText !== localText)
4074
6161
  entries.push({ path, status: "modified", binary: false, serverText, localText });
4075
6162
  } else {
4076
6163
  const serverSize = server.file?.size;
4077
- const localSize = Buffer.byteLength(readFileSync14(local.absPath));
6164
+ const localSize = Buffer.byteLength(readFileSync16(local.absPath));
4078
6165
  if (serverSize !== undefined && serverSize !== localSize) {
4079
6166
  entries.push({ path, status: "modified", binary: true });
4080
6167
  }
@@ -4227,8 +6314,8 @@ var diff = async (args) => {
4227
6314
  var statusLabel = (e) => e.status === "added" ? c.green(" added ") : e.status === "removed" ? c.red(" removed ") : c.yellow(" modified ");
4228
6315
 
4229
6316
  // src/commands/init.ts
4230
- import { existsSync as existsSync14, mkdirSync as mkdirSync10, writeFileSync as writeFileSync11 } from "node:fs";
4231
- import { basename as basename8, dirname as dirname8, join as join16, resolve as resolve4 } from "node:path";
6317
+ import { existsSync as existsSync16, mkdirSync as mkdirSync12, writeFileSync as writeFileSync13 } from "node:fs";
6318
+ import { basename as basename9, dirname as dirname10, join as join18, resolve as resolve5 } from "node:path";
4232
6319
  var STARTER2 = `// Welcome to LeCodes — the whole SDK is available as globals, no imports needed.
4233
6320
  // Run it live on a device with \`lecodes dev\`, or bundle it with \`lecodes compile -o app.js\`.
4234
6321
 
@@ -4264,24 +6351,55 @@ const screen = UIScreen(
4264
6351
 
4265
6352
  screen.open()
4266
6353
  `;
6354
+ var readmeTemplate = (name) => `# ${name}
6355
+
6356
+ A LeCodes app. The whole SDK is available as globals (\`UIScreen\`, \`UIText\`, \`Scene2D\`, …) —
6357
+ no imports needed. \`main.ts\` is the entry point.
6358
+
6359
+ ## Run it
6360
+
6361
+ \`\`\`
6362
+ lecodes dev # live on your phone: scan the QR with the LeCodes app, hot reload
6363
+ lecodes compile -o app.js # bundle to a single self-contained JS file
6364
+ \`\`\`
6365
+
6366
+ ## Go native
6367
+
6368
+ \`\`\`
6369
+ lecodes app init ios # generate a native Xcode shell in ios/
6370
+ lecodes app sync # embed the compiled bundle + plugins into it
6371
+ lecodes app open # open it in Xcode, build & run
6372
+ \`\`\`
6373
+
6374
+ ## Layout
6375
+
6376
+ - \`main.ts\` — the app. Add more top-level .ts files freely; the largest export-free one
6377
+ is picked as the entry point.
6378
+ - \`assets/\` — images/fonts the app references (created on demand)
6379
+ - \`tsconfig.json\` + \`.lecodes/types/\` — generated IDE types (rewritten by the CLI; don't edit)
6380
+ - \`.lecodesignore\` — files a future \`lecodes push\` would skip
6381
+
6382
+ Full command list: \`lecodes --help\`.
6383
+ `;
4267
6384
  var init2 = async (args) => {
4268
6385
  const dirArg = (args._[0] ?? ".").replace(/[/\\]+$/, "") || ".";
4269
- const root = resolve4(dirArg);
4270
- const name = basename8(root);
4271
- mkdirSync10(root, { recursive: true });
6386
+ const root = resolve5(dirArg);
6387
+ const name = basename9(root);
6388
+ mkdirSync12(root, { recursive: true });
4272
6389
  let created = 0;
4273
6390
  const write = (rel, content) => {
4274
- const abs = join16(root, rel);
4275
- if (existsSync14(abs)) {
6391
+ const abs = join18(root, rel);
6392
+ if (existsSync16(abs)) {
4276
6393
  note(`kept ${rel}`);
4277
6394
  return;
4278
6395
  }
4279
- mkdirSync10(dirname8(abs), { recursive: true });
4280
- writeFileSync11(abs, content);
6396
+ mkdirSync12(dirname10(abs), { recursive: true });
6397
+ writeFileSync13(abs, content);
4281
6398
  log(`${c.green("created")} ${rel}`);
4282
6399
  created++;
4283
6400
  };
4284
6401
  write("main.ts", STARTER2);
6402
+ write("README.md", readmeTemplate(name));
4285
6403
  write(".lecodesignore", DEFAULT_IGNORE_FILE);
4286
6404
  const wroteTypes = materializeTypesLocal(root);
4287
6405
  writeTsconfig(root);
@@ -4308,20 +6426,20 @@ var login = async (args) => {
4308
6426
  };
4309
6427
 
4310
6428
  // src/commands/render.ts
4311
- import { writeFileSync as writeFileSync12 } from "node:fs";
4312
- import { resolve as resolve5 } from "node:path";
6429
+ import { writeFileSync as writeFileSync14 } from "node:fs";
6430
+ import { resolve as resolve6 } from "node:path";
4313
6431
  var LOCAL_ASSET_SCHEME2 = "lecodesfile://";
4314
6432
  var DEVICE_SAFE_AREA = {
4315
- none: [0, 0, 0, 0],
4316
- iphone: [47, 0, 34, 0],
4317
- android: [24, 0, 24, 0]
6433
+ none: { insets: [0, 0, 0, 0], bars: [] },
6434
+ iphone: { insets: [59, 0, 34, 0], bars: [] },
6435
+ android: { insets: [24, 0, 48, 0], bars: ["top", "bottom"] }
4318
6436
  };
4319
6437
  var resolveSafeArea = (args) => {
4320
6438
  const explicit = flagStr(args, "safe-area");
4321
6439
  if (explicit) {
4322
6440
  const n = explicit.split(",").map((s) => Number(s.trim()));
4323
6441
  if (n.length === 4 && n.every((v) => Number.isFinite(v)))
4324
- return n;
6442
+ return { safeArea: n, safeAreaBars: [] };
4325
6443
  throw new CliError(`--safe-area expects "top,right,bottom,left" (4 numbers), got "${explicit}".`);
4326
6444
  }
4327
6445
  const device = flagStr(args, "device");
@@ -4329,7 +6447,7 @@ var resolveSafeArea = (args) => {
4329
6447
  const preset = DEVICE_SAFE_AREA[device];
4330
6448
  if (!preset)
4331
6449
  throw new CliError(`Unknown --device "${device}". Options: ${Object.keys(DEVICE_SAFE_AREA).join(", ")}.`);
4332
- return preset;
6450
+ return { safeArea: preset.insets, safeAreaBars: preset.bars };
4333
6451
  }
4334
6452
  return;
4335
6453
  };
@@ -4352,6 +6470,13 @@ var render = async (args) => {
4352
6470
  const useLocalAssets = !publicUrlFlag && !flagBool(args, "remote-assets");
4353
6471
  const compileEntries = useLocalAssets ? entries.map((e) => e.type === "resource" && e.absPath ? { ...e, fileSrc: encodeURIComponent(e.absPath) } : e) : entries;
4354
6472
  const warnings = useLocalAssets ? collectWarnings.filter((w) => !w.includes("No server URL")) : collectWarnings;
6473
+ await applyProjectFonts(compileEntries, useLocalAssets ? {
6474
+ mode: "vendor",
6475
+ root,
6476
+ vendorDir: root,
6477
+ vendorPrefix: "",
6478
+ makeResourceEntry: (path, absPath) => ({ path, type: "resource", fileSrc: encodeURIComponent(absPath), absPath })
6479
+ } : { mode: "cdn", root });
4355
6480
  note("Compiling…");
4356
6481
  const js = await compileProject2({
4357
6482
  entries: compileEntries,
@@ -4384,7 +6509,7 @@ var render = async (args) => {
4384
6509
  localAssets: useLocalAssets,
4385
6510
  timeMs: timeStr !== undefined ? Number(timeStr) : undefined,
4386
6511
  afterAnimations: !flagBool(args, "no-after-animations"),
4387
- safeArea: resolveSafeArea(args),
6512
+ ...resolveSafeArea(args),
4388
6513
  onConsole
4389
6514
  };
4390
6515
  const done = (flush) => flush(() => {
@@ -4405,7 +6530,7 @@ var render = async (args) => {
4405
6530
  if (isNullSink(pngFlag)) {
4406
6531
  note(`Discarded PNG (${pngFlag})`);
4407
6532
  } else {
4408
- writeFileSync12(resolve5(pngFlag), png);
6533
+ writeFileSync14(resolve6(pngFlag), png);
4409
6534
  note(`Wrote ${pngFlag} (${png.length} bytes, ${width}x${height})`);
4410
6535
  }
4411
6536
  return done((cb) => cb());
@@ -4420,7 +6545,7 @@ var render = async (args) => {
4420
6545
  return done((cb) => cb());
4421
6546
  }
4422
6547
  if (out) {
4423
- writeFileSync12(resolve5(out), result.text);
6548
+ writeFileSync14(resolve6(out), result.text);
4424
6549
  note(`Wrote ${out} (${result.text.length} bytes)`);
4425
6550
  return done((cb) => cb());
4426
6551
  }
@@ -4431,8 +6556,8 @@ var render = async (args) => {
4431
6556
  };
4432
6557
 
4433
6558
  // src/commands/pull.ts
4434
- import { existsSync as existsSync15, mkdirSync as mkdirSync11, rmSync, writeFileSync as writeFileSync13 } from "node:fs";
4435
- import { dirname as dirname9, join as join17 } from "node:path";
6559
+ import { existsSync as existsSync17, mkdirSync as mkdirSync13, rmSync as rmSync2, writeFileSync as writeFileSync15 } from "node:fs";
6560
+ import { dirname as dirname11, join as join19 } from "node:path";
4436
6561
  var pull = async (args) => {
4437
6562
  const root = findProjectRoot(process.cwd());
4438
6563
  const manifest = readManifest(root);
@@ -4452,9 +6577,9 @@ var pull = async (args) => {
4452
6577
  const remotePaths = new Set([...buildPaths(project.assets).entries()].filter(([id]) => project.assets.find((a) => a.id === id)?.type !== "folder").map(([, path]) => path));
4453
6578
  for (const path of Object.keys(manifest.files)) {
4454
6579
  if (!remotePaths.has(path)) {
4455
- const abs = join17(root, ...path.replace(/^\//, "").split("/"));
4456
- if (existsSync15(abs))
4457
- rmSync(abs);
6580
+ const abs = join19(root, ...path.replace(/^\//, "").split("/"));
6581
+ if (existsSync17(abs))
6582
+ rmSync2(abs);
4458
6583
  }
4459
6584
  }
4460
6585
  const files = await materializeAssets(root, project, apiUrl, token, progress);
@@ -4491,18 +6616,18 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
4491
6616
  const files = { ...manifest.files };
4492
6617
  for (const asset of targets) {
4493
6618
  const key = pathById.get(asset.id);
4494
- const abs = join17(root, ...key.replace(/^\//, "").split("/"));
4495
- mkdirSync11(dirname9(abs), { recursive: true });
6619
+ const abs = join19(root, ...key.replace(/^\//, "").split("/"));
6620
+ mkdirSync13(dirname11(abs), { recursive: true });
4496
6621
  let sha;
4497
6622
  if (asset.type === "file") {
4498
6623
  if (!asset.file?.src)
4499
6624
  continue;
4500
6625
  const bytes = await downloadBlob(apiUrl, asset.file.src);
4501
- writeFileSync13(abs, bytes);
6626
+ writeFileSync15(abs, bytes);
4502
6627
  sha = sha256(bytes);
4503
6628
  } else {
4504
6629
  const text = asset.text ?? "";
4505
- writeFileSync13(abs, text);
6630
+ writeFileSync15(abs, text);
4506
6631
  sha = sha256(text);
4507
6632
  }
4508
6633
  files[key] = { id: asset.id, type: kindOf2(asset.type), sha };
@@ -4514,8 +6639,8 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
4514
6639
  };
4515
6640
 
4516
6641
  // src/commands/push.ts
4517
- import { basename as basename9 } from "node:path";
4518
- import { readFileSync as readFileSync15 } from "node:fs";
6642
+ import { basename as basename10 } from "node:path";
6643
+ import { readFileSync as readFileSync17 } from "node:fs";
4519
6644
  var push = async (args) => {
4520
6645
  const root = findProjectRoot(process.cwd());
4521
6646
  const manifest = readManifest(root);
@@ -4569,14 +6694,14 @@ var push = async (args) => {
4569
6694
  let uploaded = 0;
4570
6695
  for (const file of scan.files) {
4571
6696
  const source = sourceFor(file.path);
4572
- const kind = source?.type ?? classifyKind(basename9(file.path));
6697
+ const kind = source?.type ?? classifyKind(basename10(file.path));
4573
6698
  const entry = { path: file.path, type: kind };
4574
6699
  if (source)
4575
6700
  entry.assetId = source.id;
4576
6701
  if (isTextKind(kind)) {
4577
- entry.text = readFileSync15(file.absPath, "utf8");
6702
+ entry.text = readFileSync17(file.absPath, "utf8");
4578
6703
  } else if (!source || source.sha !== file.sha) {
4579
- const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename9(file.path), readFileSync15(file.absPath));
6704
+ const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename10(file.path), readFileSync17(file.absPath));
4580
6705
  entry.blobFileId = blob.id;
4581
6706
  uploaded++;
4582
6707
  }
@@ -4592,7 +6717,7 @@ var push = async (args) => {
4592
6717
  reportPublish(res);
4593
6718
  return;
4594
6719
  }
4595
- const shaByPath = new Map(scan.files.map((f) => [f.path, f.sha]));
6720
+ const shaByPath = new Map(scan.files.map((f2) => [f2.path, f2.sha]));
4596
6721
  const newFiles = {};
4597
6722
  for (const node of res.nodes) {
4598
6723
  newFiles[node.path] = { id: node.id, type: node.type, sha: shaByPath.get(node.path) ?? "" };
@@ -4629,22 +6754,22 @@ var reportPublish = (res) => {
4629
6754
  };
4630
6755
 
4631
6756
  // src/commands/scene.ts
4632
- import { existsSync as existsSync17, readFileSync as readFileSync17, readdirSync as readdirSync7, statSync as statSync3, writeFileSync as writeFileSync14 } from "node:fs";
4633
- import { basename as basename10, dirname as dirname10, join as join19, resolve as resolve6 } from "node:path";
6757
+ import { existsSync as existsSync19, readFileSync as readFileSync19, readdirSync as readdirSync8, statSync as statSync4, writeFileSync as writeFileSync16 } from "node:fs";
6758
+ import { basename as basename11, dirname as dirname12, join as join21, resolve as resolve7 } from "node:path";
4634
6759
 
4635
6760
  // src/compile/sceneCompile.ts
4636
- import { existsSync as existsSync16, readFileSync as readFileSync16 } from "node:fs";
4637
- import { join as join18 } from "node:path";
6761
+ import { existsSync as existsSync18, readFileSync as readFileSync18 } from "node:fs";
6762
+ import { join as join20 } from "node:path";
4638
6763
  var TEXT_EXT = /\.(ts|js|json|svg|txt|md)$/i;
4639
6764
  var rel = (key) => key.replace(/^\//, "");
4640
6765
  var resolveHarness = (loadSceneHarness2) => {
4641
- const inRepo = join18(distRoot, "..", "projects", "src", "scene-editor");
6766
+ const inRepo = join20(distRoot, "..", "projects", "src", "scene-editor");
4642
6767
  try {
4643
- return loadSceneHarness2(existsSync16(inRepo) ? inRepo : undefined);
6768
+ return loadSceneHarness2(existsSync18(inRepo) ? inRepo : undefined);
4644
6769
  } catch {
4645
- const vendored = join18(distRoot, "runtime", "scene-harness.json");
4646
- if (existsSync16(vendored))
4647
- return JSON.parse(readFileSync16(vendored, "utf8"));
6770
+ const vendored = join20(distRoot, "runtime", "scene-harness.json");
6771
+ if (existsSync18(vendored))
6772
+ return JSON.parse(readFileSync18(vendored, "utf8"));
4648
6773
  throw new CliError("scene-editor harness sources not found — reinstall the CLI or set LECODES_SCENE_HARNESS_DIR.");
4649
6774
  }
4650
6775
  };
@@ -4656,7 +6781,7 @@ var compileSceneEditor = async (opts) => {
4656
6781
  for (const file of scan.files) {
4657
6782
  const path = "./" + rel(file.path);
4658
6783
  if (TEXT_EXT.test(path)) {
4659
- entries.push({ path, type: "text", text: readFileSync16(file.absPath, "utf8") });
6784
+ entries.push({ path, type: "text", text: readFileSync18(file.absPath, "utf8") });
4660
6785
  } else {
4661
6786
  entries.push({ path, type: "resource", fileSrc: "/project/" + rel(file.path) });
4662
6787
  }
@@ -4696,24 +6821,24 @@ var getContext2 = (args) => {
4696
6821
  manifest = null;
4697
6822
  const pathArg = args._[0];
4698
6823
  if (pathArg) {
4699
- const abs = resolve6(process.cwd(), pathArg);
4700
- if (existsSync17(abs) && statSync3(abs).isDirectory())
6824
+ const abs = resolve7(process.cwd(), pathArg);
6825
+ if (existsSync19(abs) && statSync4(abs).isDirectory())
4701
6826
  root = abs;
4702
- else if (abs.endsWith(".scene.ts") && existsSync17(abs))
4703
- root = dirname10(abs);
6827
+ else if (abs.endsWith(".scene.ts") && existsSync19(abs))
6828
+ root = dirname12(abs);
4704
6829
  else
4705
6830
  throw new CliError(`No such folder or .scene.ts: ${pathArg}`);
4706
6831
  }
4707
6832
  }
4708
- return { root, manifest, name: manifest?.name ?? basename10(root) };
6833
+ return { root, manifest, name: manifest?.name ?? basename11(root) };
4709
6834
  };
4710
6835
  var writeMcpConfig2 = (root, port) => {
4711
- const path = join19(root, ".mcp.json");
6836
+ const path = join21(root, ".mcp.json");
4712
6837
  const entry = { type: "http", url: `http://localhost:${port}/mcp` };
4713
6838
  let config = {};
4714
- if (existsSync17(path)) {
6839
+ if (existsSync19(path)) {
4715
6840
  try {
4716
- config = JSON.parse(readFileSync17(path, "utf8"));
6841
+ config = JSON.parse(readFileSync19(path, "utf8"));
4717
6842
  } catch {
4718
6843
  warnErr(".mcp.json exists but isn't valid JSON — left it untouched. Add the lecodes-scene server by hand.");
4719
6844
  return;
@@ -4726,17 +6851,17 @@ var writeMcpConfig2 = (root, port) => {
4726
6851
  return;
4727
6852
  }
4728
6853
  config.mcpServers["lecodes-scene"] = entry;
4729
- writeFileSync14(path, JSON.stringify(config, null, 2) + `
6854
+ writeFileSync16(path, JSON.stringify(config, null, 2) + `
4730
6855
  `);
4731
6856
  note(`${existing ? "updated" : "created"} .mcp.json (lecodes-scene MCP → ${entry.url})`);
4732
6857
  };
4733
6858
  var countScenes = (dir) => {
4734
6859
  let n = 0;
4735
- for (const name of readdirSync7(dir)) {
6860
+ for (const name of readdirSync8(dir)) {
4736
6861
  if (name === "node_modules" || name.startsWith("."))
4737
6862
  continue;
4738
- const full = join19(dir, name);
4739
- if (statSync3(full).isDirectory())
6863
+ const full = join21(dir, name);
6864
+ if (statSync4(full).isDirectory())
4740
6865
  n += countScenes(full);
4741
6866
  else if (name.endsWith(".scene.ts"))
4742
6867
  n++;
@@ -4788,12 +6913,12 @@ var status = async (_args) => {
4788
6913
  info(c.dim("Working tree clean — nothing to push.") + ignoredNote);
4789
6914
  return;
4790
6915
  }
4791
- for (const f of diff2.added) {
4792
- if (!diff2.moves.has(f.path))
4793
- log(c.green(` added ${f.path}`));
6916
+ for (const f2 of diff2.added) {
6917
+ if (!diff2.moves.has(f2.path))
6918
+ log(c.green(` added ${f2.path}`));
4794
6919
  }
4795
- for (const f of diff2.modified)
4796
- log(c.yellow(` modified ${f.path}`));
6920
+ for (const f2 of diff2.modified)
6921
+ log(c.yellow(` modified ${f2.path}`));
4797
6922
  for (const [to, from] of diff2.moves)
4798
6923
  log(c.cyan(` moved ${from} → ${to}`));
4799
6924
  const movedFrom = new Set(diff2.moves.values());
@@ -4806,21 +6931,22 @@ var status = async (_args) => {
4806
6931
  };
4807
6932
 
4808
6933
  // src/index.ts
4809
- import { existsSync as existsSync19, realpathSync as realpathSync2 } from "node:fs";
4810
- import { delimiter as delimiter2, dirname as dirname12, join as join21 } from "node:path";
6934
+ import { existsSync as existsSync21, readFileSync as readFileSync21, realpathSync as realpathSync2 } from "node:fs";
6935
+ import { createRequire as createRequire4 } from "node:module";
6936
+ import { delimiter as delimiter2, dirname as dirname14, join as join23 } from "node:path";
4811
6937
 
4812
6938
  // src/commands/install.ts
4813
- import { execFileSync } from "node:child_process";
4814
- import { appendFileSync as appendFileSync2, chmodSync, cpSync, existsSync as existsSync18, mkdirSync as mkdirSync12, readFileSync as readFileSync18, realpathSync, rmSync as rmSync2, symlinkSync } from "node:fs";
4815
- import { homedir as homedir2 } from "node:os";
4816
- import { basename as basename11, delimiter, dirname as dirname11, join as join20, resolve as resolve7, sep } from "node:path";
6939
+ import { execFileSync as execFileSync2 } from "node:child_process";
6940
+ import { appendFileSync as appendFileSync2, chmodSync, cpSync as cpSync2, existsSync as existsSync20, mkdirSync as mkdirSync14, readFileSync as readFileSync20, realpathSync, rmSync as rmSync3, symlinkSync } from "node:fs";
6941
+ import { homedir as homedir3 } from "node:os";
6942
+ import { basename as basename12, delimiter, dirname as dirname13, join as join22, resolve as resolve8, sep } from "node:path";
4817
6943
  var isWin = process.platform === "win32";
4818
- var HOME = homedir2();
6944
+ var HOME = homedir3();
4819
6945
  var safeRealpath = (p) => {
4820
6946
  try {
4821
6947
  return realpathSync(p);
4822
6948
  } catch {
4823
- return resolve7(p);
6949
+ return resolve8(p);
4824
6950
  }
4825
6951
  };
4826
6952
  var onPath = (dir) => {
@@ -4836,25 +6962,25 @@ var onPath = (dir) => {
4836
6962
  });
4837
6963
  };
4838
6964
  var install = async (args) => {
4839
- const bundle = dirname11(process.execPath);
4840
- const exeName = basename11(process.execPath);
6965
+ const bundle = dirname13(process.execPath);
6966
+ const exeName = basename12(process.execPath);
4841
6967
  const chiselName = isWin ? "chisel.exe" : "chisel";
4842
- if (!existsSync18(join20(bundle, "runtime")) || !existsSync18(join20(bundle, chiselName))) {
6968
+ if (!existsSync20(join22(bundle, "runtime")) || !existsSync20(join22(bundle, chiselName))) {
4843
6969
  throw new CliError("`lecodes install` only works from the packaged release build (the archive containing lecodes + " + "chisel + runtime/). You appear to be running from source or an npm install — install globally " + "with your package manager there instead.");
4844
6970
  }
4845
- const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f) => existsSync18(join20(bundle, f)));
6971
+ const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f2) => existsSync20(join22(bundle, f2)));
4846
6972
  if (isWin)
4847
6973
  return installWindows(args, bundle, companions);
4848
6974
  return installUnix(args, bundle, companions);
4849
6975
  };
4850
6976
  var copyBundle = (bundle, target, companions) => {
4851
- mkdirSync12(target, { recursive: true });
6977
+ mkdirSync14(target, { recursive: true });
4852
6978
  if (safeRealpath(target) === safeRealpath(bundle))
4853
6979
  return;
4854
- for (const f of companions) {
4855
- const dest = join20(target, f);
4856
- rmSync2(dest, { recursive: true, force: true });
4857
- cpSync(join20(bundle, f), dest, { recursive: true });
6980
+ for (const f2 of companions) {
6981
+ const dest = join22(target, f2);
6982
+ rmSync3(dest, { recursive: true, force: true });
6983
+ cpSync2(join22(bundle, f2), dest, { recursive: true });
4858
6984
  }
4859
6985
  };
4860
6986
  var permError = (e, dir, system) => {
@@ -4866,41 +6992,41 @@ var permError = (e, dir, system) => {
4866
6992
  };
4867
6993
  var installUnix = (args, bundle, companions) => {
4868
6994
  const system = flagBool(args, "system");
4869
- const libDir = resolve7(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join20(HOME, ".local", "lib", "lecodes-cli")));
4870
- const binDir = resolve7(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join20(HOME, ".local", "bin")));
4871
- const link = join20(binDir, "lecodes");
6995
+ const libDir = resolve8(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join22(HOME, ".local", "lib", "lecodes-cli")));
6996
+ const binDir = resolve8(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join22(HOME, ".local", "bin")));
6997
+ const link = join22(binDir, "lecodes");
4872
6998
  try {
4873
6999
  copyBundle(bundle, libDir, companions);
4874
- chmodSync(join20(libDir, "lecodes"), 493);
4875
- chmodSync(join20(libDir, "chisel"), 493);
7000
+ chmodSync(join22(libDir, "lecodes"), 493);
7001
+ chmodSync(join22(libDir, "chisel"), 493);
4876
7002
  } catch (e) {
4877
7003
  throw permError(e, libDir, system);
4878
7004
  }
4879
7005
  try {
4880
- mkdirSync12(binDir, { recursive: true });
4881
- rmSync2(link, { force: true });
4882
- symlinkSync(join20(libDir, "lecodes"), link);
7006
+ mkdirSync14(binDir, { recursive: true });
7007
+ rmSync3(link, { force: true });
7008
+ symlinkSync(join22(libDir, "lecodes"), link);
4883
7009
  } catch (e) {
4884
7010
  throw permError(e, binDir, system);
4885
7011
  }
4886
7012
  success(`Installed to ${c.bold(libDir)}`);
4887
- log(` ${c.dim(link + " → " + join20(libDir, "lecodes"))}`);
7013
+ log(` ${c.dim(link + " → " + join22(libDir, "lecodes"))}`);
4888
7014
  finishPathUnix(args, binDir);
4889
7015
  };
4890
7016
  var shellRc = () => {
4891
- const shell = basename11(process.env.SHELL ?? "");
7017
+ const shell = basename12(process.env.SHELL ?? "");
4892
7018
  if (shell === "zsh")
4893
- return { file: join20(HOME, ".zshrc"), name: "~/.zshrc" };
7019
+ return { file: join22(HOME, ".zshrc"), name: "~/.zshrc" };
4894
7020
  if (shell === "bash")
4895
- return { file: join20(HOME, ".bashrc"), name: "~/.bashrc" };
4896
- return { file: join20(HOME, ".profile"), name: "~/.profile" };
7021
+ return { file: join22(HOME, ".bashrc"), name: "~/.bashrc" };
7022
+ return { file: join22(HOME, ".profile"), name: "~/.profile" };
4897
7023
  };
4898
7024
  var PATH_MARKER = "# Added by `lecodes install`";
4899
7025
  var appendPathToShellRc = (binDir) => {
4900
7026
  const { file, name } = shellRc();
4901
7027
  const line = `export PATH="${binDir}:$PATH"`;
4902
7028
  try {
4903
- const existing = existsSync18(file) ? readFileSync18(file, "utf8") : "";
7029
+ const existing = existsSync20(file) ? readFileSync20(file, "utf8") : "";
4904
7030
  if (existing.includes(line))
4905
7031
  return name;
4906
7032
  appendFileSync2(file, `${existing && !existing.endsWith(`
@@ -4938,7 +7064,7 @@ var finishPathUnix = (args, binDir) => {
4938
7064
  log(` • or install system-wide (no PATH edit): ${c.bold("sudo lecodes install --system")}`);
4939
7065
  };
4940
7066
  var installWindows = (args, bundle, companions) => {
4941
- const target = resolve7(flagStr(args, "dir") ?? join20(process.env.LOCALAPPDATA ?? join20(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
7067
+ const target = resolve8(flagStr(args, "dir") ?? join22(process.env.LOCALAPPDATA ?? join22(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
4942
7068
  try {
4943
7069
  copyBundle(bundle, target, companions);
4944
7070
  } catch (e) {
@@ -4966,7 +7092,7 @@ var installWindows = (args, bundle, companions) => {
4966
7092
  var addToUserPathWindows = (dir) => {
4967
7093
  const script = "$d = $env:LECODES_INSTALL_DIR;" + "$p = [Environment]::GetEnvironmentVariable('Path','User'); if (-not $p) { $p = '' };" + "$parts = @($p.Split(';') | Where-Object { $_ -ne '' });" + "if ($parts -notcontains $d) { [Environment]::SetEnvironmentVariable('Path', (@($parts + $d) -join ';'), 'User') }";
4968
7094
  try {
4969
- execFileSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], { stdio: "ignore", env: { ...process.env, LECODES_INSTALL_DIR: dir } });
7095
+ execFileSync2("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], { stdio: "ignore", env: { ...process.env, LECODES_INSTALL_DIR: dir } });
4970
7096
  return true;
4971
7097
  } catch {
4972
7098
  return false;
@@ -4975,9 +7101,9 @@ var addToUserPathWindows = (dir) => {
4975
7101
 
4976
7102
  // src/index.ts
4977
7103
  var STANDALONE = (() => {
4978
- const dir = dirname12(process.execPath);
7104
+ const dir = dirname14(process.execPath);
4979
7105
  const chisel = process.platform === "win32" ? "chisel.exe" : "chisel";
4980
- return existsSync19(join21(dir, "runtime")) && existsSync19(join21(dir, chisel));
7106
+ return existsSync21(join23(dir, "runtime")) && existsSync21(join23(dir, chisel));
4981
7107
  })();
4982
7108
  var INSTALLED = STANDALONE && (() => {
4983
7109
  const exe = process.platform === "win32" ? "lecodes.exe" : "lecodes";
@@ -4992,8 +7118,8 @@ var INSTALLED = STANDALONE && (() => {
4992
7118
  if (!d)
4993
7119
  return false;
4994
7120
  try {
4995
- const cand = join21(d, exe);
4996
- return existsSync19(cand) && realpathSync2(cand) === self;
7121
+ const cand = join23(d, exe);
7122
+ return existsSync21(cand) && realpathSync2(cand) === self;
4997
7123
  } catch {
4998
7124
  return false;
4999
7125
  }
@@ -5080,12 +7206,70 @@ ${c.bold("Commands:")}
5080
7206
  --port <n> editor-server port (default 4488)
5081
7207
  --no-open don't open the browser
5082
7208
  --no-mcp don't register the MCP server in .mcp.json
7209
+ app init ios Generate a native Xcode shell in <project>/ios/ (offline-first:
7210
+ runs the bundled app.js; ios/ is never pushed to LeCodes)
7211
+ --name <s> display name (default: app.json / folder name)
7212
+ --bundle-id <id> iOS bundle identifier
7213
+ --team <id> Apple development team id
7214
+ --variant <v> SDK variant: core (default) or full (3D/AR)
7215
+ app sync Make the shell match the project: compile the bundle + embed
7216
+ its assets into ios/App/Resources/ (offline by default, also
7217
+ for server projects; app.json "resources": {"remote": [globs]}
7218
+ keeps heavy assets streaming from the server), vendor app.json
7219
+ plugins (pinned in plugins.lock.json), regenerate the
7220
+ CLI-owned shell files
7221
+ app plugin <list|add|remove> Manage native plugins in app.json (then run 'lecodes app sync')
7222
+ add takes a known id (camera, qr-scanner) or github:owner/repo[@tag]
7223
+ app open [ios] Open the native shell in its IDE (Xcode; Android Studio later)
7224
+ app doctor Preflight checks for building/uploading the shell
7225
+ app build ios Sync, archive and export the .ipa (automatic signing)
7226
+ --configuration <c> build configuration (default Release)
7227
+ --method <m> export method (default app-store-connect)
7228
+ --no-export stop after the archive
7229
+ --no-sync skip the lecodes app sync step
7230
+ app upload ios Upload the archive to App Store Connect (then bumps buildNumber)
7231
+ --key-path <p8> --key-id <id> --issuer-id <id>
7232
+ ASC API key; stored in ~/.lecodes/asc.json
5083
7233
 
5084
7234
  Config lives in ~/.lecodes/config.json (override with LECODES_API / LECODES_TOKEN).`;
5085
- var commands = { init: init2, login, create, clone, status, diff, pull, push, compile: compile2, preview: previewLegacy, render, design, scene, dev };
7235
+ var commands = { init: init2, login, create, clone, status, diff, pull, push, compile: compile2, preview: previewLegacy, render, design, scene, dev, app };
5086
7236
  if (STANDALONE)
5087
7237
  commands.install = install;
5088
7238
  var readVersion = () => package_default.version ?? "unknown";
7239
+ var OPTIONAL_PEERS = [
7240
+ ["lecodes-renderer", "lecodes-renderer/headless"],
7241
+ ["lecodes-design", "lecodes-design/server"],
7242
+ ["lecodes-3d-editor", "lecodes-3d-editor/server"]
7243
+ ];
7244
+ var peerVersion = (name, probe) => {
7245
+ try {
7246
+ const require2 = createRequire4(import.meta.url);
7247
+ let dir = dirname14(require2.resolve(probe));
7248
+ for (let depth = 0;depth < 16; depth++) {
7249
+ const manifest = join23(dir, "package.json");
7250
+ if (existsSync21(manifest)) {
7251
+ const parsed = JSON.parse(readFileSync21(manifest, "utf8"));
7252
+ if (parsed.name === name)
7253
+ return parsed.version ?? "unknown";
7254
+ }
7255
+ const parent = dirname14(dir);
7256
+ if (parent === dir)
7257
+ break;
7258
+ dir = parent;
7259
+ }
7260
+ } catch {}
7261
+ return null;
7262
+ };
7263
+ var printVersion = () => {
7264
+ const rows = [["lecodes-cli", readVersion()]];
7265
+ for (const [name, probe] of OPTIONAL_PEERS) {
7266
+ const version = peerVersion(name, probe);
7267
+ rows.push([name, version ?? c.dim("not installed")]);
7268
+ }
7269
+ const width = Math.max(...rows.map(([label]) => label.length));
7270
+ for (const [label, value] of rows)
7271
+ log(`${label.padEnd(width)} ${value}`);
7272
+ };
5089
7273
  var main = async () => {
5090
7274
  const argv = process.argv.slice(2);
5091
7275
  const command = argv[0];
@@ -5094,7 +7278,7 @@ var main = async () => {
5094
7278
  return;
5095
7279
  }
5096
7280
  if (command === "--version" || command === "-v" || command === "version") {
5097
- log(readVersion());
7281
+ printVersion();
5098
7282
  return;
5099
7283
  }
5100
7284
  const run = commands[command];