lecodes-cli 0.18.2 → 0.19.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 (57) hide show
  1. package/dist/index.js +1240 -170
  2. package/package.json +3 -2
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk/compile/aspectMacro.ts +86 -0
  5. package/runtime/sdk/compile/assetIconMacro.ts +384 -0
  6. package/runtime/sdk/compile/assetMacro.ts +146 -0
  7. package/runtime/sdk/compile/assetName.ts +50 -0
  8. package/runtime/sdk/compile/bundler.ts +287 -0
  9. package/runtime/sdk/compile/compileProject.ts +129 -0
  10. package/runtime/sdk/compile/detectEntry.ts +128 -0
  11. package/runtime/sdk/compile/fontMacro.ts +459 -0
  12. package/runtime/sdk/compile/fontRegistry.ts +78 -0
  13. package/runtime/sdk/compile/header.ts +67 -0
  14. package/runtime/sdk/compile/index.ts +100 -0
  15. package/runtime/sdk/compile/libraryImports.ts +52 -0
  16. package/runtime/sdk/compile/liteMaterial.ts +247 -0
  17. package/runtime/sdk/compile/sceneEditor.ts +88 -0
  18. package/runtime/sdk/compile/serverSplit.ts +233 -0
  19. package/runtime/sdk/compile/serverTypes.ts +227 -0
  20. package/runtime/sdk/compile/sfnt.ts +98 -0
  21. package/runtime/sdk/compile/shaderSchema.ts +202 -0
  22. package/runtime/sdk/compile/shaderTargets.ts +81 -0
  23. package/runtime/sdk/compile/sourcemap.ts +25 -0
  24. package/runtime/sdk/core/Aspect.ts +65 -9
  25. package/runtime/sdk/core/StateMachine.ts +308 -0
  26. package/runtime/sdk/core/time.ts +3 -0
  27. package/runtime/sdk/gl/CameraPlace.ts +52 -52
  28. package/runtime/sdk/gl/Light.ts +12 -4
  29. package/runtime/sdk/gl/Lightmap.ts +87 -10
  30. package/runtime/sdk/gl/Material.ts +15 -0
  31. package/runtime/sdk/gl/Model.ts +68 -8
  32. package/runtime/sdk/gl/NavAgent.ts +337 -0
  33. package/runtime/sdk/gl/NavMesh.ts +397 -0
  34. package/runtime/sdk/gl/Ragdoll.ts +270 -0
  35. package/runtime/sdk/gl/Scene.ts +7 -0
  36. package/runtime/sdk/gl/Shape.ts +37 -5
  37. package/runtime/sdk/gl/Terrain.ts +1092 -0
  38. package/runtime/sdk/gl/Texture.ts +17 -0
  39. package/runtime/sdk/gl/Trigger.ts +45 -45
  40. package/runtime/sdk/gl/animation/Animator.ts +35 -2
  41. package/runtime/sdk/gl/animation/Layer.ts +3 -1
  42. package/runtime/sdk/gl/animation/Loop.ts +5 -0
  43. package/runtime/sdk/gl/animation/Playback.ts +4 -3
  44. package/runtime/sdk/gl/animation/core.ts +52 -10
  45. package/runtime/sdk/gl/scenarios.ts +291 -291
  46. package/runtime/sdk/gl/state.ts +6 -6
  47. package/runtime/sdk/gl/terrainMesh.ts +219 -0
  48. package/runtime/sdk/inject.ts +12 -1
  49. package/runtime/sdk/net/codec.ts +119 -0
  50. package/runtime/sdk/net/core.ts +384 -0
  51. package/runtime/sdk/net/index.ts +181 -0
  52. package/runtime/sdk/net/replication.ts +622 -0
  53. package/runtime/sdk/scene/defineScene.ts +140 -14
  54. package/runtime/sdk/scene/editorPlugins.ts +18 -0
  55. package/runtime/sdk/scene/gizmos.ts +148 -148
  56. package/runtime/sdk/ui/UINode.ts +4 -0
  57. package/runtime/sdk-types.json +1 -1
