lecodes-cli 0.10.4 → 0.12.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 (54) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +48887 -6261
  3. package/package.json +4 -3
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk/compile/aspectMacro.ts +42 -0
  6. package/runtime/sdk/compile/assetIconMacro.ts +382 -0
  7. package/runtime/sdk/compile/assetMacro.ts +45 -0
  8. package/runtime/sdk/compile/assetName.ts +50 -0
  9. package/runtime/sdk/compile/bundler.ts +244 -0
  10. package/runtime/sdk/compile/compileProject.ts +106 -0
  11. package/runtime/sdk/compile/detectEntry.ts +125 -0
  12. package/runtime/sdk/compile/fontMacro.ts +459 -0
  13. package/runtime/sdk/compile/fontRegistry.ts +78 -0
  14. package/runtime/sdk/compile/header.ts +55 -0
  15. package/runtime/sdk/compile/index.ts +53 -0
  16. package/runtime/sdk/compile/libraryImports.ts +52 -0
  17. package/runtime/sdk/compile/sceneEditor.ts +78 -0
  18. package/runtime/sdk/compile/sfnt.ts +98 -0
  19. package/runtime/sdk/compile/sourcemap.ts +25 -0
  20. package/runtime/sdk/g2/Node2D.ts +3 -0
  21. package/runtime/sdk/g2/SpriteAnimation.ts +101 -20
  22. package/runtime/sdk/g2/SpriteSheet.ts +166 -0
  23. package/runtime/sdk/g2/Tileset.ts +71 -0
  24. package/runtime/sdk/g2/autotile.ts +394 -0
  25. package/runtime/sdk/g2/cells.ts +91 -0
  26. package/runtime/sdk/g2/defineScene2d.ts +381 -0
  27. package/runtime/sdk/g2/scenarios2d.ts +69 -0
  28. package/runtime/sdk/inject.ts +18 -0
  29. package/runtime/sdk/runtime/app.ts +21 -3
  30. package/runtime/sdk/scene/defineScene.ts +10 -70
  31. package/runtime/sdk/scene/grammar.ts +85 -0
  32. package/runtime/sdk/ui/NativeView.ts +9 -12
  33. package/runtime/sdk/ui/UI.ts +9 -1
  34. package/runtime/sdk/ui/UIBottomSheet.ts +10 -20
  35. package/runtime/sdk/ui/UIButton.ts +19 -36
  36. package/runtime/sdk/ui/UIContainer.ts +41 -103
  37. package/runtime/sdk/ui/UIImage.ts +10 -18
  38. package/runtime/sdk/ui/UIInput.ts +14 -26
  39. package/runtime/sdk/ui/UIModal.ts +10 -20
  40. package/runtime/sdk/ui/UINode.ts +142 -36
  41. package/runtime/sdk/ui/UIPager.ts +29 -28
  42. package/runtime/sdk/ui/UIPopover.ts +10 -16
  43. package/runtime/sdk/ui/UIScreen.ts +23 -40
  44. package/runtime/sdk/ui/UIScrollable.ts +37 -47
  45. package/runtime/sdk/ui/UISpacer.ts +4 -8
  46. package/runtime/sdk/ui/UITabs.ts +175 -0
  47. package/runtime/sdk/ui/UIText.ts +7 -13
  48. package/runtime/sdk/ui/UIVideo.ts +11 -13
  49. package/runtime/sdk/ui/UIVirtualizedList.ts +43 -49
  50. package/runtime/sdk/ui/UIWidget.ts +24 -36
  51. package/runtime/sdk/ui/fonts.ts +3 -0
  52. package/runtime/sdk/ui/router.ts +13 -0
  53. package/runtime/sdk/ui/theme.ts +21 -30
  54. package/runtime/sdk-types.json +1 -1
