lecodes-cli 0.10.4 → 0.12.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 (54) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +48887 -6261
  3. package/package.json +4 -3
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk/compile/aspectMacro.ts +42 -0
  6. package/runtime/sdk/compile/assetIconMacro.ts +382 -0
  7. package/runtime/sdk/compile/assetMacro.ts +45 -0
  8. package/runtime/sdk/compile/assetName.ts +50 -0
  9. package/runtime/sdk/compile/bundler.ts +244 -0
  10. package/runtime/sdk/compile/compileProject.ts +106 -0
  11. package/runtime/sdk/compile/detectEntry.ts +125 -0
  12. package/runtime/sdk/compile/fontMacro.ts +459 -0
  13. package/runtime/sdk/compile/fontRegistry.ts +78 -0
  14. package/runtime/sdk/compile/header.ts +55 -0
  15. package/runtime/sdk/compile/index.ts +53 -0
  16. package/runtime/sdk/compile/libraryImports.ts +52 -0
  17. package/runtime/sdk/compile/sceneEditor.ts +78 -0
  18. package/runtime/sdk/compile/sfnt.ts +98 -0
  19. package/runtime/sdk/compile/sourcemap.ts +25 -0
  20. package/runtime/sdk/g2/Node2D.ts +3 -0
  21. package/runtime/sdk/g2/SpriteAnimation.ts +101 -20
  22. package/runtime/sdk/g2/SpriteSheet.ts +166 -0
  23. package/runtime/sdk/g2/Tileset.ts +71 -0
  24. package/runtime/sdk/g2/autotile.ts +394 -0
  25. package/runtime/sdk/g2/cells.ts +91 -0
  26. package/runtime/sdk/g2/defineScene2d.ts +381 -0
  27. package/runtime/sdk/g2/scenarios2d.ts +69 -0
  28. package/runtime/sdk/inject.ts +18 -0
  29. package/runtime/sdk/runtime/app.ts +21 -3
  30. package/runtime/sdk/scene/defineScene.ts +10 -70
  31. package/runtime/sdk/scene/grammar.ts +85 -0
  32. package/runtime/sdk/ui/NativeView.ts +9 -12
  33. package/runtime/sdk/ui/UI.ts +9 -1
  34. package/runtime/sdk/ui/UIBottomSheet.ts +10 -20
  35. package/runtime/sdk/ui/UIButton.ts +19 -36
  36. package/runtime/sdk/ui/UIContainer.ts +41 -103
  37. package/runtime/sdk/ui/UIImage.ts +10 -18
  38. package/runtime/sdk/ui/UIInput.ts +14 -26
  39. package/runtime/sdk/ui/UIModal.ts +10 -20
  40. package/runtime/sdk/ui/UINode.ts +142 -36
  41. package/runtime/sdk/ui/UIPager.ts +29 -28
  42. package/runtime/sdk/ui/UIPopover.ts +10 -16
  43. package/runtime/sdk/ui/UIScreen.ts +23 -40
  44. package/runtime/sdk/ui/UIScrollable.ts +37 -47
  45. package/runtime/sdk/ui/UISpacer.ts +4 -8
  46. package/runtime/sdk/ui/UITabs.ts +175 -0
  47. package/runtime/sdk/ui/UIText.ts +7 -13
  48. package/runtime/sdk/ui/UIVideo.ts +11 -13
  49. package/runtime/sdk/ui/UIVirtualizedList.ts +43 -49
  50. package/runtime/sdk/ui/UIWidget.ts +24 -36
  51. package/runtime/sdk/ui/fonts.ts +3 -0
  52. package/runtime/sdk/ui/router.ts +13 -0
  53. package/runtime/sdk/ui/theme.ts +21 -30
  54. package/runtime/sdk-types.json +1 -1