@@ -0,0 +1,384 @@
1
+ /*
2
+ * The `assetIcon('pack:name')` compile macro — scan/parse/rewrite core, shared by every compiler
3
+ * (CLI design + app compiles, backend design board + platform app compile). At compile time each
4
+ * call is resolved to an SVG body and rewritten to an inline `SvgSource(\`<svg>\`)` — the same image
5
+ * source `SvgSource(...)` returns and that a `.svg` asset compiles to — so icons render offline and
6
+ * deterministically on every host with no runtime support and no network at render time.
7
+ *
8
+ * UIImage(assetIcon('lucide:bell', { color: colors.accent })) // recolor via a token
9
+ * UIImage(assetIcon('lucide:bell', { color: '#5b8cff' })) // bake a hex literal
10
+ * UIImage(assetIcon('lucide:bell')).style({ width: 24, tintColor: '#5b8cff' }) // or the style prop
11
+ *
12
+ * The SVG uses `currentColor`. The `{ color }` option recolors it: a string LITERAL is baked into the
13
+ * SVG at compile time (currentColor → the hex), a token / any other expression is lowered to the
14
+ * SvgSource's source-level `tintColor` (a mask recolor the renderers honor). The `id` must be a
15
+ * string literal.
16
+ *
17
+ * WHERE the SVG bodies come from is the caller's business, passed in as a resolver:
18
+ * - CLI design compile: vendored into the design folder from the icon registry (lock + notices);
19
+ * - CLI app compiles: the user-level cache (~/.lecodes/cache/icons) + registry on miss;
20
+ * - backend app compile + design board: vendored `assets/icons/**` text assets first, then a
21
+ * server-side cache + registry on miss.
22
+ * App compiles never write icons into the project — attribution for platform-deployed apps rides
23
+ * the `// icons:` bundle header (buildIconsHeaderValue) and the platform, not an in-repo notice.
24
+ */
25
+
26
+ /** `pack:name`, each part lowercase `[a-z0-9-]` (matches the registry's ids). */
27
+ export const ICON_ID = /^([a-z0-9]+(?:-[a-z0-9]+)*):([a-z0-9]+(?:-[a-z0-9]+)*)$/
28
+
29
+ export type IconCall = {
30
+ /** Index of `assetIcon` in the source. */
31
+ start: number
32
+ /** Index just past the call's closing `)`. */
33
+ end: number
34
+ /** The literal id (`"lucide:bell"`), or "" when the id wasn't a literal. */
35
+ id: string
36
+ pack: string
37
+ name: string
38
+ /** A literal color from `{ color: "…" }` to bake into the SVG at compile time (currentColor → color). */
39
+ bake?: string
40
+ /** A non-literal `{ color: expr }` (e.g. a token) — inlined verbatim as the SvgSource's source-level
41
+ * tintColor, which the renderer masks the SVG with. Mutually exclusive with `bake`. */
42
+ tint?: string
43
+ /** Set when the call is malformed (non-literal id, bad id shape, bad opts) — a hard error. */
44
+ error?: string
45
+ }
46
+
47
+ // ---- lexing (comment/string/template aware, dependency-free) ------------------------------------
48
+
49
+ const isIdentStart = (c: string) => (c >= "A" && c <= "Z") || (c >= "a" && c <= "z") || c === "_" || c === "$"
50
+ const isIdentPart = (c: string) => isIdentStart(c) || (c >= "0" && c <= "9")
51
+
52
+ /** Advance past a string/template literal that opens at `start`; returns the index just after the
53
+ * closing quote. Template `${…}` interpolations (and strings nested in them) are skipped. */
54
+ const skipString = (src: string, start: number): number => {
55
+ const quote = src[start]
56
+ const n = src.length
57
+ let i = start + 1
58
+ while (i < n) {
59
+ const c = src[i]
60
+ if (c === "\\") { i += 2; continue }
61
+ if (quote === "`" && c === "$" && src[i + 1] === "{") { i = skipBraces(src, i + 1); continue }
62
+ if (c === quote) return i + 1
63
+ i++
64
+ }
65
+ return n
66
+ }
67
+
68
+ /** From the index of a `{`, return the index just after its matching `}` (string-aware). */
69
+ const skipBraces = (src: string, open: number): number => {
70
+ const n = src.length
71
+ let depth = 0
72
+ let i = open
73
+ while (i < n) {
74
+ const c = src[i]
75
+ if (c === '"' || c === "'" || c === "`") { i = skipString(src, i); continue }
76
+ if (c === "{") depth++
77
+ else if (c === "}") { depth--; if (depth === 0) return i + 1 }
78
+ i++
79
+ }
80
+ return n
81
+ }
82
+
83
+ /** Index of the `)` matching the `(` at `open`, or -1. Skips strings and comments. */
84
+ const matchParen = (src: string, open: number): number => {
85
+ const n = src.length
86
+ let depth = 0
87
+ let i = open
88
+ while (i < n) {
89
+ const c = src[i]
90
+ if (c === '"' || c === "'" || c === "`") { i = skipString(src, i); continue }
91
+ if (c === "/" && src[i + 1] === "/") { i += 2; while (i < n && src[i] !== "\n") i++; continue }
92
+ if (c === "/" && src[i + 1] === "*") { i += 2; while (i < n && !(src[i] === "*" && src[i + 1] === "/")) i++; i += 2; continue }
93
+ if (c === "(") depth++
94
+ else if (c === ")") { depth--; if (depth === 0) return i }
95
+ i++
96
+ }
97
+ return -1
98
+ }
99
+
100
+ /** Split on a top-level (depth-0, outside strings) single-char delimiter. */
101
+ const splitTopLevel = (text: string, delim: string): string[] => {
102
+ const parts: string[] = []
103
+ const n = text.length
104
+ let depth = 0
105
+ let last = 0
106
+ let i = 0
107
+ while (i < n) {
108
+ const c = text[i]
109
+ if (c === '"' || c === "'" || c === "`") { i = skipString(text, i); continue }
110
+ if (c === "(" || c === "[" || c === "{") depth++
111
+ else if (c === ")" || c === "]" || c === "}") depth = Math.max(0, depth - 1)
112
+ else if (depth === 0 && c === delim) { parts.push(text.slice(last, i)); last = i + 1 }
113
+ i++
114
+ }
115
+ parts.push(text.slice(last))
116
+ return parts
117
+ }
118
+
119
+ /** The value of a whole-string literal (`"x"`, `'x'`, plain `` `x` ``), or null if `text` isn't one. */
120
+ const stringLiteralValue = (text: string): string | null => {
121
+ const t = text.trim()
122
+ if (t.length < 2) return null
123
+ const q = t[0]
124
+ if ((q !== '"' && q !== "'" && q !== "`") || t[t.length - 1] !== q) return null
125
+ const inner = t.slice(1, -1)
126
+ if (q === "`" && inner.includes("${")) return null // interpolated → not static
127
+ // A same-kind unescaped quote before the end means it's two literals, not one.
128
+ if (inner.includes(q) && !inner.includes("\\" + q)) return null
129
+ return inner.replace(/\\(.)/g, "$1")
130
+ }
131
+
132
+ // ---- scanning -----------------------------------------------------------------------------------
133
+
134
+ /** Find every `assetIcon(...)` call in a source file. Pure and offline; positions are into `source`.
135
+ * A call with a non-literal id / bad id / non-literal color is returned with `.error` set. */
136
+ export const scanAssetIconCalls = (source: string): IconCall[] => {
137
+ const calls: IconCall[] = []
138
+ const n = source.length
139
+ let i = 0
140
+ while (i < n) {
141
+ const c = source[i]
142
+ if (c === '"' || c === "'" || c === "`") { i = skipString(source, i); continue }
143
+ if (c === "/" && source[i + 1] === "/") { i += 2; while (i < n && source[i] !== "\n") i++; continue }
144
+ if (c === "/" && source[i + 1] === "*") { i += 2; while (i < n && !(source[i] === "*" && source[i + 1] === "/")) i++; i += 2; continue }
145
+ if (isIdentStart(c)) {
146
+ let j = i + 1
147
+ while (j < n && isIdentPart(source[j])) j++
148
+ if (source.slice(i, j) === "assetIcon" && prevNonSpace(source, i) !== ".") {
149
+ const call = parseCall(source, i, j)
150
+ if (call) { calls.push(call); i = call.end; continue }
151
+ }
152
+ i = j
153
+ continue
154
+ }
155
+ i++
156
+ }
157
+ return calls
158
+ }
159
+
160
+ /** The last non-whitespace char before `i` (to reject `obj.assetIcon`), or "". */
161
+ const prevNonSpace = (src: string, i: number): string => {
162
+ let k = i - 1
163
+ while (k >= 0 && /\s/.test(src[k])) k--
164
+ return k >= 0 ? src[k] : ""
165
+ }
166
+
167
+ /** Parse an `assetIcon` call whose identifier spans [start, identEnd). Returns null if it isn't a
168
+ * call (no `(`), else an IconCall (with `.error` set for a malformed one). */
169
+ const parseCall = (src: string, start: number, identEnd: number): IconCall | null => {
170
+ const n = src.length
171
+ let i = identEnd
172
+ while (i < n && /\s/.test(src[i])) i++
173
+ if (src[i] !== "(") return null
174
+ const close = matchParen(src, i)
175
+ if (close === -1) return null
176
+
177
+ const call: IconCall = { start, end: close + 1, id: "", pack: "", name: "" }
178
+ const args = splitTopLevel(src.slice(i + 1, close), ",")
179
+ const id = stringLiteralValue(args[0] ?? "")
180
+ if (id === null) {
181
+ call.error = `assetIcon(...) needs a string-literal id like "lucide:bell" (got \`${(args[0] ?? "").trim() || "()"}\`).`
182
+ return call
183
+ }
184
+ const m = ICON_ID.exec(id)
185
+ if (!m) {
186
+ call.error = `assetIcon("${id}"): id must be "pack:name", lowercase [a-z0-9-] (e.g. "lucide:bell").`
187
+ return call
188
+ }
189
+ call.id = id
190
+ call.pack = m[1]
191
+ call.name = m[2]
192
+
193
+ const optsText = args[1]?.trim()
194
+ if (optsText) {
195
+ const color = extractColor(optsText)
196
+ if (color.error) { call.error = color.error; return call }
197
+ if (color.bake !== undefined) call.bake = color.bake
198
+ else if (color.tint !== undefined) call.tint = color.tint
199
+ }
200
+ return call
201
+ }
202
+
203
+ /** Pull `color` out of an `{ color: … }` opts literal. A string literal is baked (currentColor → it);
204
+ * any other expression (a token, a call) is kept verbatim to apply as a runtime tint. A missing color
205
+ * or unknown keys are fine; a non-object `opts` is an error. */
206
+ const extractColor = (optsText: string): { bake?: string, tint?: string, error?: string } => {
207
+ if (!optsText.startsWith("{") || !optsText.endsWith("}")) {
208
+ return { error: `assetIcon(id, opts): opts must be an object literal like { color: "#5b8cff" }.` }
209
+ }
210
+ for (const prop of splitTopLevel(optsText.slice(1, -1), ",")) {
211
+ const kv = splitTopLevel(prop, ":")
212
+ if (kv.length < 2) continue
213
+ const key = kv[0].trim().replace(/^["'`]|["'`]$/g, "")
214
+ if (key !== "color") continue
215
+ const raw = kv.slice(1).join(":").trim()
216
+ if (!raw) continue
217
+ const literal = stringLiteralValue(raw)
218
+ return literal !== null ? { bake: literal } : { tint: raw }
219
+ }
220
+ return {}
221
+ }
222
+
223
+ // ---- rewriting ----------------------------------------------------------------------------------
224
+
225
+ /** Rewrite each `assetIcon(...)` call in `source` to an inline SvgSource — baking a literal color into
226
+ * the SVG, or attaching a non-literal color as the source-level tint. Calls not in `svgById` (resolved
227
+ * elsewhere) are left untouched. */
228
+ export const rewriteAssetIcons = (source: string, calls: IconCall[], svgById: Map<string, string>): string => {
229
+ let out = source
230
+ // Apply back-to-front so earlier indices stay valid.
231
+ for (const call of [...calls].sort((a, b) => b.start - a.start)) {
232
+ const svg = svgById.get(call.id)
233
+ if (svg === undefined) continue
234
+ out = out.slice(0, call.start) + iconExpr(svg, call) + out.slice(call.end)
235
+ }
236
+ return out
237
+ }
238
+
239
+ /** The inline replacement expression for one resolved call. */
240
+ const iconExpr = (svg: string, call: IconCall): string => {
241
+ const body = call.bake ? svg.split("currentColor").join(call.bake) : svg
242
+ const src = `SvgSource(\`${escapeTemplate(body)}\`)`
243
+ // A non-literal color rides the source-level tint channel (the renderer masks the SVG with it).
244
+ return call.tint ? `Object.assign(${src}, { tintColor: (${call.tint}) })` : src
245
+ }
246
+
247
+ const escapeTemplate = (s: string) => s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${")
248
+
249
+ // ---- the macro pass -----------------------------------------------------------------------------
250
+
251
+ export type AssetIconEntry = { path: string, type: string, text?: string }
252
+
253
+ /** id[] → id → SVG body. A resolver may throw its own contextual error for an unresolvable id
254
+ * (unknown pack, missing icon, not vendored); any id it silently omits fails the pass generically. */
255
+ export type AssetIconResolver = (ids: string[]) => Promise<Map<string, string>> | Map<string, string>
256
+
257
+ /**
258
+ * Resolve + rewrite every `assetIcon(...)` across the compile's text entries (mutates
259
+ * `entries[].text`). Returns the resolved ids — `[]` when nothing uses `assetIcon` (the resolver is
260
+ * not called). Throws (aborting the compile) on a malformed call or an unresolvable icon.
261
+ */
262
+ export const applyAssetIconMacro = async (
263
+ entries: AssetIconEntry[],
264
+ resolveSvgs: AssetIconResolver,
265
+ ): Promise<string[]> => {
266
+ const scanned = entries
267
+ .filter((e) => e.type === "text" && typeof e.text === "string" && e.text.includes("assetIcon"))
268
+ .map((e) => ({ entry: e, calls: scanAssetIconCalls(e.text!) }))
269
+ .filter((s) => s.calls.length > 0)
270
+ if (scanned.length === 0) return []
271
+
272
+ const errors = scanned.flatMap((s) => s.calls.filter((c) => c.error).map((c) => c.error!))
273
+ if (errors.length) throw new Error([...new Set(errors)].join("\n"))
274
+
275
+ const ids = [...new Set(scanned.flatMap((s) => s.calls.map((c) => c.id)))]
276
+ const svgById = await resolveSvgs(ids)
277
+
278
+ const missing = ids.filter((id) => !svgById.has(id))
279
+ if (missing.length) throw new Error(`assetIcon: could not resolve icon(s): ${missing.join(", ")}.`)
280
+
281
+ for (const { entry, calls } of scanned) entry.text = rewriteAssetIcons(entry.text!, calls, svgById)
282
+ return ids
283
+ }
284
+
285
+ /** The `// icons:` header payload for a compile that resolved `ids`: the distinct packs, sorted —
286
+ * the bundle's self-describing record of which icon sets it redistributes (licenses live in the
287
+ * registry's licenses.json; the platform joins the two for attribution UI). "" when no icons. */
288
+ export const buildIconsHeaderValue = (ids: string[]): string =>
289
+ [...new Set(ids.map((id) => id.split(":")[0]))].sort().join(";")
290
+
291
+ // ---- "did you mean" (for icon-not-found errors) ---------------------------------------------------
292
+
293
+ /** Levenshtein with an early-exit cap: returns cap+1 as soon as the distance exceeds `cap`. */
294
+ const editDistance = (a: string, b: string, cap: number): number => {
295
+ if (Math.abs(a.length - b.length) > cap) return cap + 1
296
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
297
+ for (let i = 1; i <= a.length; i++) {
298
+ const cur = [i]
299
+ let rowMin = i
300
+ for (let j = 1; j <= b.length; j++) {
301
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
302
+ if (cur[j] < rowMin) rowMin = cur[j]
303
+ }
304
+ if (rowMin > cap) return cap + 1
305
+ prev = cur
306
+ }
307
+ return prev[b.length]
308
+ }
309
+
310
+ /** The closest icon names to `want` in one pack's meta (`icons: { name: { kw } }`), best-first,
311
+ * max 5. Ranks: substring overlap (typing "bell" → "bell-ring"), then small edit distance (typos:
312
+ * "bel" → "bell"), then a shared keyword (a dash-part of `want` appearing in an icon's kw list). */
313
+ export const suggestIconNames = (want: string, icons: Record<string, { kw?: string[] }>): string[] => {
314
+ const wantParts = new Set(want.split("-").filter(Boolean))
315
+ const scored: { name: string, rank: number }[] = []
316
+ for (const [name, info] of Object.entries(icons)) {
317
+ const lenDiff = Math.abs(name.length - want.length) / 100
318
+ let rank: number
319
+ if (name.includes(want) || want.includes(name)) rank = lenDiff
320
+ else {
321
+ const d = editDistance(want, name, 2)
322
+ if (d <= 2) rank = 1 + d
323
+ else if (info.kw?.some((k) => wantParts.has(k))) rank = 4 + lenDiff
324
+ else continue
325
+ }
326
+ scored.push({ name, rank })
327
+ }
328
+ return scored.sort((a, b) => a.rank - b.rank || (a.name < b.name ? -1 : 1)).slice(0, 5).map((s) => s.name)
329
+ }
330
+
331
+ /** Reads one registry-relative text file (`lucide/meta.json`), or null when absent/unreachable. */
332
+ export type RegistryReader = (rel: string) => Promise<string | null>
333
+
334
+ /** Build the legible error for an icon the registry didn't have: fetches the pack's meta.json for
335
+ * "did you mean" suggestions; a pack with no meta falls back to licenses.json for the available-pack
336
+ * list; nothing reachable at all reads as a connectivity problem. Environment-free — callers pass
337
+ * their registry reader. */
338
+ export const iconNotFoundError = async (
339
+ pack: string,
340
+ name: string,
341
+ read: RegistryReader,
342
+ registry?: string,
343
+ ): Promise<Error> => {
344
+ const metaRaw = await read(`${pack}/meta.json`)
345
+ if (metaRaw !== null) {
346
+ let icons: Record<string, { kw?: string[] }> = {}
347
+ try { icons = (JSON.parse(metaRaw) as { icons?: typeof icons }).icons ?? {} } catch { /* corrupt meta → no suggestions */ }
348
+ const similar = suggestIconNames(name, icons)
349
+ const hint = similar.length
350
+ ? ` Did you mean ${similar.map((s) => `"${pack}:${s}"`).join(", ")}?`
351
+ : ` No similar name in the "${pack}" pack either.`
352
+ return new Error(`assetIcon: icon "${pack}:${name}" not found.${hint}`)
353
+ }
354
+ const licRaw = await read("licenses.json")
355
+ if (licRaw !== null) {
356
+ try {
357
+ const packs = Object.keys(JSON.parse(licRaw) as Record<string, unknown>).sort()
358
+ return new Error(`assetIcon: unknown icon pack "${pack}" (in "${pack}:${name}"). Available packs: ${packs.join(", ")}.`)
359
+ } catch { /* corrupt licenses → fall through to the connectivity error */ }
360
+ }
361
+ // `transient` lets callers that cache compile results skip caching this one: unlike a missing
362
+ // icon, an unreachable registry is not a property of the source being compiled.
363
+ return Object.assign(new Error(
364
+ `assetIcon: can't reach the icon registry${registry ? ` (${registry})` : ""} to resolve "${pack}:${name}". `
365
+ + `Check your connection, or set LECODES_ICON_REGISTRY.`,
366
+ ), { transient: true })
367
+ }
368
+
369
+ /** `…assets/icons/<pack>/<name>.svg` — an icon the design flow vendored into the project tree. */
370
+ const VENDORED_ICON_PATH = /(?:^|\/)assets\/icons\/([a-z0-9]+(?:-[a-z0-9]+)*)\/([a-z0-9]+(?:-[a-z0-9]+)*)\.svg$/
371
+
372
+ /** id → SVG body for every icon vendored in the compile's own tree (`assets/icons/<pack>/<name>.svg`
373
+ * text entries — the design flow vendors those). App-compile resolvers check this FIRST, so a
374
+ * pushed/pulled project with a design folder resolves its icons offline, exactly like the design
375
+ * pipeline, and only genuinely new ids go to the cache/registry. */
376
+ export const collectVendoredIcons = (entries: AssetIconEntry[]): Map<string, string> => {
377
+ const map = new Map<string, string>()
378
+ for (const e of entries) {
379
+ if (e.type !== "text" || typeof e.text !== "string") continue
380
+ const m = VENDORED_ICON_PATH.exec(e.path)
381
+ if (m) map.set(`${m[1]}:${m[2]}`, e.text)
382
+ }
383
+ return map
384
+ }
@@ -0,0 +1,146 @@
1
+ // Ported from packages/lecodes-cli/src/compile/assetMacro.ts (itself from the backend). The
2
+ // compile-time `asset('./x.png')` macro is desugared into a real import so it goes through the same
3
+ // loader/resolver as a hand-written import.
4
+
5
+ // Matches `asset("./img.png")` / `asset('./img.png')` calls whose argument is a single string
6
+ // literal (handling escaped chars inside the literal). The lookbehind excludes property access —
7
+ // a METHOD named asset (`ui.asset("model")`, InspectorUI) is not the macro.
8
+ const ASSET_RE = /(?<![.\w$])asset\(\s*(["'])((?:[^"'\\]|\\.)*)\1\s*\)/g
9
+ const ASSET_CALL_RE = /(?<![.\w$])asset\(/g
10
+
11
+ /** Cheap pre-check so we don't run the regex over every file. */
12
+ export const hasAssetMacro = (src: string): boolean => src.includes("asset(")
13
+
14
+ /**
15
+ * Blanks out comments and string/template-literal TEXT (spaces, newlines kept) so the macro scan
16
+ * only ever fires on real code. Offsets and line numbers are preserved 1:1 with the input, so a
17
+ * match position in the mask indexes the original source. `${…}` interpolations stay live — an
18
+ * `asset()` inside one is a genuine call.
19
+ *
20
+ * Not a full lexer: a regex LITERAL containing `asset(` would still be seen as code. That case
21
+ * doesn't occur in practice and behaves exactly as it did before this masking existed.
22
+ */
23
+ const maskNonCode = (src: string): string => {
24
+ const out = src.split("")
25
+ const blank = (from: number, to: number) => {
26
+ for (let k = from; k < to && k < out.length; k++) if (out[k] !== "\n") out[k] = " "
27
+ }
28
+ const tpl: number[] = [] // brace depth outside each open `${`
29
+ let braces = 0
30
+ let i = 0
31
+
32
+ // Blank a template's raw text from `i` up to `${` (which resumes code mode) or the closing tick.
33
+ const scanTemplate = () => {
34
+ const start = i
35
+ while (i < src.length) {
36
+ const c = src[i]
37
+ if (c === "\\") { i += 2; continue }
38
+ if (c === "`") { blank(start, i); i++; return }
39
+ if (c === "$" && src[i + 1] === "{") { blank(start, i); tpl.push(braces); i += 2; return }
40
+ i++
41
+ }
42
+ blank(start, i)
43
+ }
44
+
45
+ while (i < src.length) {
46
+ const c = src[i], d = src[i + 1]
47
+ if (c === "/" && d === "/") {
48
+ const e = src.indexOf("\n", i)
49
+ const stop = e === -1 ? src.length : e
50
+ blank(i, stop); i = stop; continue
51
+ }
52
+ if (c === "/" && d === "*") {
53
+ const e = src.indexOf("*/", i + 2)
54
+ const stop = e === -1 ? src.length : e + 2
55
+ blank(i, stop); i = stop; continue
56
+ }
57
+ if (c === "'" || c === "\"") {
58
+ const start = i
59
+ i++
60
+ while (i < src.length) {
61
+ const q = src[i]
62
+ if (q === "\\") { i += 2; continue }
63
+ i++
64
+ if (q === c || q === "\n") break
65
+ }
66
+ blank(start, i); continue
67
+ }
68
+ if (c === "`") { i++; scanTemplate(); continue }
69
+ if (c === "{") { braces++; i++; continue }
70
+ if (c === "}") {
71
+ if (tpl.length > 0 && braces === tpl[tpl.length - 1]) { tpl.pop(); i++; scanTemplate(); continue }
72
+ if (braces > 0) braces--
73
+ i++; continue
74
+ }
75
+ i++
76
+ }
77
+ return out.join("")
78
+ }
79
+
80
+ /** One `asset('…')` call site: the path as written, plus where it sits in the file. */
81
+ export type AssetMacroRef = { path: string, index: number, length: number, line: number }
82
+
83
+ /**
84
+ * Every `asset('…')` macro call in `src`, skipping the ones inside comments and string literals
85
+ * (a commented-out call must not pull in an asset — nor be reported missing). Throws when a call
86
+ * has a dynamic argument, which the macro can't resolve at compile time.
87
+ */
88
+ export const collectAssetMacros = (src: string, filePath: string): AssetMacroRef[] => {
89
+ const mask = maskNonCode(src)
90
+ const refs: AssetMacroRef[] = []
91
+ const literal = new Set<number>()
92
+ // Matches arrive in source order, so the line counter walks the file once.
93
+ let scanned = 0
94
+ let line = 1
95
+ for (const m of src.matchAll(ASSET_RE)) {
96
+ const index = m.index ?? 0
97
+ if (mask[index] !== "a") continue // inside a comment or a string — not code
98
+ for (; scanned < index; scanned++) if (src[scanned] === "\n") line++
99
+ refs.push({ path: m[2], index, length: m[0].length, line })
100
+ literal.add(index)
101
+ }
102
+ // Both patterns match at the `a` of `asset`, so any live call site the string-literal form
103
+ // didn't claim was called with something the compiler can't read.
104
+ for (const m of src.matchAll(ASSET_CALL_RE)) {
105
+ const index = m.index ?? 0
106
+ if (mask[index] === "a" && !literal.has(index)) {
107
+ throw new Error(`asset() expects a string literal, found a dynamic argument in: ${filePath}`)
108
+ }
109
+ }
110
+ return refs
111
+ }
112
+
113
+ /**
114
+ * Desugars `asset('./path.png')` into the exact import the user could have written by hand.
115
+ * Identical paths within a file map to one identifier. Only string-literal arguments are supported.
116
+ * `found` is this file's `collectAssetMacros` result when the caller already has it (the bundler
117
+ * collects them to check the files exist first) — otherwise it's scanned here.
118
+ */
119
+ export const desugarAssetMacros = (src: string, filePath: string, found?: AssetMacroRef[]): string => {
120
+ const refs = found ?? collectAssetMacros(src, filePath)
121
+ if (refs.length === 0) return src
122
+
123
+ const byPath = new Map<string, string>()
124
+ const parts: string[] = []
125
+ let at = 0
126
+ for (const ref of refs) {
127
+ let name = byPath.get(ref.path)
128
+ if (!name) {
129
+ name = `__asset_${byPath.size}`
130
+ byPath.set(ref.path, name)
131
+ }
132
+ parts.push(src.slice(at, ref.index), name)
133
+ at = ref.index + ref.length
134
+ }
135
+ parts.push(src.slice(at))
136
+
137
+ const imports = [ ...byPath ]
138
+ .map(([ path, name ]) => `import ${name} from ${JSON.stringify(path)};`)
139
+ .join("\n")
140
+
141
+ // APPENDED, never prepended. ESM imports hoist, so their position is semantically irrelevant —
142
+ // but lines added at the TOP would shift every original line, and the bundle's source map would
143
+ // then disagree with the file the editor shows: inspector jumps and error-overlay stack frames
144
+ // land N lines off in exactly (and only) the files that use asset(). Appending adds no offsets.
145
+ return parts.join("") + "\n" + imports
146
+ }
@@ -0,0 +1,50 @@
1
+ // The one spelling a project file name is allowed to have. Browser-safe (no imports): the backend
2
+ // (asset tree), the editor (upload/rename) and the CLI (local scan) all apply it, so the same name
3
+ // reaches every one of them identically.
4
+ //
5
+ // Why: an import specifier is matched against a file name character for character (chisel, Monaco,
6
+ // the asset tree). A name carrying an invisible or look-alike character therefore produces an import
7
+ // that LOOKS correct and resolves to nothing — a file uploaded as "итальянский<NBSP>сквер1.jpg"
8
+ // cannot be imported as "итальянский сквер1.jpg", and the two are indistinguishable on screen.
9
+ // Canonicalizing at every entry point means a stored name is always the one the user sees and types.
10
+
11
+ type Range = [ number, number ]
12
+
13
+ const inRanges = (cp: number, ranges: Range[]) => ranges.some(([ from, to ]) => cp >= from && cp <= to)
14
+
15
+ /** C0/C1 controls, soft hyphen, zero-width, bidi marks and other invisible format characters. */
16
+ const INVISIBLE: Range[] = [
17
+ [ 0x0000, 0x001f ], [ 0x007f, 0x009f ], [ 0x00ad, 0x00ad ], [ 0x061c, 0x061c ], [ 0x180e, 0x180e ],
18
+ [ 0x200b, 0x200f ], [ 0x2028, 0x2029 ], [ 0x202a, 0x202e ], [ 0x2060, 0x2064 ], [ 0x2066, 0x206f ],
19
+ [ 0xfeff, 0xfeff ],
20
+ ]
21
+
22
+ /** Every Unicode space that isn't U+0020: NBSP, en/em spaces, narrow NBSP, ideographic space, … */
23
+ const ODD_SPACE: Range[] = [
24
+ [ 0x00a0, 0x00a0 ], [ 0x1680, 0x1680 ], [ 0x2000, 0x200a ], [ 0x202f, 0x202f ], [ 0x205f, 0x205f ],
25
+ [ 0x3000, 0x3000 ],
26
+ ]
27
+
28
+ /** Path separators + characters no Windows path may hold — `lecodes clone` writes names to disk. */
29
+ const ILLEGAL = "/\\:*?\"<>|"
30
+
31
+ /**
32
+ * Canonical spelling of a single file/folder name (a NAME, not a path — separators are replaced,
33
+ * not split on). Returns "" when nothing printable is left; callers decide whether that's an error.
34
+ */
35
+ export const sanitizeAssetName = (name: string): string => {
36
+ let out = ""
37
+ // NFC first: "й" arrives from macOS as и + U+0306 (combining breve) and must collapse to U+0439.
38
+ for (const ch of name.normalize("NFC")) {
39
+ const cp = ch.codePointAt(0)!
40
+ if (inRanges(cp, INVISIBLE)) continue
41
+ out += inRanges(cp, ODD_SPACE) ? " " : ILLEGAL.includes(ch) ? "_" : ch
42
+ }
43
+ return out
44
+ .replace(/ {2,}/g, " ")
45
+ .replace(/^ +/, "") // leading dots stay — ".gitignore" is a real name
46
+ .replace(/[ .]+$/, "") // trailing dots/spaces are silently dropped by Windows
47
+ }
48
+
49
+ /** True when `name` is already canonical, i.e. storing it would change nothing. */
50
+ export const isCanonicalAssetName = (name: string): boolean => sanitizeAssetName(name) === name