@@ -0,0 +1,459 @@
1
+ // `font(...)` — the compile-time font macro (docs/font-system-plan.md). Using a font IS installing
2
+ // it: every call compiles to the family-name string literal's identifier, and the faces it implies
3
+ // are registered by a generated sibling module the file imports (ESM imports hoist, so registration
4
+ // runs before the file's own body — no app-side gating, ever).
5
+ //
6
+ // theme({ fontFamily: font("manrope") }) // registry family → "Manrope"
7
+ // font("ibm-plex-sans", { weights: [400, 600] })// narrow/extend the face set
8
+ // font("./fonts/Brand.ttf") // project file → real family from its name table
9
+ //
10
+ // Registry ids resolve against the vendored snapshot (fontRegistry.ts). Two source modes:
11
+ // cdn — faces stay remote: the generated code bakes registry URLs, picking woff2/ttf via the
12
+ // host's `_creatorUI.fontFormat` (absent → ttf). Platform/server-backed compiles.
13
+ // vendor — the caller downloads faces into the project (assets/fonts/ + fonts.lock.json) and
14
+ // hands back their entry paths; faces then ride the normal asset pipeline (dev URLs,
15
+ // `id:` + fetchLocal, lecodesfile://) — fully offline, and local faces register
16
+ // synchronously at boot (zero swap).
17
+ //
18
+ // Project-file fonts (path-shaped arg) are entries already; the file's family/weight/italic are
19
+ // read from the font itself (sfnt.ts). Registration is idempotent per (family, weight, style) —
20
+ // hosts dedupe — so several files declaring the same font cost nothing.
21
+ //
22
+ // A file that DECLARES its own `font` identifier (function/const/import) is skipped entirely —
23
+ // real scoping wins, the macro only exists where the global would.
24
+
25
+ import { FONT_REGISTRY, faceFileName, fontRegistryBase } from "./fontRegistry"
26
+ import { parseSfnt } from "./sfnt"
27
+
28
+ export type FontMacroEntry = { path: string, type: string, text?: string }
29
+
30
+ export type FontCall = {
31
+ /** Index of `font` in the source. */
32
+ start: number
33
+ /** Index just past the call's closing `)`. */
34
+ end: number
35
+ /** The literal argument. */
36
+ arg: string
37
+ kind: "registry" | "file"
38
+ /** Registry id (kind "registry"). */
39
+ id?: string
40
+ /** `{ weights }` option (kind "registry"). */
41
+ weights?: number[]
42
+ /** `{ italic: true }` option — italic faces are added alongside the upright ones. */
43
+ italic?: boolean
44
+ /** Root-relative entry path of the font file (kind "file"). */
45
+ filePath?: string
46
+ /** Set when the call is malformed — a hard compile error. */
47
+ error?: string
48
+ }
49
+
50
+ export type FontFaceRef = {
51
+ family: string
52
+ weight: number
53
+ italic: boolean
54
+ /** cdn: extension-less URL base (`…/manrope-400.`) — append woff2/ttf. entry: a project entry. */
55
+ source: { kind: "cdn", urlBase: string } | { kind: "entry", path: string }
56
+ }
57
+
58
+ export type FontMacroOptions = {
59
+ mode: "cdn" | "vendor"
60
+ /** Registry base for cdn URL baking (default: fontRegistryBase()). */
61
+ registryBase?: string
62
+ /** vendor mode: materialize one registry face into the project and return its ENTRY path
63
+ * (downloading + lockfile bookkeeping is the caller's job). Called once per unique face. */
64
+ vendorFace?: (face: { id: string, family: string, weight: number, italic: boolean, fileName: string }) => string | Promise<string>
65
+ /** Bytes of a project entry, for `font('./x.ttf')` (CLI: disk; backend: uploads). */
66
+ readBytes?: (entryPath: string) => Uint8Array | null
67
+ }
68
+
69
+ const FONT_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
70
+ const CODE_FILE = /\.[tj]sx?$/
71
+ /** A file that declares/imports its own `font` — the macro must not touch it. */
72
+ const DECLARES_FONT = /\b(?:function|const|let|var|class)\s+font\b|\bimport\s+(?:\{[^}]*\bfont\b[^}]*\}|font)\s+from\b/
73
+
74
+ // ---- lexing (comment/string/template aware; same approach as the assetIcon macro) ---------------
75
+
76
+ const isIdentStart = (c: string) => (c >= "A" && c <= "Z") || (c >= "a" && c <= "z") || c === "_" || c === "$"
77
+ const isIdentPart = (c: string) => isIdentStart(c) || (c >= "0" && c <= "9")
78
+
79
+ const skipString = (src: string, start: number): number => {
80
+ const quote = src[start]
81
+ const n = src.length
82
+ let i = start + 1
83
+ while (i < n) {
84
+ const c = src[i]
85
+ if (c === "\\") { i += 2; continue }
86
+ if (quote === "`" && c === "$" && src[i + 1] === "{") { i = skipBraces(src, i + 1); continue }
87
+ if (c === quote) return i + 1
88
+ i++
89
+ }
90
+ return n
91
+ }
92
+
93
+ const skipBraces = (src: string, open: number): number => {
94
+ const n = src.length
95
+ let depth = 0
96
+ let i = open
97
+ while (i < n) {
98
+ const c = src[i]
99
+ if (c === '"' || c === "'" || c === "`") { i = skipString(src, i); continue }
100
+ if (c === "{") depth++
101
+ else if (c === "}") { depth--; if (depth === 0) return i + 1 }
102
+ i++
103
+ }
104
+ return n
105
+ }
106
+
107
+ const matchParen = (src: string, open: number): number => {
108
+ const n = src.length
109
+ let depth = 0
110
+ let i = open
111
+ while (i < n) {
112
+ const c = src[i]
113
+ if (c === '"' || c === "'" || c === "`") { i = skipString(src, i); continue }
114
+ if (c === "/" && src[i + 1] === "/") { i += 2; while (i < n && src[i] !== "\n") i++; continue }
115
+ if (c === "/" && src[i + 1] === "*") { i += 2; while (i < n && !(src[i] === "*" && src[i + 1] === "/")) i++; i += 2; continue }
116
+ if (c === "(") depth++
117
+ else if (c === ")") { depth--; if (depth === 0) return i }
118
+ i++
119
+ }
120
+ return -1
121
+ }
122
+
123
+ const splitTopLevel = (text: string, delim: string): string[] => {
124
+ const parts: string[] = []
125
+ const n = text.length
126
+ let depth = 0
127
+ let last = 0
128
+ let i = 0
129
+ while (i < n) {
130
+ const c = text[i]
131
+ if (c === '"' || c === "'" || c === "`") { i = skipString(text, i); continue }
132
+ if (c === "(" || c === "[" || c === "{") depth++
133
+ else if (c === ")" || c === "]" || c === "}") depth = Math.max(0, depth - 1)
134
+ else if (depth === 0 && c === delim) { parts.push(text.slice(last, i)); last = i + 1 }
135
+ i++
136
+ }
137
+ parts.push(text.slice(last))
138
+ return parts
139
+ }
140
+
141
+ const stringLiteralValue = (text: string): string | null => {
142
+ const t = text.trim()
143
+ if (t.length < 2) return null
144
+ const q = t[0]
145
+ if ((q !== '"' && q !== "'" && q !== "`") || t[t.length - 1] !== q) return null
146
+ const inner = t.slice(1, -1)
147
+ if (q === "`" && inner.includes("${")) return null
148
+ if (inner.includes(q) && !inner.includes("\\" + q)) return null
149
+ return inner.replace(/\\(.)/g, "$1")
150
+ }
151
+
152
+ const prevNonSpace = (src: string, i: number): string => {
153
+ let k = i - 1
154
+ while (k >= 0 && /\s/.test(src[k])) k--
155
+ return k >= 0 ? src[k] : ""
156
+ }
157
+
158
+ // ---- scanning -----------------------------------------------------------------------------------
159
+
160
+ /** Find every bare `font(...)` call. Pure and offline; positions index into `source`.
161
+ * Malformed calls come back with `.error` set (a hard compile error). */
162
+ export const scanFontCalls = (source: string): FontCall[] => {
163
+ const calls: FontCall[] = []
164
+ const n = source.length
165
+ let i = 0
166
+ while (i < n) {
167
+ const c = source[i]
168
+ if (c === '"' || c === "'" || c === "`") { i = skipString(source, i); continue }
169
+ if (c === "/" && source[i + 1] === "/") { i += 2; while (i < n && source[i] !== "\n") i++; continue }
170
+ if (c === "/" && source[i + 1] === "*") { i += 2; while (i < n && !(source[i] === "*" && source[i + 1] === "/")) i++; i += 2; continue }
171
+ if (isIdentStart(c)) {
172
+ let j = i + 1
173
+ while (j < n && isIdentPart(source[j])) j++
174
+ if (source.slice(i, j) === "font" && prevNonSpace(source, i) !== ".") {
175
+ const call = parseCall(source, i, j)
176
+ if (call) { calls.push(call); i = call.end; continue }
177
+ }
178
+ i = j
179
+ continue
180
+ }
181
+ i++
182
+ }
183
+ return calls
184
+ }
185
+
186
+ const parseCall = (src: string, start: number, identEnd: number): FontCall | null => {
187
+ const n = src.length
188
+ let i = identEnd
189
+ while (i < n && /\s/.test(src[i])) i++
190
+ if (src[i] !== "(") return null
191
+ const close = matchParen(src, i)
192
+ if (close === -1) return null
193
+
194
+ const call: FontCall = { start, end: close + 1, arg: "", kind: "registry" }
195
+ const args = splitTopLevel(src.slice(i + 1, close), ",")
196
+ const arg = stringLiteralValue(args[0] ?? "")
197
+ if (arg === null) {
198
+ call.error = `font(...) needs a string-literal argument — a registry id ("manrope") or a project path ("./fonts/Brand.ttf"). Got \`${(args[0] ?? "").trim() || "()"}\`.`
199
+ return call
200
+ }
201
+ call.arg = arg
202
+
203
+ if (arg.startsWith("./") || arg.startsWith("../")) {
204
+ call.kind = "file"
205
+ if (!/\.(ttf|otf)$/i.test(arg)) {
206
+ call.error = `font("${arg}"): project fonts must be .ttf/.otf files.`
207
+ return call
208
+ }
209
+ if (args.length > 1 && args.slice(1).some((a) => a.trim())) {
210
+ call.error = `font("${arg}"): a project font takes no options — weight and style come from the file itself.`
211
+ return call
212
+ }
213
+ return call
214
+ }
215
+
216
+ if (arg === "serif" || arg === "sans-serif" || arg === "monospace" || arg === "monospaced") {
217
+ call.error = `font("${arg}"): system font families need no font() — pass the string straight to fontFamily, e.g. .style({ fontFamily: "${arg}" }).`
218
+ return call
219
+ }
220
+ if (!FONT_ID.test(arg)) {
221
+ call.error = `font("${arg}"): expected a registry id (lowercase [a-z0-9-], e.g. "manrope") or a relative path ("./fonts/Brand.ttf").`
222
+ return call
223
+ }
224
+ call.kind = "registry"
225
+ call.id = arg
226
+
227
+ const optsText = args[1]?.trim()
228
+ if (optsText) {
229
+ const err = parseOptions(optsText, call)
230
+ if (err) { call.error = err; return call }
231
+ }
232
+ return call
233
+ }
234
+
235
+ /** Parse `{ weights: [400, 700], italic: true }` — literals only, unknown keys are errors. */
236
+ const parseOptions = (optsText: string, call: FontCall): string | null => {
237
+ if (!optsText.startsWith("{") || !optsText.endsWith("}")) {
238
+ return `font("${call.arg}", opts): opts must be an object literal like { weights: [400, 700] }.`
239
+ }
240
+ for (const prop of splitTopLevel(optsText.slice(1, -1), ",")) {
241
+ if (!prop.trim()) continue
242
+ const kv = splitTopLevel(prop, ":")
243
+ if (kv.length < 2) return `font("${call.arg}"): couldn't parse option \`${prop.trim()}\`.`
244
+ const key = kv[0].trim().replace(/^["'`]|["'`]$/g, "")
245
+ const raw = kv.slice(1).join(":").trim()
246
+ if (key === "weights") {
247
+ const m = /^\[\s*(\d+(?:\s*,\s*\d+)*)\s*,?\s*\]$/.exec(raw)
248
+ if (!m) return `font("${call.arg}"): weights must be a literal number array, e.g. { weights: [400, 700] }.`
249
+ call.weights = m[1].split(",").map((s) => parseInt(s.trim(), 10))
250
+ } else if (key === "italic") {
251
+ if (raw !== "true" && raw !== "false") return `font("${call.arg}"): italic must be a literal true/false.`
252
+ call.italic = raw === "true"
253
+ } else {
254
+ return `font("${call.arg}"): unknown option "${key}" (did you mean weights/italic?).`
255
+ }
256
+ }
257
+ return null
258
+ }
259
+
260
+ // ---- path helpers (entry paths are "/"-separated; a leading "/" is tolerated) -------------------
261
+
262
+ const norm = (p: string) => p.replace(/\\/g, "/").replace(/^\//, "")
263
+ const dirOf = (p: string) => { const n = norm(p); const i = n.lastIndexOf("/"); return i === -1 ? "" : n.slice(0, i) }
264
+ const baseOf = (p: string) => { const n = norm(p); return n.slice(n.lastIndexOf("/") + 1) }
265
+
266
+ /** Resolve `rel` ("./x", "../x") against `dir`; "" segments and "." collapse, ".." pops. */
267
+ const resolveRel = (dir: string, rel: string): string => {
268
+ const out = dir ? dir.split("/") : []
269
+ for (const seg of rel.split("/")) {
270
+ if (!seg || seg === ".") continue
271
+ if (seg === "..") { if (out.length === 0) return "" ; out.pop() }
272
+ else out.push(seg)
273
+ }
274
+ return out.join("/")
275
+ }
276
+
277
+ /** A relative import specifier from `fromDir` to entry `toPath` (always "./" or "../" prefixed). */
278
+ const relSpecifier = (fromDir: string, toPath: string): string => {
279
+ const from = fromDir ? fromDir.split("/") : []
280
+ const to = norm(toPath).split("/")
281
+ let common = 0
282
+ while (common < from.length && common < to.length - 1 && from[common] === to[common]) common++
283
+ const ups = from.length - common
284
+ const rest = to.slice(common).join("/")
285
+ return ups === 0 ? `./${rest}` : `${"../".repeat(ups)}${rest}`
286
+ }
287
+
288
+ // ---- the compile pass ---------------------------------------------------------------------------
289
+
290
+ const faceKey = (family: string, weight: number, italic: boolean) => `${family}|${weight}|${italic ? "i" : "n"}`
291
+
292
+ /** Resolve + rewrite every `font(...)` across the compile's text entries: calls become imported
293
+ * family-name consts, and each using file gains a generated sibling module that registers the
294
+ * faces (pushed as new text entries). Mutates `entries`. Returns the unique faces (header input);
295
+ * `[]` when nothing uses the macro. Throws — aborting the compile — on any malformed call. */
296
+ export const applyFontMacro = async (entries: FontMacroEntry[], opts: FontMacroOptions): Promise<FontFaceRef[]> => {
297
+ const scanned = entries
298
+ .filter((e) => e.type === "text" && CODE_FILE.test(e.path) && typeof e.text === "string" && e.text.includes("font("))
299
+ .filter((e) => !DECLARES_FONT.test(e.text!))
300
+ .map((e) => ({ entry: e, calls: scanFontCalls(e.text!) }))
301
+ .filter((s) => s.calls.length > 0)
302
+ if (scanned.length === 0) return []
303
+
304
+ const errors = scanned.flatMap((s) => s.calls.filter((c) => c.error).map((c) => `${s.entry.path}: ${c.error!}`))
305
+ if (errors.length) throw new Error([...new Set(errors)].join("\n"))
306
+
307
+ const registryBase = (opts.registryBase ?? fontRegistryBase()).replace(/\/+$/, "")
308
+ const entryByPath = new Map(entries.map((e) => [norm(e.path), e]))
309
+ const faces = new Map<string, FontFaceRef>()
310
+ const fileFaceCache = new Map<string, { family: string, weight: number, italic: boolean }>()
311
+
312
+ /** Resolve one call to its family name + the faces it implies (source set later for registry). */
313
+ const resolveCall = (entryPath: string, call: FontCall): { family: string, callFaces: { id?: string, family: string, weight: number, italic: boolean }[] } => {
314
+ if (call.kind === "file") {
315
+ const filePath = resolveRel(dirOf(entryPath), call.arg)
316
+ const target = filePath && entryByPath.get(filePath)
317
+ if (!target) throw new Error(`${entryPath}: font("${call.arg}"): no such file in the project (resolved to "${filePath || call.arg}").`)
318
+ call.filePath = filePath
319
+ let info = fileFaceCache.get(filePath)
320
+ if (!info) {
321
+ if (!opts.readBytes) throw new Error(`${entryPath}: font("${call.arg}"): project-font files aren't supported in this compile context.`)
322
+ const bytes = opts.readBytes(filePath)
323
+ if (!bytes) throw new Error(`${entryPath}: font("${call.arg}"): couldn't read "${filePath}".`)
324
+ try { info = parseSfnt(bytes) } catch (e) {
325
+ throw new Error(`${entryPath}: font("${call.arg}"): ${(e as Error).message}`)
326
+ }
327
+ fileFaceCache.set(filePath, info)
328
+ }
329
+ return { family: info.family, callFaces: [{ family: info.family, weight: info.weight, italic: info.italic }] }
330
+ }
331
+
332
+ const meta = FONT_REGISTRY[call.id!]
333
+ if (!meta) {
334
+ const known = Object.keys(FONT_REGISTRY)
335
+ throw new Error(`${entryPath}: font("${call.id}"): unknown registry font. Available: ${known.slice(0, 10).join(", ")}${known.length > 10 ? ", …" : ""}`)
336
+ }
337
+ const weights = call.weights ?? meta.defaultWeights
338
+ const badW = weights.filter((w) => !meta.weights.includes(w))
339
+ if (badW.length) throw new Error(`${entryPath}: font("${call.id}"): no ${badW.join("/")} face — available weights: ${meta.weights.join(", ")}.`)
340
+ if (call.italic) {
341
+ const badI = weights.filter((w) => !meta.italics.includes(w))
342
+ if (badI.length) {
343
+ 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"}.`)
344
+ }
345
+ }
346
+ const callFaces = weights.flatMap((w) => call.italic
347
+ ? [{ id: call.id, family: meta.family, weight: w, italic: false }, { id: call.id, family: meta.family, weight: w, italic: true }]
348
+ : [{ id: call.id, family: meta.family, weight: w, italic: false }])
349
+ return { family: meta.family, callFaces }
350
+ }
351
+
352
+ // Pass 1: resolve every call; collect the global unique face set (sources still pending).
353
+ type FileWork = { entry: FontMacroEntry, calls: { call: FontCall, family: string }[], faceKeys: Set<string> }
354
+ const pendingRegistry = new Map<string, { id: string, family: string, weight: number, italic: boolean }>()
355
+ const work: FileWork[] = []
356
+ for (const { entry, calls } of scanned) {
357
+ const fw: FileWork = { entry, calls: [], faceKeys: new Set() }
358
+ for (const call of calls) {
359
+ const { family, callFaces } = resolveCall(norm(entry.path), call)
360
+ fw.calls.push({ call, family })
361
+ for (const cf of callFaces) {
362
+ const key = faceKey(cf.family, cf.weight, cf.italic)
363
+ fw.faceKeys.add(key)
364
+ if (faces.has(key)) continue
365
+ if (cf.id === undefined) {
366
+ faces.set(key, { family: cf.family, weight: cf.weight, italic: cf.italic, source: { kind: "entry", path: (call.filePath!) } })
367
+ } else {
368
+ pendingRegistry.set(key, cf as { id: string, family: string, weight: number, italic: boolean })
369
+ faces.set(key, { family: cf.family, weight: cf.weight, italic: cf.italic, source: { kind: "cdn", urlBase: "" } })
370
+ }
371
+ }
372
+ }
373
+ work.push(fw)
374
+ }
375
+
376
+ // Pass 2: give registry faces their source — baked CDN url bases, or vendored project entries.
377
+ for (const [key, face] of pendingRegistry) {
378
+ const ref = faces.get(key)!
379
+ if (opts.mode === "cdn") {
380
+ ref.source = { kind: "cdn", urlBase: `${registryBase}/${face.id}/${face.id}-${face.weight}${face.italic ? "-italic" : ""}.` }
381
+ } else {
382
+ if (!opts.vendorFace) throw new Error(`font("${face.id}"): registry fonts need vendoring in this compile context, but no vendor hook was provided.`)
383
+ const path = await opts.vendorFace({ ...face, fileName: faceFileName(face.id, face.weight, face.italic, "ttf") })
384
+ ref.source = { kind: "entry", path: norm(path) }
385
+ }
386
+ }
387
+
388
+ // Pass 3: rewrite each file + generate its registration module.
389
+ for (const fw of work) {
390
+ const entryPath = norm(fw.entry.path)
391
+ const dir = dirOf(entryPath)
392
+ const modBase = `.${baseOf(entryPath)}.__fonts.ts`
393
+ const modPath = dir ? `${dir}/${modBase}` : modBase
394
+
395
+ // family → exported const ident (per file)
396
+ const idents = new Map<string, string>()
397
+ for (const { family } of fw.calls) {
398
+ if (!idents.has(family)) idents.set(family, `__font_${idents.size}`)
399
+ }
400
+
401
+ // Rewrite calls back-to-front so earlier indices stay valid.
402
+ let text = fw.entry.text!
403
+ for (const { call, family } of [...fw.calls].sort((a, b) => b.call.start - a.call.start)) {
404
+ text = text.slice(0, call.start) + idents.get(family)! + text.slice(call.end)
405
+ }
406
+ // Appended, never prepended — added lines must not shift the file's source map (see assetMacro).
407
+ text += `\nimport { ${[...idents.values()].join(", ")} } from ${JSON.stringify(`./${modBase}`)}`
408
+ fw.entry.text = text
409
+
410
+ const lines: string[] = [
411
+ "// Generated by the font() compile macro: registers this file's fonts. Imports hoist, so the",
412
+ "// faces are declared before the importing module's body runs. Hosts dedupe repeated faces.",
413
+ ]
414
+ const faceImports = new Map<string, string>() // entry path → import ident
415
+ const body: string[] = []
416
+ for (const key of [...fw.faceKeys].sort()) {
417
+ const ref = faces.get(key)!
418
+ let urlExpr: string
419
+ if (ref.source.kind === "cdn") {
420
+ urlExpr = `${JSON.stringify(ref.source.urlBase)} + (_creatorUI.fontFormat ?? "ttf")`
421
+ } else {
422
+ let ident = faceImports.get(ref.source.path)
423
+ if (!ident) {
424
+ ident = `__face_${faceImports.size}`
425
+ faceImports.set(ref.source.path, ident)
426
+ lines.push(`import ${ident} from ${JSON.stringify(relSpecifier(dir, ref.source.path))}`)
427
+ }
428
+ urlExpr = ident
429
+ }
430
+ const optionsExpr = ref.italic ? `{ weight: ${ref.weight}, style: "italic" }` : `{ weight: ${ref.weight} }`
431
+ body.push(`_creatorUI.registerFont(${JSON.stringify(ref.family)}, ${urlExpr}, ${optionsExpr}, () => {}, () => {})`)
432
+ }
433
+ for (const [family, ident] of idents) lines.push(`export const ${ident} = ${JSON.stringify(family)}`)
434
+ lines.push(...body)
435
+ entries.push({ path: modPath, type: "text", text: lines.join("\n") + "\n" })
436
+ }
437
+
438
+ return [...faces.values()]
439
+ }
440
+
441
+ // ---- header -------------------------------------------------------------------------------------
442
+
443
+ /** The `// fonts:` header value: `Family@<weight>[i]=<url>` entries joined by ";". CDN faces carry
444
+ * a `{fmt}` placeholder the host replaces with its container format (woff2/ttf). Entry-sourced
445
+ * faces are included only when `entryUrl` yields an absolute URL (URL-mode compiles); local faces
446
+ * need no prefetch — they resolve synchronously via fetchLocal and already ride `// preload:`. */
447
+ export const buildFontsHeaderValue = (
448
+ faces: FontFaceRef[],
449
+ entryUrl?: (path: string) => string | null,
450
+ ): string =>
451
+ faces
452
+ .map((f) => {
453
+ const tag = `${f.family}@${f.weight}${f.italic ? "i" : ""}`
454
+ if (f.source.kind === "cdn") return `${tag}=${f.source.urlBase}{fmt}`
455
+ const url = entryUrl?.(f.source.path)
456
+ return url ? `${tag}=${url}` : null
457
+ })
458
+ .filter((s): s is string => !!s)
459
+ .join(";")
@@ -0,0 +1,78 @@
1
+ // The vendored snapshot of the font registry — the compile-time source of truth for `font("id")`
2
+ // (docs/font-system-plan.md). The CDN holds the actual files, immutable once published:
3
+ // <base>/<id>/<id>-<weight>[-italic].(woff2|ttf) e.g. manrope/manrope-400.woff2
4
+ // <base>/licenses.json per-family license metadata
5
+ //
6
+ // GENERATED from the built registry (Desktop/Ingenuity/lecodes-font-registry — `bun build.ts`
7
+ // emits dist/fontRegistry.snapshot.ts; weights/italics/scripts are verified against the actual
8
+ // face files by parsing them, cyrillic via cmap). Regenerate there, don't hand-edit the data.
9
+ // All families are OFL-licensed. `scripts` flags cyrillic coverage for RU-safe picks.
10
+
11
+ export type FontFamilyMeta = {
12
+ family: string
13
+ category: "sans-serif" | "serif" | "display" | "monospace" | "handwriting"
14
+ scripts: ("latin" | "cyrillic")[]
15
+ weights: number[]
16
+ /** Weights that also ship an italic face. */
17
+ italics: number[]
18
+ /** Faces `font("id")` installs when no `{ weights }` option narrows the set. */
19
+ defaultWeights: number[]
20
+ license: string
21
+ }
22
+
23
+ export const DEFAULT_FONT_REGISTRY_BASE = "https://cdn.le.codes/fonts/v1"
24
+
25
+ /** The active registry base — a URL or a local directory with the same layout (tests). */
26
+ export const fontRegistryBase = (): string =>
27
+ (typeof process !== "undefined" && process.env?.LECODES_FONT_REGISTRY?.trim()) || DEFAULT_FONT_REGISTRY_BASE
28
+
29
+ /** Registry face file name: `<id>-<weight>[-italic].<ext>`. */
30
+ export const faceFileName = (id: string, weight: number, italic: boolean, ext: string): string =>
31
+ `${id}-${weight}${italic ? "-italic" : ""}.${ext}`
32
+
33
+ const LATIN: ("latin" | "cyrillic")[] = ["latin"]
34
+ const CYR: ("latin" | "cyrillic")[] = ["latin", "cyrillic"]
35
+ const OFL = "OFL-1.1"
36
+
37
+ const f = (
38
+ family: string, category: FontFamilyMeta["category"], scripts: FontFamilyMeta["scripts"],
39
+ weights: number[], italics: number[], defaultWeights: number[],
40
+ ): FontFamilyMeta => ({ family, category, scripts, weights, italics, defaultWeights, license: OFL })
41
+
42
+ export const FONT_REGISTRY: Record<string, FontFamilyMeta> = {
43
+ // sans / UI
44
+ "inter": f("Inter", "sans-serif", CYR, [100, 200, 300, 400, 500, 600, 700, 800, 900], [], [400, 500, 700]),
45
+ "manrope": f("Manrope", "sans-serif", CYR, [200, 300, 400, 500, 600, 700, 800], [], [400, 500, 700]),
46
+ "golos-text": f("Golos Text", "sans-serif", CYR, [400, 500, 600, 700, 800, 900], [], [400, 500, 700]),
47
+ "onest": f("Onest", "sans-serif", CYR, [100, 200, 300, 400, 500, 600, 700, 800, 900], [], [400, 500, 700]),
48
+ "rubik": f("Rubik", "sans-serif", CYR, [300, 400, 500, 600, 700, 800, 900], [300, 400, 500, 600, 700, 800, 900], [400, 500, 700]),
49
+ "nunito": f("Nunito", "sans-serif", CYR, [200, 300, 400, 500, 600, 700, 800, 900], [400, 700], [400, 600, 700]),
50
+ "montserrat": f("Montserrat", "sans-serif", CYR, [100, 200, 300, 400, 500, 600, 700, 800, 900], [400, 700], [400, 500, 700]),
51
+ "ibm-plex-sans": f("IBM Plex Sans", "sans-serif", CYR, [100, 200, 300, 400, 500, 600, 700], [400, 700], [400, 500, 700]),
52
+ "pt-sans": f("PT Sans", "sans-serif", CYR, [400, 700], [400, 700], [400, 700]),
53
+ "jost": f("Jost", "sans-serif", CYR, [100, 200, 300, 400, 500, 600, 700, 800, 900], [400, 700], [400, 500, 700]), // cyrillic cmap-verified
54
+ "exo-2": f("Exo 2", "sans-serif", CYR, [100, 200, 300, 400, 500, 600, 700, 800, 900], [400, 700], [400, 500, 700]),
55
+
56
+ // display
57
+ "unbounded": f("Unbounded", "display", CYR, [200, 300, 400, 500, 600, 700, 800, 900], [], [400, 700]),
58
+ "russo-one": f("Russo One", "display", CYR, [400], [], [400]),
59
+ "oswald": f("Oswald", "display", CYR, [200, 300, 400, 500, 600, 700], [], [400, 700]),
60
+ "comfortaa": f("Comfortaa", "display", CYR, [300, 400, 500, 600, 700], [], [400, 700]),
61
+ "space-grotesk": f("Space Grotesk", "display", LATIN, [300, 400, 500, 600, 700], [], [400, 700]),
62
+
63
+ // serif
64
+ "playfair-display": f("Playfair Display", "serif", CYR, [400, 500, 600, 700, 800, 900], [400, 700], [400, 700]),
65
+ "lora": f("Lora", "serif", CYR, [400, 500, 600, 700], [400, 700], [400, 700]),
66
+ "pt-serif": f("PT Serif", "serif", CYR, [400, 700], [400, 700], [400, 700]),
67
+ "cormorant": f("Cormorant", "serif", CYR, [300, 400, 500, 600, 700], [400, 700], [400, 700]),
68
+ "literata": f("Literata", "serif", CYR, [200, 300, 400, 500, 600, 700, 800, 900], [400, 700], [400, 700]),
69
+
70
+ // mono
71
+ "jetbrains-mono": f("JetBrains Mono", "monospace", CYR, [100, 200, 300, 400, 500, 600, 700, 800], [400, 700], [400, 700]),
72
+ "fira-code": f("Fira Code", "monospace", CYR, [300, 400, 500, 600, 700], [], [400, 700]),
73
+ "ibm-plex-mono": f("IBM Plex Mono", "monospace", CYR, [100, 200, 300, 400, 500, 600, 700], [400, 700], [400, 700]),
74
+
75
+ // handwriting
76
+ "caveat": f("Caveat", "handwriting", CYR, [400, 500, 600, 700], [], [400, 700]),
77
+ "pacifico": f("Pacifico", "handwriting", CYR, [400], [], [400]), // cyrillic cmap-verified
78
+ }
@@ -0,0 +1,55 @@
1
+ // The metadata header the launcher reads from the compiled bundle: name / icon / preload / gl / 2d /
2
+ // ar. Single source of truth — the backend and CLI both call this (today they mirror it by hand).
3
+ //
4
+ // Detection keys on the *bundled* `_creator*` calls, which the SDK's thin wrappers still emit
5
+ // (new Scene() → _creator.createScene, new Scene2D() → _creator2d.createScene), so the re-skin keeps
6
+ // the exact same signals the old worker bundles produced.
7
+
8
+ export type HeaderOptions = {
9
+ /** Generated-icon URL (backend supplies it; the CLI omits it). */
10
+ icon?: string
11
+ /** `// fonts:` payload from the font() macro (buildFontsHeaderValue) — the boot faces a host
12
+ * should prefetch and hold first paint for. CDN URLs carry a `{fmt}` placeholder (woff2/ttf). */
13
+ fonts?: string
14
+ /** `// icons:` payload from the assetIcon() macro (buildIconsHeaderValue) — the icon packs whose
15
+ * glyphs are inlined in the bundle. Purely informational (attribution record); hosts ignore it. */
16
+ icons?: string
17
+ }
18
+
19
+ export const buildHeader = (name: string, code: string, opts: HeaderOptions = {}): string => {
20
+ let header = `// name: ${name.slice(0, 20)}\n`
21
+ if (opts.icon) header += `// icon: ${opts.icon}\n`
22
+ header += `// last-updated: ${new Date().toISOString()}\n`
23
+
24
+ // Local assets to preload: explicit fetchLocal() calls + implicit engine archives.
25
+ const localAssets = new Set<string>()
26
+ for (const m of code.matchAll(/_creatorUtils\.fetchLocal\((.+?)\)/g)) {
27
+ localAssets.add(m[1].replaceAll(/['"]/g, "").trim())
28
+ }
29
+ if (/_creator\.createGlb/.test(code)) localAssets.add("uberarchive")
30
+ if (/_creator\.captureImage/.test(code)) localAssets.add("capture")
31
+ if (localAssets.size > 0) header += `// preload: ${[ ...localAssets ].join(";")}\n`
32
+
33
+ if (opts.fonts) header += `// fonts: ${opts.fonts}\n`
34
+ if (opts.icons) header += `// icons: ${opts.icons}\n`
35
+
36
+ // GL: disabled unless the project touches the 3D engine; deferred if it lazily creates the engine.
37
+ if (!code.includes("_creator.createScene") && !code.includes("_creator.createEntity")) header += `// gl: disable\n`
38
+ else if (code.includes("_creator.createEngine")) header += `// gl: defer\n`
39
+
40
+ // 2D (creator-2d) — non-exclusive: emitted alongside `gl: disable` so hosts skip Filament and a
41
+ // 2D-aware host brings up the lightweight sokol engine instead.
42
+ if (code.includes("_creator2d.")) header += `// 2d: enable\n`
43
+
44
+ if (code.includes("_creator.createARController")) header += "// ar: enable"
45
+ header += "\n"
46
+ return header
47
+ }
48
+
49
+ /** Same engine signals the header uses, for callers that need the project kind (e.g. icon type). */
50
+ export const detectProjectKind = (code: string): "ar" | "ui" | "2d" | "3d" => {
51
+ if (code.includes("_creator.createARController")) return "ar"
52
+ if (code.includes("_creator2d.")) return "2d"
53
+ if (!code.includes("_creator.")) return "ui"
54
+ return "3d"
55
+ }
@@ -0,0 +1,53 @@
1
+ // The public entry of the SDK's bundle build system. The backend, CLI, and local renderer import
2
+ // from here instead of re-forking a bundler. (Node + Bun safe — no top-level host globals.)
3
+
4
+ export { bundleProject, bundleProjectWithMap, scanModulesChisel, type MemoryFiles } from "./bundler"
5
+ export {
6
+ detectEntry,
7
+ scanModulesFallback,
8
+ ENTRY_CONVENTION,
9
+ type DetectedEntry,
10
+ type DetectEntryInput,
11
+ type ModuleScanner,
12
+ type ScanModule,
13
+ } from "./detectEntry"
14
+ export { sanitizeAssetName, isCanonicalAssetName } from "./assetName"
15
+ export { offsetSourceMap, inlineSourceMapComment } from "./sourcemap"
16
+ export { buildHeader, detectProjectKind, type HeaderOptions } from "./header"
17
+ export {
18
+ compileProject,
19
+ type CompileEntry,
20
+ type CompileOptions,
21
+ type CompileFileType,
22
+ } from "./compileProject"
23
+ export {
24
+ loadSceneHarness,
25
+ sceneEditorEntries,
26
+ SCENE_ENTRY_PATH,
27
+ SCENE_HARNESS_DIR,
28
+ } from "./sceneEditor"
29
+ export {
30
+ applyFontMacro,
31
+ buildFontsHeaderValue,
32
+ scanFontCalls,
33
+ type FontCall,
34
+ type FontFaceRef,
35
+ type FontMacroEntry,
36
+ type FontMacroOptions,
37
+ } from "./fontMacro"
38
+ export {
39
+ applyAssetIconMacro,
40
+ buildIconsHeaderValue,
41
+ collectVendoredIcons,
42
+ scanAssetIconCalls,
43
+ rewriteAssetIcons,
44
+ suggestIconNames,
45
+ iconNotFoundError,
46
+ ICON_ID,
47
+ type IconCall,
48
+ type AssetIconEntry,
49
+ type AssetIconResolver,
50
+ type RegistryReader,
51
+ } from "./assetIconMacro"
52
+ export { FONT_REGISTRY, DEFAULT_FONT_REGISTRY_BASE, fontRegistryBase, faceFileName, type FontFamilyMeta } from "./fontRegistry"
53
+ export { parseSfnt, type SfntInfo } from "./sfnt"