lecodes-cli 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +3 -0
  2. package/dist/index.js +883 -507
  3. package/package.json +9 -4
  4. package/runtime/sdk/canvas/Canvas.ts +270 -0
  5. package/runtime/sdk/compile/aspectMacro.ts +42 -0
  6. package/runtime/sdk/compile/assetMacro.ts +44 -0
  7. package/runtime/sdk/compile/bundler.ts +173 -0
  8. package/runtime/sdk/compile/compileProject.ts +84 -0
  9. package/runtime/sdk/compile/header.ts +46 -0
  10. package/runtime/sdk/compile/index.ts +12 -0
  11. package/runtime/sdk/compile/sourcemap.ts +25 -0
  12. package/runtime/sdk/core/Aspect.ts +82 -26
  13. package/runtime/sdk/core/signals.ts +277 -0
  14. package/runtime/sdk/g2/CharacterController2D.ts +5 -4
  15. package/runtime/sdk/g2/Node2D.ts +1 -1
  16. package/runtime/sdk/g2/Physics2D.ts +22 -12
  17. package/runtime/sdk/g2/Scene2D.ts +30 -0
  18. package/runtime/sdk/g2/{Shape.ts → Shape2D.ts} +10 -10
  19. package/runtime/sdk/g2/Sprite.ts +16 -6
  20. package/runtime/sdk/g2/Texture2D.ts +12 -2
  21. package/runtime/sdk/g2/{Trigger.ts → Trigger2D.ts} +9 -9
  22. package/runtime/sdk/gl/CharacterController.ts +101 -0
  23. package/runtime/sdk/gl/Material.ts +10 -3
  24. package/runtime/sdk/gl/Node.ts +11 -2
  25. package/runtime/sdk/gl/Physics.ts +65 -41
  26. package/runtime/sdk/gl/Scene.ts +18 -0
  27. package/runtime/sdk/gl/Shape.ts +98 -0
  28. package/runtime/sdk/gl/Texture.ts +13 -2
  29. package/runtime/sdk/gl/Trigger.ts +46 -0
  30. package/runtime/sdk/gl/physicsEvents.ts +20 -0
  31. package/runtime/sdk/inject.ts +14 -3
  32. package/runtime/sdk/runtime/camera.ts +31 -0
  33. package/runtime/sdk/runtime/device.ts +26 -0
  34. package/runtime/sdk/runtime/share.ts +9 -0
  35. package/runtime/sdk/ui/UI.ts +5 -1
  36. package/runtime/sdk/ui/UIButton.ts +11 -5
  37. package/runtime/sdk/ui/UIContainer.ts +28 -13
  38. package/runtime/sdk/ui/UIImage.ts +31 -11
  39. package/runtime/sdk/ui/UIInput.ts +10 -0
  40. package/runtime/sdk/ui/UINode.ts +206 -8
  41. package/runtime/sdk/ui/UIScreen.ts +17 -7
  42. package/runtime/sdk/ui/UIScrollable.ts +10 -5
  43. package/runtime/sdk/ui/UISpacer.ts +4 -0
  44. package/runtime/sdk/ui/UIText.ts +21 -5
  45. package/runtime/sdk/ui/UIVideo.ts +5 -0
  46. package/runtime/sdk/ui/UIVirtualizedList.ts +5 -0
  47. package/runtime/sdk/ui/UIWidget.ts +10 -5
  48. package/runtime/sdk/gl/Collider.ts +0 -29
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lecodes-cli",
3
- "version": "0.2.0",
3
+ "version": "0.5.0",
4
4
  "description": "Clone, edit and push LeCodes projects from your machine.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,23 +21,28 @@
21
21
  },
22
22
  "scripts": {
23
23
  "vendor-runtime": "bun scripts/vendor-runtime.ts",
24
- "build": "bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external @letary/chisel --external lecodes-renderer --banner=\"#!/usr/bin/env node\"",
24
+ "build": "bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external @letary/chisel --external lecodes-renderer --external lecodes-design --banner=\"#!/usr/bin/env node\"",
25
25
  "typecheck": "tsc --noEmit",
26
26
  "dev": "bun run src/index.ts",
27
27
  "prepublishOnly": "bun run build"
28
28
  },
29
29
  "dependencies": {
30
- "@letary/chisel": "^0.1.2"
30
+ "@letary/chisel": "^0.2.0"
31
31
  },
32
32
  "peerDependencies": {
33
- "lecodes-renderer": "^0.2.0"
33
+ "lecodes-design": "^0.1.0",
34
+ "lecodes-renderer": "^0.4.0"
34
35
  },
35
36
  "peerDependenciesMeta": {
37
+ "lecodes-design": {
38
+ "optional": true
39
+ },
36
40
  "lecodes-renderer": {
37
41
  "optional": true
38
42
  }
39
43
  },
40
44
  "devDependencies": {
45
+ "lecodes-design": "workspace:*",
41
46
  "lecodes-renderer": "workspace:*",
42
47
  "sdk": "workspace:*"
43
48
  }
