lecodes-cli 0.13.0 → 0.13.2
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 +51 -28
- package/package.json +3 -3
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/scene/defineScene.ts +221 -109
- package/runtime/sdk/scene/editorPlugins.ts +22 -16
- package/runtime/sdk/scene/grammar.ts +49 -14
- package/runtime/sdk-types.json +1 -1
- package/runtime/sdk/compile/aspectMacro.ts +0 -42
- package/runtime/sdk/compile/assetIconMacro.ts +0 -382
- package/runtime/sdk/compile/assetMacro.ts +0 -45
- package/runtime/sdk/compile/assetName.ts +0 -50
- package/runtime/sdk/compile/bundler.ts +0 -244
- package/runtime/sdk/compile/compileProject.ts +0 -106
- package/runtime/sdk/compile/detectEntry.ts +0 -125
- package/runtime/sdk/compile/fontMacro.ts +0 -459
- package/runtime/sdk/compile/fontRegistry.ts +0 -78
- package/runtime/sdk/compile/header.ts +0 -55
- package/runtime/sdk/compile/index.ts +0 -53
- package/runtime/sdk/compile/libraryImports.ts +0 -52
- package/runtime/sdk/compile/sceneEditor.ts +0 -78
- package/runtime/sdk/compile/sfnt.ts +0 -98
- package/runtime/sdk/compile/sourcemap.ts +0 -25
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
// Compile-time injection of an aspect's runtime name. A user aspect carries its accessor name in the
|
|
2
|
-
// (type-erased) generic — `class Health extends Aspect<'health', Sprite> {…}` — so neither the
|
|
3
|
-
// bundler nor the runtime can see it after types are stripped. This pass injects the matching
|
|
4
|
-
// `static aspect = "health"` so node.aspect()/node.health work at runtime, exactly like the SDK's
|
|
5
|
-
// built-in aspects declare it by hand.
|
|
6
|
-
//
|
|
7
|
-
// Runs on PROJECT files only (the SDK's own aspects already declare `static aspect`). Idempotent:
|
|
8
|
-
// skips a class that already declares one. String-based, like assetMacro — no parser dependency.
|
|
9
|
-
|
|
10
|
-
// `class X extends Aspect<'name', …>` up to the class body's opening brace. Group 3 = the name.
|
|
11
|
-
const ASPECT_RE = /\bclass\s+\w+\s+extends\s+Aspect\s*<\s*(["'])((?:[^"'\\]|\\.)*)\1[^{};]*>\s*\{/g
|
|
12
|
-
|
|
13
|
-
/** Cheap pre-check so we don't scan files that define no aspects. */
|
|
14
|
-
export const hasAspectExtends = (src: string): boolean => /\bextends\s+Aspect\s*</.test(src)
|
|
15
|
-
|
|
16
|
-
/** Inject `static aspect = "<name>"` into each `class … extends Aspect<'<name>', …>`. */
|
|
17
|
-
export const injectAspectNames = (src: string): string => {
|
|
18
|
-
let out = ""
|
|
19
|
-
let last = 0
|
|
20
|
-
ASPECT_RE.lastIndex = 0
|
|
21
|
-
let m: RegExpExecArray | null
|
|
22
|
-
while ((m = ASPECT_RE.exec(src)) !== null) {
|
|
23
|
-
const braceEnd = m.index + m[0].length // index just past the opening `{`
|
|
24
|
-
if (!classBodyDeclaresAspect(src, braceEnd)) {
|
|
25
|
-
out += src.slice(last, braceEnd) + ` static aspect = ${JSON.stringify(m[2])};`
|
|
26
|
-
last = braceEnd
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
return last === 0 ? src : out + src.slice(last)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Does the class body starting just after `from` (its `{`) already declare a `static aspect`? */
|
|
33
|
-
const classBodyDeclaresAspect = (src: string, from: number): boolean => {
|
|
34
|
-
let depth = 1
|
|
35
|
-
let i = from
|
|
36
|
-
for (; i < src.length && depth > 0; i++) {
|
|
37
|
-
const c = src[i]
|
|
38
|
-
if (c === "{") depth++
|
|
39
|
-
else if (c === "}") depth--
|
|
40
|
-
}
|
|
41
|
-
return /\bstatic\b[^;{}()=]*\baspect\b/.test(src.slice(from, i))
|
|
42
|
-
}
|
|
@@ -1,382 +0,0 @@
|
|
|
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
|
-
* - backend design board: the already-pushed `design/assets/icons/**` text assets, no registry;
|
|
20
|
-
* - CLI app compiles: the user-level cache (~/.lecodes/cache/icons) + registry on miss;
|
|
21
|
-
* - backend app compile: a 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
|
-
return new Error(
|
|
362
|
-
`assetIcon: can't reach the icon registry${registry ? ` (${registry})` : ""} to resolve "${pack}:${name}". `
|
|
363
|
-
+ `Check your connection, or set LECODES_ICON_REGISTRY.`,
|
|
364
|
-
)
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/** `…assets/icons/<pack>/<name>.svg` — an icon the design flow vendored into the project tree. */
|
|
368
|
-
const VENDORED_ICON_PATH = /(?:^|\/)assets\/icons\/([a-z0-9]+(?:-[a-z0-9]+)*)\/([a-z0-9]+(?:-[a-z0-9]+)*)\.svg$/
|
|
369
|
-
|
|
370
|
-
/** id → SVG body for every icon vendored in the compile's own tree (`assets/icons/<pack>/<name>.svg`
|
|
371
|
-
* text entries — the design flow vendors those). App-compile resolvers check this FIRST, so a
|
|
372
|
-
* pushed/pulled project with a design folder resolves its icons offline, exactly like the design
|
|
373
|
-
* pipeline, and only genuinely new ids go to the cache/registry. */
|
|
374
|
-
export const collectVendoredIcons = (entries: AssetIconEntry[]): Map<string, string> => {
|
|
375
|
-
const map = new Map<string, string>()
|
|
376
|
-
for (const e of entries) {
|
|
377
|
-
if (e.type !== "text" || typeof e.text !== "string") continue
|
|
378
|
-
const m = VENDORED_ICON_PATH.exec(e.path)
|
|
379
|
-
if (m) map.set(`${m[1]}:${m[2]}`, e.text)
|
|
380
|
-
}
|
|
381
|
-
return map
|
|
382
|
-
}
|
|
@@ -1,45 +0,0 @@
|
|
|
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
|
-
|
|
10
|
-
/** Cheap pre-check so we don't run the regex over every file. */
|
|
11
|
-
export const hasAssetMacro = (src: string): boolean => src.includes("asset(")
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Desugars `asset('./path.png')` into the exact import the user could have written by hand.
|
|
15
|
-
* Identical paths within a file map to one identifier. Only string-literal arguments are supported.
|
|
16
|
-
*/
|
|
17
|
-
export const desugarAssetMacros = (src: string, filePath: string): string => {
|
|
18
|
-
const byPath = new Map<string, string>()
|
|
19
|
-
let i = 0
|
|
20
|
-
|
|
21
|
-
const out = src.replace(ASSET_RE, (_match, _quote, path) => {
|
|
22
|
-
let name = byPath.get(path)
|
|
23
|
-
if (!name) {
|
|
24
|
-
name = `__asset_${i++}`
|
|
25
|
-
byPath.set(path, name)
|
|
26
|
-
}
|
|
27
|
-
return name
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
if (/(?<![.\w$])asset\(/.test(out)) {
|
|
31
|
-
throw new Error(`asset() expects a string literal, found a dynamic argument in: ${filePath}`)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
if (byPath.size === 0) return src
|
|
35
|
-
|
|
36
|
-
const imports = [ ...byPath ]
|
|
37
|
-
.map(([ path, name ]) => `import ${name} from ${JSON.stringify(path)};`)
|
|
38
|
-
.join("\n")
|
|
39
|
-
|
|
40
|
-
// APPENDED, never prepended. ESM imports hoist, so their position is semantically irrelevant —
|
|
41
|
-
// but lines added at the TOP would shift every original line, and the bundle's source map would
|
|
42
|
-
// then disagree with the file the editor shows: inspector jumps and error-overlay stack frames
|
|
43
|
-
// land N lines off in exactly (and only) the files that use asset(). Appending adds no offsets.
|
|
44
|
-
return out + "\n" + imports
|
|
45
|
-
}
|
|
@@ -1,50 +0,0 @@
|
|
|
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
|