@@ -0,0 +1,244 @@
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 type { ScanModule } from "./detectEntry"
8
+ import { hasAspectExtends, injectAspectNames } from "./aspectMacro"
9
+ import { hasImportStatement, rewriteLibraryImports } from "./libraryImports"
10
+
11
+ /*
12
+ * The user-project bundler. Project files live in an in-memory map; the whole SDK is delivered via
13
+ * chisel's `inject` so user code uses globals (Scene, Sprite, toast, …) with NO imports. chisel — the
14
+ * Rust method-granular bundler (@letary/chisel) — tree-shakes the injected SDK down to what the
15
+ * project references, including unused class *methods*. It is the only bundler (the esbuild fallback
16
+ * is gone). Bare imports are a hard error (chisel's resolver enforces it).
17
+ */
18
+
19
+ export type MemoryFiles = Record<string, string>
20
+
21
+ /** Locate the SDK inject entry: an explicit override (LECODES_SDK_INJECT — used by the published CLI,
22
+ * which vendors the SDK source), else the source in-repo. chisel needs the SOURCE tree (not a bundle). */
23
+ const resolveInject = (): string => {
24
+ const override = process.env.LECODES_SDK_INJECT
25
+ if (override && existsSync(override)) return override
26
+ const here = dirname(fileURLToPath(import.meta.url)) // …/sdk/src/compile — portable (Node + Bun)
27
+ const src = join(here, "..", "inject.ts")
28
+ if (existsSync(src)) return src
29
+ throw new Error("sdk: could not locate the inject source (LECODES_SDK_INJECT / src/inject.ts)")
30
+ }
31
+
32
+ const normalize = (path: string): string => {
33
+ const parts: string[] = []
34
+ for (const part of path.split("/")) {
35
+ if (!part || part === ".") continue
36
+ if (part === "..") parts.pop()
37
+ else parts.push(part)
38
+ }
39
+ return "/" + parts.join("/")
40
+ }
41
+
42
+ // ---- chisel (Rust) bundler -------------------------------------------------------------------
43
+ //
44
+ // chisel is fed an in-memory file map over JSON-stdio: the SDK source under `/__sdk/…` (injected via
45
+ // `/__sdk/inject.ts`), the project files at their own paths, and a `define` map. It does
46
+ // method-granular tree-shaking + (when minify is on) compress + mangle. Assets/shaders/svg reach it
47
+ // as `asset()` macros desugared to default imports; aspect names are injected as `static aspect`.
48
+
49
+ // `EDITOR` is a boolean define: `false` (const-folded, `if (EDITOR)` branches DCE'd away) in
50
+ // every shipped bundle, `true` only in scene-editor compiles (CompileOptions.editor). Requires a
51
+ // chisel with boolean-define support — an older binary ignores the entry and leaves `EDITOR` as a
52
+ // free identifier (a runtime ReferenceError only in code that actually uses it).
53
+ const CHISEL_DEFINE: Record<string, string> = { RAD2DEG: "57.29577951308232", DEG2RAD: "0.017453292519943295" }
54
+
55
+ // Methods the native host invokes by name have no in-bundle caller, so method-DCE can't see them.
56
+ // The SDK prefixes its framework / host-facing methods with `_` (`_emitTouchStart`, `_emitClick`,
57
+ // `_onAnimationComplete`, `_emitAnim`, …), so keep every underscore-prefixed method on classes that survive.
58
+ const CHISEL_KEEP = [ "_*" ]
59
+
60
+ /** The SDK source root (parent of `inject.ts`). chisel needs the source tree, not a prebuilt bundle. */
61
+ const resolveSdkSrc = (): string => {
62
+ const inject = resolveInject()
63
+ if (!inject.endsWith("inject.ts")) {
64
+ throw new Error(`chisel needs the SDK source tree (src/inject.ts); got ${inject}. Set LECODES_SDK_INJECT to the source.`)
65
+ }
66
+ return dirname(inject)
67
+ }
68
+
69
+ const isSdkSource = (path: string) =>
70
+ /\.[tj]s$/.test(path) && !path.endsWith(".d.ts") &&
71
+ !path.includes("/compile/") && !path.includes("/tests/") && !path.includes("/__tests__/")
72
+
73
+ // The SDK tree doesn't change within a process — read it once.
74
+ const sdkFilesCache = new Map<string, MemoryFiles>()
75
+ const collectSdkFiles = (sdkSrc: string): MemoryFiles => {
76
+ const cached = sdkFilesCache.get(sdkSrc)
77
+ if (cached) return cached
78
+ const out: MemoryFiles = {}
79
+ const walk = (dir: string) => {
80
+ for (const name of readdirSync(dir)) {
81
+ const p = join(dir, name)
82
+ if (statSync(p).isDirectory()) walk(p)
83
+ else if (isSdkSource(p)) out["/__sdk/" + relative(sdkSrc, p).split("\\").join("/")] = readFileSync(p, "utf8")
84
+ }
85
+ }
86
+ walk(sdkSrc)
87
+ sdkFilesCache.set(sdkSrc, out)
88
+ return out
89
+ }
90
+
91
+ /** Locate the chisel binary: an explicit override (LECODES_CHISEL_BIN), else the prebuilt binary from
92
+ * the @letary/chisel npm package (which resolves the platform via optional @letary/chisel-<platform>). */
93
+ const resolveChiselBin = (): string => {
94
+ const override = process.env.LECODES_CHISEL_BIN
95
+ if (override) {
96
+ if (existsSync(override)) return override
97
+ throw new Error(`LECODES_CHISEL_BIN points at a missing file: ${override}`)
98
+ }
99
+ try {
100
+ const bin = createRequire(import.meta.url)("@letary/chisel").binaryPath() as string | null
101
+ if (bin && existsSync(bin)) return bin
102
+ } catch { /* @letary/chisel (or its platform binary) not installed */ }
103
+ throw new Error("chisel binary not found. Install @letary/chisel, or set LECODES_CHISEL_BIN.")
104
+ }
105
+
106
+ /** Parse every module in `files` via chisel scan (no bundling): resolved local imports +
107
+ * hasExports per file. Null when the binary is missing or predates scan mode (it then rejects
108
+ * the entry-less input) — detectEntry then falls back to its regex scanner. Passed into
109
+ * `detectEntry` as its `scanner` by every node caller (detectEntry itself stays browser-safe). */
110
+ export const scanModulesChisel = (files: MemoryFiles): Record<string, ScanModule> | null => {
111
+ let bin: string
112
+ try {
113
+ bin = resolveChiselBin()
114
+ } catch {
115
+ return null
116
+ }
117
+ const input = JSON.stringify({ scan: true, files })
118
+ const r = spawnSync(bin, [], { input, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 })
119
+ if (r.status !== 0 || r.error) return null
120
+ try {
121
+ const parsed = JSON.parse(r.stdout || "{}") as { scan?: Record<string, ScanModule> }
122
+ return parsed.scan ?? null
123
+ } catch {
124
+ return null
125
+ }
126
+ }
127
+
128
+ const bundleProjectChisel = async (
129
+ entrypoint: string,
130
+ files: MemoryFiles,
131
+ format: "esm" | "iife",
132
+ sourcemap: boolean,
133
+ minifyOverride?: boolean,
134
+ editor?: boolean,
135
+ libraries?: Record<string, MemoryFiles>,
136
+ ): Promise<{ code: string, map: string | null, diagnostics: string[] }> => {
137
+ const entry = normalize(entrypoint)
138
+ const slugs = new Set(Object.keys(libraries ?? {}))
139
+ // Per-file source transforms (project files only — SDK aspects declare their own `static aspect`):
140
+ // - rewrite bare library imports (`from "acme-ui"`) → the injected `/__lib/<slug>/…` tree
141
+ // - desugar `asset('./x')` → a default import that resolves to the synthetic asset module
142
+ // - inject `static aspect = '<name>'` from `class … extends Aspect<'<name>', …>`
143
+ const projFiles: MemoryFiles = {}
144
+ for (const [ k, v ] of Object.entries(files)) {
145
+ const path = normalize(k)
146
+ let src = v
147
+ if (/\.[tj]sx?$/.test(path)) {
148
+ if (hasImportStatement(src)) src = rewriteLibraryImports(src, slugs)
149
+ if (hasAssetMacro(src)) src = desugarAssetMacros(src, path)
150
+ if (hasAspectExtends(src)) src = injectAspectNames(src)
151
+ }
152
+ projFiles[path] = src
153
+ }
154
+ if (!projFiles[entry]) throw new Error(`Entrypoint not found: ${entry}`)
155
+
156
+ // Library dependencies: merge each library's files under /__lib/<slug>/… (like /__sdk/…). They
157
+ // get the same asset/aspect desugaring as project code, but NOT the bare-import rewrite — a
158
+ // library can't declare dependencies (flat graph v1), so any bare import in a library is an
159
+ // error chisel surfaces. Library-internal relative imports resolve within the prefix untouched.
160
+ for (const [ slug, libFiles ] of Object.entries(libraries ?? {})) {
161
+ for (const [ k, v ] of Object.entries(libFiles)) {
162
+ const path = normalize(`/__lib/${slug}/${k}`)
163
+ let src = v
164
+ if (/\.[tj]sx?$/.test(path)) {
165
+ if (hasAssetMacro(src)) src = desugarAssetMacros(src, path)
166
+ if (hasAspectExtends(src)) src = injectAspectNames(src)
167
+ }
168
+ projFiles[path] = src
169
+ }
170
+ }
171
+
172
+ const sdkFiles = collectSdkFiles(resolveSdkSrc())
173
+ // Shipped bundles minify by default; edit-mode bundles pass `false` — mangling erases class
174
+ // names, which the scene editor matches aspect schemas by (`describeAspect().className`).
175
+ const minify = minifyOverride ?? (process.env.LECODES_CHISEL_MINIFY !== "0")
176
+ const input = JSON.stringify({
177
+ files: { ...sdkFiles, ...projFiles },
178
+ entry,
179
+ inject: [ "/__sdk/inject.ts" ],
180
+ format,
181
+ minify,
182
+ // Chain fusion, ON by default (`LECODES_CHISEL_FUSE=0` opts out): Vec3/date math chains lower to
183
+ // scalar arithmetic (bit-exact — JS numbers are f64 and the lowering never reassociates) and
184
+ // particle `curve()`/`colorCurve()` chains constant-fold into the exact native Float32Array.
185
+ fuse: process.env.LECODES_CHISEL_FUSE !== "0",
186
+ define: { ...CHISEL_DEFINE, EDITOR: editor ? "true" : "false" },
187
+ keep: CHISEL_KEEP,
188
+ sourcemap,
189
+ // Reactive-UI desugaring (memoized children .map → __uiMap + auto-wrapped signal reads in
190
+ // UIText/style args). Only fires on free references to the injected globals, i.e. user code.
191
+ // An older chisel binary ignores the unknown field — bundles still work, children just
192
+ // rebuild per change instead of reusing nodes.
193
+ reactive_ui: process.env.LECODES_CHISEL_REACTIVE_UI !== "0",
194
+ // Flatten literal array args of UI factory calls into variadic args (`UIColumn([a, b])` →
195
+ // `UIColumn(a, b)`) — the SDK's buildUI dispatch flattens array args one level anyway, so
196
+ // this only drops an array allocation per call site. Safe to always request: the sibling SDK
197
+ // source above has the variadic dispatch, and an older binary ignores the unknown field.
198
+ flatten_ui: process.env.LECODES_CHISEL_FLATTEN_UI !== "0",
199
+ })
200
+
201
+ const bin = resolveChiselBin()
202
+ const r = spawnSync(bin, [], { input, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 })
203
+ if (r.status === 2 || r.error) {
204
+ throw new Error(`chisel failed: ${r.error?.message ?? r.stderr ?? "unknown error"}`)
205
+ }
206
+ let parsed: { code?: string, map?: string, error?: string, diagnostics?: string[] }
207
+ try {
208
+ parsed = JSON.parse(r.stdout || "{}")
209
+ } catch {
210
+ throw new Error(`chisel produced invalid output: ${(r.stdout || r.stderr || "").slice(0, 200)}`)
211
+ }
212
+ if (parsed.error) throw new Error(`chisel: ${parsed.error}`)
213
+ if (parsed.code == null) throw new Error("chisel produced no code")
214
+ // Non-fatal build-time findings (`path:line:col: message`) — today: particle curve chains the
215
+ // engine would reject at set time. Warn so they're visible in the CLI / server log; the bundle
216
+ // itself is unaffected (chisel leaves an invalid chain as real calls).
217
+ const diagnostics = parsed.diagnostics ?? []
218
+ for (const d of diagnostics) console.warn(`warning: ${d}`)
219
+ return { code: parsed.code, map: parsed.map ?? null, diagnostics }
220
+ }
221
+
222
+ /**
223
+ * Bundle the project `files` (path → source) starting at `entrypoint`, with the SDK injected.
224
+ * `format` is "esm" by default (what the platform ships); "iife" produces a self-running script.
225
+ */
226
+ export const bundleProject = async (
227
+ entrypoint: string,
228
+ files: MemoryFiles,
229
+ format: "esm" | "iife" = "esm",
230
+ ): Promise<string> => (await bundleProjectChisel(entrypoint, files, format, false)).code
231
+
232
+ /** Like `bundleProject`, but also returns chisel's source map (and any build-time `diagnostics`).
233
+ * The map's `sources` are the original module paths and its generated positions are relative to
234
+ * `code` (line 1 = first line of `code`). Pass `sourcemap: false` to skip map generation entirely —
235
+ * `map` then comes back null. */
236
+ export const bundleProjectWithMap = async (
237
+ entrypoint: string,
238
+ files: MemoryFiles,
239
+ format: "esm" | "iife" = "esm",
240
+ minify?: boolean,
241
+ editor?: boolean,
242
+ libraries?: Record<string, MemoryFiles>,
243
+ sourcemap = true,
244
+ ): Promise<{ code: string, map: string | null, diagnostics: string[] }> => bundleProjectChisel(entrypoint, files, format, sourcemap, minify, editor, libraries)
@@ -0,0 +1,106 @@
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, scanModulesChisel, type MemoryFiles } from "./bundler"
7
+ import { detectEntry } from "./detectEntry"
8
+ import { buildHeader } from "./header"
9
+ import { inlineSourceMapComment, offsetSourceMap } from "./sourcemap"
10
+
11
+ export type CompileFileType = "text" | "resource" | "shader"
12
+
13
+ export type CompileEntry = {
14
+ path: string
15
+ type: CompileFileType
16
+ /** Source text for code / .svg files. */
17
+ text?: string
18
+ /** Server src for a binary resource (e.g. "/uploads/x.png"); prefixed with publicUrl. */
19
+ fileSrc?: string
20
+ /** Under `localAssets`: keep THIS resource in the URL form (publicUrl + fileSrc) anyway — for
21
+ * hosts that bundle most assets locally but stream heavy ones (e.g. videos) from the server. */
22
+ remote?: boolean
23
+ /** Compiled-shader artifacts (platform + src); the first drives the per-backend URL. */
24
+ shaders?: { platform: string, src: string }[]
25
+ }
26
+
27
+ export type CompileOptions = {
28
+ entries: CompileEntry[]
29
+ name: string
30
+ publicUrl: string
31
+ entryOverride?: string
32
+ /** Prepend the metadata header (default true). */
33
+ header?: boolean
34
+ /** Generated-icon URL for the header (backend only). */
35
+ icon?: string
36
+ /** `// fonts:` header payload (from the font() macro pass the caller ran over the entries). */
37
+ fontsHeader?: string
38
+ /** `// icons:` header payload (from the assetIcon() macro pass the caller ran over the entries). */
39
+ iconsHeader?: string
40
+ /** "esm" (default, shipped) or "iife" (self-running, for the local renderer). */
41
+ format?: "esm" | "iife"
42
+ /** Resolve `asset()` resources to a LOCAL host asset at runtime instead of a `publicUrl` URL: each
43
+ * resource compiles to `"id:" + _creatorUtils.fetchLocal(<name>)` (the host reads the file by name
44
+ * and returns its systemId). For hosts with no asset server (the desktop native host), where a URL
45
+ * can't be fetched. Off by default — the shipped/web build keeps the URL form. */
46
+ localAssets?: boolean
47
+ /** Override minification (default: on, `LECODES_CHISEL_MINIFY` env aside). Scene-editor bundles
48
+ * compile with `false`: mangling erases class names, which the editor matches aspect schemas by. */
49
+ minify?: boolean
50
+ /** Generate a source map (default true) and inline it into the bundle. `false` skips the map
51
+ * entirely — a smaller bundle, but runtime errors report generated positions only. */
52
+ sourcemap?: boolean
53
+ /** Editor compile: the `EDITOR` global compiles to `true` (scene-editor bundles). Default false —
54
+ * shipped bundles get `EDITOR` = `false`, const-folded so `if (EDITOR)` branches are DCE'd. */
55
+ editor?: boolean
56
+ /** Component-library dependencies: slug → the library's files (root-relative paths). Merged under
57
+ * `/__lib/<slug>/…` and reachable from project code via `import … from "<slug>"`. See
58
+ * docs/library-system-plan.md §4. */
59
+ libraries?: Record<string, MemoryFiles>
60
+ }
61
+
62
+ export const compileProject = async (opts: CompileOptions): Promise<string> => {
63
+ const entry = opts.entryOverride ?? detectEntry(opts.entries, scanModulesChisel).entry
64
+ if (!entry) {
65
+ throw new Error("Couldn't find an entrypoint (no .ts/.js project file — name yours main.ts). Pass entryOverride.")
66
+ }
67
+
68
+ const files: Record<string, string> = {}
69
+ for (const e of opts.entries) {
70
+ if (e.type === "resource") {
71
+ if (opts.localAssets && !e.remote) {
72
+ // The host resolves the asset by name at runtime (reads the local file → systemId); the
73
+ // "id:" prefix is the host's local-resource URL scheme (parsed by the UI + 2D texture paths).
74
+ const name = e.path.split(/[\\/]/).pop() ?? e.path
75
+ files[e.path] = `export default ("id:" + _creatorUtils.fetchLocal(${JSON.stringify(name)}))`
76
+ } else {
77
+ files[e.path] = `export default "${opts.publicUrl}${e.fileSrc ?? ""}"`
78
+ }
79
+ } else if (e.type === "shader") {
80
+ const shaders = e.shaders ?? []
81
+ if (shaders.length > 2) {
82
+ files[e.path] = `export default \`${opts.publicUrl}${shaders[0].src.replace("_opengl", "_${_creator.subBackend ?? _creator.backend}")}\``
83
+ } else if (shaders.length > 0) {
84
+ files[e.path] = `export default \`${opts.publicUrl}${shaders[0].src.replace("_opengl", "_${_creator.backend}")}\``
85
+ } else {
86
+ files[e.path] = `export default ""` // local-only shader with no compiled artifact yet
87
+ }
88
+ } else if (e.path.toLowerCase().endsWith(".svg")) {
89
+ files[e.path] = `export default SvgSource(\`${e.text ?? ""}\`)`
90
+ } else {
91
+ files[e.path] = e.text ?? ""
92
+ }
93
+ }
94
+
95
+ const { code, map } = await bundleProjectWithMap(
96
+ entry, files, opts.format ?? "esm", opts.minify, opts.editor, opts.libraries, opts.sourcemap ?? true,
97
+ )
98
+ if (opts.header === false) {
99
+ return map ? `${code}\n${inlineSourceMapComment(map)}` : code
100
+ }
101
+ const header = buildHeader(opts.name, code, { icon: opts.icon, fonts: opts.fontsHeader, icons: opts.iconsHeader })
102
+ if (!map) return header + code
103
+ // Shift the map down past the header (prepended before the code); the inline comment lands last.
104
+ const headerLines = (header.match(/\n/g) ?? []).length
105
+ return `${header}${code}\n${inlineSourceMapComment(offsetSourceMap(map, headerLines))}`
106
+ }
@@ -0,0 +1,125 @@
1
+ // Entrypoint detection from the project's import graph. The entry is the file no other project
2
+ // file imports (an import-graph root): a side-effect registration module is imported by the entry,
3
+ // so it stops being a candidate, and the entry itself may freely use `export`.
4
+ //
5
+ // Pick order among roots: `/main.ts` (the documented convention) → export-free roots by size →
6
+ // any root by size. `roots.length > 1` without a `/main.ts` means the choice was ambiguous —
7
+ // CLI callers surface that as a warning suggesting `main.ts` or `--entry`.
8
+ //
9
+ // This module is intentionally dependency-free (no node imports) so the FRONTEND can run the same
10
+ // detection live in the editor. Node callers inject chisel's `scan` mode (real parser — comments,
11
+ // strings, `import type` handled correctly) via the `scanner` parameter (`scanModulesChisel` from
12
+ // bundler.ts); without one — in the browser, or with a pre-scan chisel binary — the regex fallback
13
+ // below runs over the same resolution rules.
14
+
15
+ type Files = Record<string, string>
16
+
17
+ /** Per-module facts entry detection consumes (chisel `scan` output shape). */
18
+ export type ScanModule = { imports: string[], hasExports: boolean, error?: string }
19
+
20
+ /** A pluggable module scanner: path→source in, per-module facts out (null = unavailable). */
21
+ export type ModuleScanner = (files: Files) => Record<string, ScanModule> | null
22
+
23
+ export type DetectEntryInput = {
24
+ path: string
25
+ type?: string
26
+ text?: string
27
+ }
28
+
29
+ export type DetectedEntry = {
30
+ /** The chosen entry path ("" when the project has no candidate module at all). */
31
+ entry: string
32
+ /** Import-graph roots. More than one (without a `/main.ts` among them) = ambiguous choice. */
33
+ roots: string[]
34
+ }
35
+
36
+ /** The conventional entry filename — always wins when it is an import-graph root. */
37
+ export const ENTRY_CONVENTION = "/main.ts"
38
+
39
+ const isCode = (path: string) => /\.[tj]s$/.test(path) && !path.endsWith(".d.ts")
40
+
41
+ const normalize = (path: string): string => {
42
+ const parts: string[] = []
43
+ for (const part of path.split("/")) {
44
+ if (!part || part === ".") continue
45
+ if (part === "..") parts.pop()
46
+ else parts.push(part)
47
+ }
48
+ return "/" + parts.join("/")
49
+ }
50
+
51
+ const dirOf = (path: string): string => {
52
+ const i = path.lastIndexOf("/")
53
+ return i <= 0 ? "/" : path.slice(0, i)
54
+ }
55
+
56
+ /** Mirror of chisel's resolver: relative/absolute specifiers only, `.ts`/`.js`/`/index.ts`
57
+ * fallback, exact match first. Null for bare imports (libraries) and unresolved paths. */
58
+ const resolveSpec = (files: Files, importer: string, spec: string): string | null => {
59
+ if (!spec.startsWith("./") && !spec.startsWith("../") && !spec.startsWith("/")) return null
60
+ const base = normalize(spec.startsWith("/") ? spec : dirOf(importer) + "/" + spec)
61
+ for (const cand of [base, base + ".ts", base + ".js", base + "/index.ts"]) {
62
+ if (cand in files) return cand
63
+ }
64
+ return null
65
+ }
66
+
67
+ // One pattern, two alternatives: `import|export … from "<spec>"` (multi-line safe — the specifier
68
+ // clause may sit on a later line) and a side-effect `import "<spec>"`.
69
+ const IMPORT_RE = /(?:^|[\n;])\s*(?:import|export)\b[^'"`]*?\bfrom\s*['"]([^'"\n]+)['"]|(?:^|[\n;])\s*import\s*['"]([^'"\n]+)['"]/g
70
+ const EXPORT_RE = /(?:^|[\n;])\s*export\b/
71
+
72
+ /** Regex fallback for a chisel binary without scan support. Same output shape; less precise
73
+ * (a specifier inside a comment counts, `export type` counts as an export) — both only affect
74
+ * which root wins a tie, never whether the true entry is a root. */
75
+ export const scanModulesFallback = (files: Files): Record<string, ScanModule> => {
76
+ const out: Record<string, ScanModule> = {}
77
+ for (const [path, text] of Object.entries(files)) {
78
+ const imports: string[] = []
79
+ for (const m of text.matchAll(IMPORT_RE)) {
80
+ const target = resolveSpec(files, path, m[1] ?? m[2] ?? "")
81
+ if (target && isCode(target) && !imports.includes(target)) imports.push(target)
82
+ }
83
+ out[path] = { imports, hasExports: EXPORT_RE.test(text) }
84
+ }
85
+ return out
86
+ }
87
+
88
+ /**
89
+ * Detect the project's entrypoint. `entries` is the full project file list; only `.ts`/`.js` text
90
+ * files participate (`.d.ts` and `.editor.ts` are never entries, and editor-only imports don't
91
+ * remove a file from candidacy — production compiles don't bundle editor plugins).
92
+ */
93
+ export const detectEntry = (entries: DetectEntryInput[], scanner?: ModuleScanner): DetectedEntry => {
94
+ const files: Files = {}
95
+ for (const e of entries) {
96
+ if (e.type !== undefined && e.type !== "text") continue
97
+ if (typeof e.text !== "string") continue
98
+ const path = normalize(e.path.split("\\").join("/"))
99
+ if (isCode(path)) files[path] = e.text
100
+ }
101
+
102
+ const candidates = Object.keys(files).filter((p) => !p.endsWith(".editor.ts"))
103
+ if (candidates.length === 0) return { entry: "", roots: [] }
104
+
105
+ const scan = scanner?.(files) ?? scanModulesFallback(files)
106
+
107
+ const imported = new Set<string>()
108
+ for (const [path, mod] of Object.entries(scan)) {
109
+ if (path.endsWith(".editor.ts")) continue
110
+ for (const target of mod.imports) imported.add(target)
111
+ }
112
+
113
+ let roots = candidates.filter((p) => !imported.has(p))
114
+ // Everything imported (a cycle through every file) — degrade to all candidates.
115
+ if (roots.length === 0) roots = candidates
116
+
117
+ if (roots.includes(ENTRY_CONVENTION)) return { entry: ENTRY_CONVENTION, roots }
118
+
119
+ // Export-free roots are scripts — the old heuristic's signal, kept as the tie-breaker so
120
+ // existing projects resolve the same way; a lone exporting root is still a valid entry.
121
+ const scripts = roots.filter((p) => !scan[p]?.hasExports)
122
+ const pool = scripts.length > 0 ? scripts : roots
123
+ const bySize = [...pool].sort((a, b) => files[b].length - files[a].length)
124
+ return { entry: bySize[0], roots }
125
+ }