@@ -0,0 +1,270 @@
1
+ // A core 2D drawing surface that bakes to a texture — see docs/canvas-contract.md. It is NOT part of
2
+ // creator-2d/creator-gl: the texture it produces is consumed by 2D sprites, 3D materials and UI
3
+ // images alike ("a canvas is just a texture"), so world/label text and arbitrary 2D graphics are
4
+ // drawn once, per platform, on that platform's native 2D stack (web OffscreenCanvas, iOS CoreGraphics,
5
+ // Android Canvas).
6
+ //
7
+ // The API looks immediate but RECORDS commands into a compact buffer and flushes them to the backend
8
+ // ONCE per bake (one bridge crossing, not one per lineTo). Colors and fonts are CSS strings, resolved
9
+ // by the platform. Do all drawing AFTER `await registerFont(...)` — an unloaded font mis-measures.
10
+ //
11
+ // State (fillStyle, font, textAlign, …) is get/set like the browser; drawing calls are methods (they
12
+ // return `this`, so drawing still chains). `pixelRatio` is the device-pixel multiplier (browser-style
13
+ // `scale(sx, sy)` is a transform).
14
+ //
15
+ // const c = new Canvas(160, 40, { pixelRatio: 2 })
16
+ // c.font = 'bold 24px Inter'
17
+ // const w = c.measureText('Alice').width + 24
18
+ // c.resize(w, 40)
19
+ // c.fillStyle = '#000a'; c.roundRect(0, 0, w, 40, 12).fill()
20
+ // c.fillStyle = '#fff'; c.textAlign = 'center'; c.fillText('Alice', w / 2, 27)
21
+ // new Sprite({ texture: c }) // Canvas is a valid texture source everywhere
22
+
23
+ import { Texture2D } from "../g2/Texture2D"
24
+ import { File } from "../runtime/fetch"
25
+
26
+ // Opcodes — MUST stay in sync with docs/canvas-contract.md.
27
+ const OP = {
28
+ SAVE: 0, RESTORE: 1, TRANSLATE: 2, SCALE: 3, ROTATE: 4, GLOBAL_ALPHA: 5,
29
+ FILL_STYLE: 6, STROKE_STYLE: 7, LINE_WIDTH: 8, LINE_JOIN: 9, LINE_CAP: 10,
30
+ FONT: 11, TEXT_ALIGN: 12, TEXT_BASELINE: 13,
31
+ BEGIN_PATH: 14, CLOSE_PATH: 15, MOVE_TO: 16, LINE_TO: 17, QUADRATIC_TO: 18, BEZIER_TO: 19,
32
+ ARC: 20, RECT: 21, ROUND_RECT: 22, FILL: 23, STROKE: 24,
33
+ FILL_RECT: 25, STROKE_RECT: 26, CLEAR_RECT: 27, FILL_TEXT: 28, STROKE_TEXT: 29,
34
+ DRAW_IMAGE: 30,
35
+ } as const
36
+
37
+ const ALIGN = { left: 0, center: 1, right: 2, start: 3, end: 4 } as const
38
+ const BASELINE = { alphabetic: 0, top: 1, middle: 2, bottom: 3, hanging: 4, ideographic: 5 } as const
39
+ const JOIN = { miter: 0, round: 1, bevel: 2 } as const
40
+ const CAP = { butt: 0, round: 1, square: 2 } as const
41
+
42
+ export type TextAlign = keyof typeof ALIGN
43
+ export type TextBaseline = keyof typeof BASELINE
44
+ export type LineJoin = keyof typeof JOIN
45
+ export type LineCap = keyof typeof CAP
46
+ export interface CanvasTextMetrics { width: number, ascent: number, descent: number }
47
+
48
+ export class Canvas {
49
+ /** Brand so `Sprite`/`UIImage`/`Material` can accept a Canvas anywhere a texture source is expected. */
50
+ readonly __canvas = true as const
51
+
52
+ width: number
53
+ height: number
54
+ /** Device-pixel multiplier (draw at 2 for crisp retina text). Applied by the backend. */
55
+ pixelRatio: number
56
+
57
+ private _cmd: number[] = []
58
+ private _refs: string[] = []
59
+ // Shadowed drawing state so the get/set accessors read back the current value (like the browser).
60
+ private _globalAlpha = 1
61
+ private _fillStyle = "#000000"
62
+ private _strokeStyle = "#000000"
63
+ private _lineWidth = 1
64
+ private _lineJoin: LineJoin = "miter"
65
+ private _lineCap: LineCap = "butt"
66
+ private _font = "10px sans-serif"
67
+ private _textAlign: TextAlign = "start"
68
+ private _textBaseline: TextBaseline = "alphabetic"
69
+ private _surfaceId = 0
70
+ private _tex2d?: Texture2D // cached 2D texture, re-uploaded on update()
71
+ /** @internal — cached 3D (creator-gl) texture id, set by gl/Texture.fromCanvas; re-uploaded on
72
+ * update(). Kept as a bare id (not a Texture import) so a 2D-only canvas never pulls the 3D engine. */
73
+ _tex3dId = 0
74
+
75
+ constructor(width: number, height: number, opts: { pixelRatio?: number } = {}) {
76
+ this.width = width
77
+ this.height = height
78
+ this.pixelRatio = opts.pixelRatio ?? 1
79
+ }
80
+
81
+ private _ref(s: string): number {
82
+ const i = this._refs.indexOf(s)
83
+ if (i >= 0) return i
84
+ this._refs.push(s)
85
+ return this._refs.length - 1
86
+ }
87
+
88
+ /** Discard the recorded drawing to start a new one (for dynamic canvases). */
89
+ reset(): this { this._cmd.length = 0; this._refs.length = 0; return this }
90
+ /** Change the surface size; takes effect on the next bake. */
91
+ resize(width: number, height: number): this { this.width = width; this.height = height; return this }
92
+
93
+ // --- transforms & state stack (actions; chainable) ---
94
+ save(): this { this._cmd.push(OP.SAVE); return this }
95
+ restore(): this { this._cmd.push(OP.RESTORE); return this }
96
+ translate(x: number, y: number): this { this._cmd.push(OP.TRANSLATE, x, y); return this }
97
+ scale(sx: number, sy: number): this { this._cmd.push(OP.SCALE, sx, sy); return this }
98
+ rotate(rad: number): this { this._cmd.push(OP.ROTATE, rad); return this }
99
+
100
+ // --- drawing state (get/set like the browser; colors/fonts are CSS strings resolved by the platform).
101
+ // The setter records the state op AND shadows the value so the getter reads it back.
102
+ get globalAlpha(): number { return this._globalAlpha }
103
+ set globalAlpha(a: number) { this._globalAlpha = a; this._cmd.push(OP.GLOBAL_ALPHA, a) }
104
+ get fillStyle(): string { return this._fillStyle }
105
+ set fillStyle(color: string) { this._fillStyle = color; this._cmd.push(OP.FILL_STYLE, this._ref(color)) }
106
+ get strokeStyle(): string { return this._strokeStyle }
107
+ set strokeStyle(color: string) { this._strokeStyle = color; this._cmd.push(OP.STROKE_STYLE, this._ref(color)) }
108
+ get lineWidth(): number { return this._lineWidth }
109
+ set lineWidth(w: number) { this._lineWidth = w; this._cmd.push(OP.LINE_WIDTH, w) }
110
+ get lineJoin(): LineJoin { return this._lineJoin }
111
+ set lineJoin(j: LineJoin) { this._lineJoin = j; this._cmd.push(OP.LINE_JOIN, JOIN[j]) }
112
+ get lineCap(): LineCap { return this._lineCap }
113
+ set lineCap(c: LineCap) { this._lineCap = c; this._cmd.push(OP.LINE_CAP, CAP[c]) }
114
+ get font(): string { return this._font }
115
+ set font(f: string) { this._font = f; this._cmd.push(OP.FONT, this._ref(f)) }
116
+ get textAlign(): TextAlign { return this._textAlign }
117
+ set textAlign(a: TextAlign) { this._textAlign = a; this._cmd.push(OP.TEXT_ALIGN, ALIGN[a]) }
118
+ get textBaseline(): TextBaseline { return this._textBaseline }
119
+ set textBaseline(b: TextBaseline) { this._textBaseline = b; this._cmd.push(OP.TEXT_BASELINE, BASELINE[b]) }
120
+
121
+ // --- paths ---
122
+ beginPath(): this { this._cmd.push(OP.BEGIN_PATH); return this }
123
+ closePath(): this { this._cmd.push(OP.CLOSE_PATH); return this }
124
+ moveTo(x: number, y: number): this { this._cmd.push(OP.MOVE_TO, x, y); return this }
125
+ lineTo(x: number, y: number): this { this._cmd.push(OP.LINE_TO, x, y); return this }
126
+ quadraticCurveTo(cx: number, cy: number, x: number, y: number): this { this._cmd.push(OP.QUADRATIC_TO, cx, cy, x, y); return this }
127
+ bezierCurveTo(c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number): this {
128
+ this._cmd.push(OP.BEZIER_TO, c1x, c1y, c2x, c2y, x, y); return this
129
+ }
130
+ arc(x: number, y: number, r: number, a0: number, a1: number, ccw = false): this {
131
+ this._cmd.push(OP.ARC, x, y, r, a0, a1, ccw ? 1 : 0); return this
132
+ }
133
+ rect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.RECT, x, y, w, h); return this }
134
+ roundRect(x: number, y: number, w: number, h: number, r: number): this { this._cmd.push(OP.ROUND_RECT, x, y, w, h, r); return this }
135
+ fill(evenOdd = false): this { this._cmd.push(OP.FILL, evenOdd ? 1 : 0); return this }
136
+ stroke(): this { this._cmd.push(OP.STROKE); return this }
137
+
138
+ // --- shape/text sugar ---
139
+ fillRect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.FILL_RECT, x, y, w, h); return this }
140
+ strokeRect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.STROKE_RECT, x, y, w, h); return this }
141
+ clearRect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.CLEAR_RECT, x, y, w, h); return this }
142
+ fillText(text: string, x: number, y: number, maxWidth = 0): this { this._cmd.push(OP.FILL_TEXT, this._ref(text), x, y, maxWidth); return this }
143
+ strokeText(text: string, x: number, y: number, maxWidth = 0): this { this._cmd.push(OP.STROKE_TEXT, this._ref(text), x, y, maxWidth); return this }
144
+
145
+ // --- images ---
146
+ /** Blit a `Bitmap` (a snapshot from `toBitmap()`) onto this canvas. All coordinates are LOGICAL —
147
+ * including the source sub-rect in the 9-arg form (it's scaled by the bitmap's pixelRatio here).
148
+ * Records a DRAW_IMAGE op, so the blit runs at bake time: keep the Bitmap alive until the next
149
+ * `update()`/`toBitmap()`/`texture()`. Three browser-style forms:
150
+ * drawImage(bmp, dx, dy) — whole bitmap at its natural size
151
+ * drawImage(bmp, dx, dy, dw, dh) — whole bitmap, scaled into dw×dh
152
+ * drawImage(bmp, sx, sy, sw, sh, dx, dy, dw, dh) — a sub-rect of the bitmap, scaled into dw×dh */
153
+ drawImage(img: Bitmap, dx: number, dy: number): this
154
+ drawImage(img: Bitmap, dx: number, dy: number, dw: number, dh: number): this
155
+ drawImage(img: Bitmap, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number): this
156
+ drawImage(img: Bitmap, a: number, b: number, c?: number, d?: number, e?: number, f?: number, g?: number, h?: number): this {
157
+ const pr = img.pixelRatio
158
+ let sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number
159
+ if (e === undefined) { // (dx, dy) or (dx, dy, dw, dh): the whole bitmap
160
+ sx = 0; sy = 0; sw = img.pixelWidth; sh = img.pixelHeight
161
+ dx = a; dy = b; dw = c ?? img.width; dh = d ?? img.height
162
+ } else { // (sx, sy, sw, sh, dx, dy, dw, dh): a sub-rect (logical → device px)
163
+ sx = a * pr; sy = b * pr; sw = c! * pr; sh = d! * pr
164
+ dx = e; dy = f!; dw = g!; dh = h!
165
+ }
166
+ this._cmd.push(OP.DRAW_IMAGE, img._surfaceId, sx, sy, sw, sh, dx, dy, dw, dh)
167
+ return this
168
+ }
169
+
170
+ /** Measure `text` in the current font on THIS platform. The one synchronous round-trip. */
171
+ measureText(text: string): CanvasTextMetrics {
172
+ const o = _creatorCanvas.measureText(text, this._font)
173
+ return { width: o[0], ascent: o[1], descent: o[2] }
174
+ }
175
+
176
+ /** @internal — rasterize the recorded commands into the surface; returns the surface id. */
177
+ _bakeSurface(): number {
178
+ const buf = new Float32Array(this._cmd)
179
+ this._surfaceId = _creatorCanvas.rasterize(this._surfaceId, this.width, this.height, this.pixelRatio, buf, buf.length, this._refs)
180
+ return this._surfaceId
181
+ }
182
+
183
+ /** Bake and return the 2D texture (created once, then cached). */
184
+ texture(): Texture2D {
185
+ if (!this._tex2d) this._tex2d = Texture2D.fromCanvas(this)
186
+ return this._tex2d
187
+ }
188
+
189
+ /** Re-rasterize and re-upload to every texture this canvas produced (call after redrawing) — the
190
+ * cached 2D and/or 3D texture, whichever engines this canvas was baked into. */
191
+ update(): this {
192
+ const surfaceId = this._bakeSurface()
193
+ if (this._tex2d) _creator2d.updateTextureFromCanvas(this._tex2d.id, surfaceId)
194
+ if (this._tex3dId) _creator.updateTextureFromCanvas(this._tex3dId, surfaceId)
195
+ return this
196
+ }
197
+
198
+ /** Copy the current pixels into a standalone, immutable `Bitmap` — independent of this canvas, so
199
+ * later drawing/`reset()` never touches it. Draw it back with `drawImage()`, export it with
200
+ * `toDataURL()`, or use it to "flatten" an ever-growing drawing so re-bakes stay O(1):
201
+ * const snap = canvas.toBitmap()
202
+ * canvas.reset(); canvas.drawImage(snap, 0, 0) // one op replaces the whole history
203
+ * // …keep drawing on top; snap.destroy() once it's been drawn in. */
204
+ toBitmap(): Bitmap {
205
+ return new Bitmap(_creatorCanvas.snapshot(this._bakeSurface()), this.width, this.height, this.pixelRatio)
206
+ }
207
+
208
+ /** Encode the current pixels to an image `File` — 'image/png' (default) or 'image/jpeg'. The File
209
+ * is a host buffer handle {id,name,size}: append it to a FormData to upload, load it as a texture
210
+ * (`Texture2D.load(file)`), or hand it to a share/save sheet. */
211
+ toFile(name = "canvas.png", type = "image/png"): Promise<File> {
212
+ const surfaceId = this._bakeSurface()
213
+ return new Promise<File>((res, rej) => {
214
+ _creatorCanvas.toFile(surfaceId, type, (bufferId, size) => res(new File(bufferId, name, size)), rej)
215
+ })
216
+ }
217
+
218
+ destroy(): void {
219
+ if (this._surfaceId) _creatorCanvas.destroySurface(this._surfaceId)
220
+ this._tex2d?.destroy()
221
+ // The 3D texture (if any) is owned by creator-gl's engine and freed at its teardown — no per-texture
222
+ // destroy in the 3D API — so we just drop our cached id here.
223
+ this._surfaceId = 0
224
+ this._tex2d = undefined
225
+ this._tex3dId = 0
226
+ }
227
+ }
228
+
229
+ /** Duck-type guard so consumers can accept a Canvas anywhere a texture source is expected. */
230
+ export const isCanvas = (v: unknown): v is Canvas =>
231
+ typeof v === "object" && v !== null && (v as { __canvas?: unknown }).__canvas === true
232
+
233
+ /** An immutable RGBA8 snapshot of a canvas, produced by `Canvas.toBitmap()`. It owns its own baked
234
+ * surface (a copy), so the source canvas can keep drawing or `reset()` without affecting it. Use it
235
+ * to stamp/blit back (`canvas.drawImage(bitmap, …)`), to export (`bitmap.toDataURL()`), or as a
236
+ * cheap "layer" you composite. Call `destroy()` when done — it holds a native surface. */
237
+ export class Bitmap {
238
+ readonly __bitmap = true as const
239
+ /** @internal — a standalone `_creatorCanvas` surface (RGBA8), independent of any Canvas. */
240
+ readonly _surfaceId: number
241
+ /** Logical width, matching the Canvas it was captured from (pixelRatio is crispness, not size). */
242
+ readonly width: number
243
+ readonly height: number
244
+ readonly pixelRatio: number
245
+
246
+ constructor(surfaceId: number, width: number, height: number, pixelRatio: number) {
247
+ this._surfaceId = surfaceId
248
+ this.width = width
249
+ this.height = height
250
+ this.pixelRatio = pixelRatio
251
+ }
252
+
253
+ /** Device-pixel dimensions of the stored bitmap (`width * pixelRatio`, rounded). */
254
+ get pixelWidth(): number { return Math.round(this.width * this.pixelRatio) }
255
+ get pixelHeight(): number { return Math.round(this.height * this.pixelRatio) }
256
+
257
+ /** Encode to an image `File` — 'image/png' (default) or 'image/jpeg'. See `Canvas.toFile`. */
258
+ toFile(name = "canvas.png", type = "image/png"): Promise<File> {
259
+ return new Promise<File>((res, rej) => {
260
+ _creatorCanvas.toFile(this._surfaceId, type, (bufferId, size) => res(new File(bufferId, name, size)), rej)
261
+ })
262
+ }
263
+
264
+ /** Free the native surface. The bitmap must not be drawn or encoded afterwards. */
265
+ destroy(): void { _creatorCanvas.destroySurface(this._surfaceId) }
266
+ }
267
+
268
+ /** Duck-type guard for a Bitmap (see `isCanvas`). */
269
+ export const isBitmap = (v: unknown): v is Bitmap =>
270
+ typeof v === "object" && v !== null && (v as { __bitmap?: unknown }).__bitmap === true
@@ -0,0 +1,42 @@
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
+ }
@@ -0,0 +1,44 @@
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).
7
+ const ASSET_RE = /\basset\(\s*(["'])((?:[^"'\\]|\\.)*)\1\s*\)/g
8
+
9
+ /** Cheap pre-check so we don't run the regex over every file. */
10
+ export const hasAssetMacro = (src: string): boolean => src.includes("asset(")
11
+
12
+ /**
13
+ * Desugars `asset('./path.png')` into the exact import the user could have written by hand.
14
+ * Identical paths within a file map to one identifier. Only string-literal arguments are supported.
15
+ */
16
+ export const desugarAssetMacros = (src: string, filePath: string): string => {
17
+ const byPath = new Map<string, string>()
18
+ let i = 0
19
+
20
+ const out = src.replace(ASSET_RE, (_match, _quote, path) => {
21
+ let name = byPath.get(path)
22
+ if (!name) {
23
+ name = `__asset_${i++}`
24
+ byPath.set(path, name)
25
+ }
26
+ return name
27
+ })
28
+
29
+ if (/\basset\(/.test(out)) {
30
+ throw new Error(`asset() expects a string literal, found a dynamic argument in: ${filePath}`)
31
+ }
32
+
33
+ if (byPath.size === 0) return src
34
+
35
+ const imports = [ ...byPath ]
36
+ .map(([ path, name ]) => `import ${name} from ${JSON.stringify(path)};`)
37
+ .join("\n")
38
+
39
+ // APPENDED, never prepended. ESM imports hoist, so their position is semantically irrelevant —
40
+ // but lines added at the TOP would shift every original line, and the bundle's source map would
41
+ // then disagree with the file the editor shows: inspector jumps and error-overlay stack frames
42
+ // land N lines off in exactly (and only) the files that use asset(). Appending adds no offsets.
43
+ return out + "\n" + imports
44
+ }
@@ -0,0 +1,173 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
2
+ import { spawnSync } from "node:child_process"
3
+ import { createRequire } from "node:module"
4
+ import { dirname, join, relative } from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+ import { desugarAssetMacros, hasAssetMacro } from "./assetMacro"
7
+ import { hasAspectExtends, injectAspectNames } from "./aspectMacro"
8
+
9
+ /*
10
+ * The user-project bundler. Project files live in an in-memory map; the whole SDK is delivered via
11
+ * chisel's `inject` so user code uses globals (Scene, Sprite, toast, …) with NO imports. chisel — the
12
+ * Rust method-granular bundler (@letary/chisel) — tree-shakes the injected SDK down to what the
13
+ * project references, including unused class *methods*. It is the only bundler (the esbuild fallback
14
+ * is gone). Bare imports are a hard error (chisel's resolver enforces it).
15
+ */
16
+
17
+ export type MemoryFiles = Record<string, string>
18
+
19
+ /** Locate the SDK inject entry: an explicit override (LECODES_SDK_INJECT — used by the published CLI,
20
+ * which vendors the SDK source), else the source in-repo. chisel needs the SOURCE tree (not a bundle). */
21
+ const resolveInject = (): string => {
22
+ const override = process.env.LECODES_SDK_INJECT
23
+ if (override && existsSync(override)) return override
24
+ const here = dirname(fileURLToPath(import.meta.url)) // …/sdk/src/compile — portable (Node + Bun)
25
+ const src = join(here, "..", "inject.ts")
26
+ if (existsSync(src)) return src
27
+ throw new Error("sdk: could not locate the inject source (LECODES_SDK_INJECT / src/inject.ts)")
28
+ }
29
+
30
+ const normalize = (path: string): string => {
31
+ const parts: string[] = []
32
+ for (const part of path.split("/")) {
33
+ if (!part || part === ".") continue
34
+ if (part === "..") parts.pop()
35
+ else parts.push(part)
36
+ }
37
+ return "/" + parts.join("/")
38
+ }
39
+
40
+ // ---- chisel (Rust) bundler -------------------------------------------------------------------
41
+ //
42
+ // chisel is fed an in-memory file map over JSON-stdio: the SDK source under `/__sdk/…` (injected via
43
+ // `/__sdk/inject.ts`), the project files at their own paths, and a `define` map. It does
44
+ // method-granular tree-shaking + (when minify is on) compress + mangle. Assets/shaders/svg reach it
45
+ // as `asset()` macros desugared to default imports; aspect names are injected as `static aspect`.
46
+
47
+ const CHISEL_DEFINE: Record<string, string> = { RAD2DEG: "57.29577951308232", DEG2RAD: "0.017453292519943295" }
48
+
49
+ // Methods the native host invokes by name have no in-bundle caller, so method-DCE can't see them.
50
+ // The SDK prefixes its framework / host-facing methods with `_` (`_emitTouchStart`, `_emitClick`,
51
+ // `_onAnimationComplete`, `_emitAnim`, …), so keep every underscore-prefixed method on classes that survive.
52
+ const CHISEL_KEEP = [ "_*" ]
53
+
54
+ /** The SDK source root (parent of `inject.ts`). chisel needs the source tree, not a prebuilt bundle. */
55
+ const resolveSdkSrc = (): string => {
56
+ const inject = resolveInject()
57
+ if (!inject.endsWith("inject.ts")) {
58
+ throw new Error(`chisel needs the SDK source tree (src/inject.ts); got ${inject}. Set LECODES_SDK_INJECT to the source.`)
59
+ }
60
+ return dirname(inject)
61
+ }
62
+
63
+ const isSdkSource = (path: string) =>
64
+ /\.[tj]s$/.test(path) && !path.endsWith(".d.ts") &&
65
+ !path.includes("/compile/") && !path.includes("/tests/") && !path.includes("/__tests__/")
66
+
67
+ // The SDK tree doesn't change within a process — read it once.
68
+ const sdkFilesCache = new Map<string, MemoryFiles>()
69
+ const collectSdkFiles = (sdkSrc: string): MemoryFiles => {
70
+ const cached = sdkFilesCache.get(sdkSrc)
71
+ if (cached) return cached
72
+ const out: MemoryFiles = {}
73
+ const walk = (dir: string) => {
74
+ for (const name of readdirSync(dir)) {
75
+ const p = join(dir, name)
76
+ if (statSync(p).isDirectory()) walk(p)
77
+ else if (isSdkSource(p)) out["/__sdk/" + relative(sdkSrc, p).split("\\").join("/")] = readFileSync(p, "utf8")
78
+ }
79
+ }
80
+ walk(sdkSrc)
81
+ sdkFilesCache.set(sdkSrc, out)
82
+ return out
83
+ }
84
+
85
+ /** Locate the chisel binary: an explicit override (LECODES_CHISEL_BIN), else the prebuilt binary from
86
+ * the @letary/chisel npm package (which resolves the platform via optional @letary/chisel-<platform>). */
87
+ const resolveChiselBin = (): string => {
88
+ const override = process.env.LECODES_CHISEL_BIN
89
+ if (override) {
90
+ if (existsSync(override)) return override
91
+ throw new Error(`LECODES_CHISEL_BIN points at a missing file: ${override}`)
92
+ }
93
+ try {
94
+ const bin = createRequire(import.meta.url)("@letary/chisel").binaryPath() as string | null
95
+ if (bin && existsSync(bin)) return bin
96
+ } catch { /* @letary/chisel (or its platform binary) not installed */ }
97
+ throw new Error("chisel binary not found. Install @letary/chisel, or set LECODES_CHISEL_BIN.")
98
+ }
99
+
100
+ const bundleProjectChisel = async (
101
+ entrypoint: string,
102
+ files: MemoryFiles,
103
+ format: "esm" | "iife",
104
+ sourcemap: boolean,
105
+ ): Promise<{ code: string, map: string | null }> => {
106
+ const entry = normalize(entrypoint)
107
+ // Per-file source transforms (project files only — SDK aspects declare their own `static aspect`):
108
+ // - desugar `asset('./x')` → a default import that resolves to the synthetic asset module
109
+ // - inject `static aspect = '<name>'` from `class … extends Aspect<'<name>', …>`
110
+ const projFiles: MemoryFiles = {}
111
+ for (const [ k, v ] of Object.entries(files)) {
112
+ const path = normalize(k)
113
+ let src = v
114
+ if (/\.[tj]sx?$/.test(path)) {
115
+ if (hasAssetMacro(src)) src = desugarAssetMacros(src, path)
116
+ if (hasAspectExtends(src)) src = injectAspectNames(src)
117
+ }
118
+ projFiles[path] = src
119
+ }
120
+ if (!projFiles[entry]) throw new Error(`Entrypoint not found: ${entry}`)
121
+
122
+ const sdkFiles = collectSdkFiles(resolveSdkSrc())
123
+ const minify = process.env.LECODES_CHISEL_MINIFY !== "0" // shipped bundles minify by default
124
+ const input = JSON.stringify({
125
+ files: { ...sdkFiles, ...projFiles },
126
+ entry,
127
+ inject: [ "/__sdk/inject.ts" ],
128
+ format,
129
+ minify,
130
+ fuse: process.env.LECODES_CHISEL_FUSE === "1",
131
+ define: CHISEL_DEFINE,
132
+ keep: CHISEL_KEEP,
133
+ sourcemap,
134
+ // Reactive-UI desugaring (memoized children .map → __uiMap + auto-wrapped signal reads in
135
+ // UIText/style args). Only fires on free references to the injected globals, i.e. user code.
136
+ // An older chisel binary ignores the unknown field — bundles still work, children just
137
+ // rebuild per change instead of reusing nodes.
138
+ reactive_ui: process.env.LECODES_CHISEL_REACTIVE_UI !== "0",
139
+ })
140
+
141
+ const bin = resolveChiselBin()
142
+ const r = spawnSync(bin, [], { input, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 })
143
+ if (r.status === 2 || r.error) {
144
+ throw new Error(`chisel failed: ${r.error?.message ?? r.stderr ?? "unknown error"}`)
145
+ }
146
+ let parsed: { code?: string, map?: string, error?: string }
147
+ try {
148
+ parsed = JSON.parse(r.stdout || "{}")
149
+ } catch {
150
+ throw new Error(`chisel produced invalid output: ${(r.stdout || r.stderr || "").slice(0, 200)}`)
151
+ }
152
+ if (parsed.error) throw new Error(`chisel: ${parsed.error}`)
153
+ if (parsed.code == null) throw new Error("chisel produced no code")
154
+ return { code: parsed.code, map: parsed.map ?? null }
155
+ }
156
+
157
+ /**
158
+ * Bundle the project `files` (path → source) starting at `entrypoint`, with the SDK injected.
159
+ * `format` is "esm" by default (what the platform ships); "iife" produces a self-running script.
160
+ */
161
+ export const bundleProject = async (
162
+ entrypoint: string,
163
+ files: MemoryFiles,
164
+ format: "esm" | "iife" = "esm",
165
+ ): Promise<string> => (await bundleProjectChisel(entrypoint, files, format, false)).code
166
+
167
+ /** Like `bundleProject`, but also returns chisel's source map. The map's `sources` are the original
168
+ * module paths and its generated positions are relative to `code` (line 1 = first line of `code`). */
169
+ export const bundleProjectWithMap = async (
170
+ entrypoint: string,
171
+ files: MemoryFiles,
172
+ format: "esm" | "iife" = "esm",
173
+ ): Promise<{ code: string, map: string | null }> => bundleProjectChisel(entrypoint, files, format, true)
@@ -0,0 +1,84 @@
1
+ // Turns a project's files into the esbuild input map + entrypoint + metadata header, then bundles.
2
+ // The one canonical "user project → deployable bundle" path: the backend and CLI both call this
3
+ // (the backend resolves asset URLs from the DB, the CLI from local metadata — same function, the
4
+ // URLs are passed in as `publicUrl` + per-entry `fileSrc`/`shaders`).
5
+
6
+ import { bundleProjectWithMap } from "./bundler"
7
+ import { buildHeader } from "./header"
8
+ import { inlineSourceMapComment, offsetSourceMap } from "./sourcemap"
9
+
10
+ export type CompileFileType = "text" | "resource" | "shader"
11
+
12
+ export type CompileEntry = {
13
+ path: string
14
+ type: CompileFileType
15
+ /** Source text for code / .svg files. */
16
+ text?: string
17
+ /** Server src for a binary resource (e.g. "/uploads/x.png"); prefixed with publicUrl. */
18
+ fileSrc?: string
19
+ /** Compiled-shader artifacts (platform + src); the first drives the per-backend URL. */
20
+ shaders?: { platform: string, src: string }[]
21
+ }
22
+
23
+ export type CompileOptions = {
24
+ entries: CompileEntry[]
25
+ name: string
26
+ publicUrl: string
27
+ entryOverride?: string
28
+ /** Prepend the metadata header (default true). */
29
+ header?: boolean
30
+ /** Generated-icon URL for the header (backend only). */
31
+ icon?: string
32
+ /** "esm" (default, shipped) or "iife" (self-running, for the local renderer). */
33
+ format?: "esm" | "iife"
34
+ }
35
+
36
+ /** The default entrypoint: the largest top-level .ts/.js file with no `export` (a script entry). */
37
+ const detectEntry = (entries: CompileEntry[]): string => {
38
+ let entry = ""
39
+ let max = -1
40
+ for (const e of entries) {
41
+ const ext = e.path.split(".").at(-1)?.toLowerCase()
42
+ if (e.type === "text" && e.text !== undefined && !e.text.includes("export") && (ext === "ts" || ext === "js")) {
43
+ if (e.text.length > max) { entry = e.path; max = e.text.length }
44
+ }
45
+ }
46
+ return entry
47
+ }
48
+
49
+ export const compileProject = async (opts: CompileOptions): Promise<string> => {
50
+ const entry = opts.entryOverride ?? detectEntry(opts.entries)
51
+ if (!entry) {
52
+ throw new Error("Couldn't find an entrypoint (a top-level .ts/.js file with no `export`). Pass entryOverride.")
53
+ }
54
+
55
+ const files: Record<string, string> = {}
56
+ for (const e of opts.entries) {
57
+ if (e.type === "resource") {
58
+ files[e.path] = `export default "${opts.publicUrl}${e.fileSrc ?? ""}"`
59
+ } else if (e.type === "shader") {
60
+ const shaders = e.shaders ?? []
61
+ if (shaders.length > 2) {
62
+ files[e.path] = `export default \`${opts.publicUrl}${shaders[0].src.replace("_opengl", "_${_creator.subBackend ?? _creator.backend}")}\``
63
+ } else if (shaders.length > 0) {
64
+ files[e.path] = `export default \`${opts.publicUrl}${shaders[0].src.replace("_opengl", "_${_creator.backend}")}\``
65
+ } else {
66
+ files[e.path] = `export default ""` // local-only shader with no compiled artifact yet
67
+ }
68
+ } else if (e.path.toLowerCase().endsWith(".svg")) {
69
+ files[e.path] = `export default SvgSource(\`${e.text ?? ""}\`)`
70
+ } else {
71
+ files[e.path] = e.text ?? ""
72
+ }
73
+ }
74
+
75
+ const { code, map } = await bundleProjectWithMap(entry, files, opts.format ?? "esm")
76
+ if (opts.header === false) {
77
+ return map ? `${code}\n${inlineSourceMapComment(map)}` : code
78
+ }
79
+ const header = buildHeader(opts.name, code, { icon: opts.icon })
80
+ if (!map) return header + code
81
+ // Shift the map down past the header (prepended before the code); the inline comment lands last.
82
+ const headerLines = (header.match(/\n/g) ?? []).length
83
+ return `${header}${code}\n${inlineSourceMapComment(offsetSourceMap(map, headerLines))}`
84
+ }