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.
- package/dist/index.js +1240 -170
- package/package.json +3 -2
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/compile/aspectMacro.ts +86 -0
- package/runtime/sdk/compile/assetIconMacro.ts +384 -0
- package/runtime/sdk/compile/assetMacro.ts +146 -0
- package/runtime/sdk/compile/assetName.ts +50 -0
- package/runtime/sdk/compile/bundler.ts +287 -0
- package/runtime/sdk/compile/compileProject.ts +129 -0
- package/runtime/sdk/compile/detectEntry.ts +128 -0
- package/runtime/sdk/compile/fontMacro.ts +459 -0
- package/runtime/sdk/compile/fontRegistry.ts +78 -0
- package/runtime/sdk/compile/header.ts +67 -0
- package/runtime/sdk/compile/index.ts +100 -0
- package/runtime/sdk/compile/libraryImports.ts +52 -0
- package/runtime/sdk/compile/liteMaterial.ts +247 -0
- package/runtime/sdk/compile/sceneEditor.ts +88 -0
- package/runtime/sdk/compile/serverSplit.ts +233 -0
- package/runtime/sdk/compile/serverTypes.ts +227 -0
- package/runtime/sdk/compile/sfnt.ts +98 -0
- package/runtime/sdk/compile/shaderSchema.ts +202 -0
- package/runtime/sdk/compile/shaderTargets.ts +81 -0
- package/runtime/sdk/compile/sourcemap.ts +25 -0
- package/runtime/sdk/core/Aspect.ts +65 -9
- package/runtime/sdk/core/StateMachine.ts +308 -0
- package/runtime/sdk/core/time.ts +3 -0
- package/runtime/sdk/gl/CameraPlace.ts +52 -52
- package/runtime/sdk/gl/Light.ts +12 -4
- package/runtime/sdk/gl/Lightmap.ts +87 -10
- package/runtime/sdk/gl/Material.ts +15 -0
- package/runtime/sdk/gl/Model.ts +68 -8
- package/runtime/sdk/gl/NavAgent.ts +337 -0
- package/runtime/sdk/gl/NavMesh.ts +397 -0
- package/runtime/sdk/gl/Ragdoll.ts +270 -0
- package/runtime/sdk/gl/Scene.ts +7 -0
- package/runtime/sdk/gl/Shape.ts +37 -5
- package/runtime/sdk/gl/Terrain.ts +1092 -0
- package/runtime/sdk/gl/Texture.ts +17 -0
- package/runtime/sdk/gl/Trigger.ts +45 -45
- package/runtime/sdk/gl/animation/Animator.ts +35 -2
- package/runtime/sdk/gl/animation/Layer.ts +3 -1
- package/runtime/sdk/gl/animation/Loop.ts +5 -0
- package/runtime/sdk/gl/animation/Playback.ts +4 -3
- package/runtime/sdk/gl/animation/core.ts +52 -10
- package/runtime/sdk/gl/scenarios.ts +291 -291
- package/runtime/sdk/gl/state.ts +6 -6
- package/runtime/sdk/gl/terrainMesh.ts +219 -0
- package/runtime/sdk/inject.ts +12 -1
- package/runtime/sdk/net/codec.ts +119 -0
- package/runtime/sdk/net/core.ts +384 -0
- package/runtime/sdk/net/index.ts +181 -0
- package/runtime/sdk/net/replication.ts +622 -0
- package/runtime/sdk/scene/defineScene.ts +140 -14
- package/runtime/sdk/scene/editorPlugins.ts +18 -0
- package/runtime/sdk/scene/gizmos.ts +148 -148
- package/runtime/sdk/ui/UINode.ts +4 -0
- 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
|