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