lecodes-cli 2.0.2 → 2.0.4

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 (39) hide show
  1. package/dist/index.js +1013 -127
  2. package/package.json +13 -12
  3. package/runtime/sdk-types.json +1 -1
  4. package/runtime/web/VERSION.json +3 -3
  5. package/runtime/web/assets/{inspect-C7x78PNs.js → inspect-DdJ50Kun.js} +2 -2
  6. package/runtime/web/player.js +18 -18
  7. package/runtime/web-lite/assets/canvas-C6G_Pb2Q.js +1 -0
  8. package/runtime/web-lite/assets/pager-O712dZv9.js +1 -0
  9. package/runtime/web-lite/assets/roboto-cyrillic-DVBNKCCH.woff2 +0 -0
  10. package/runtime/web-lite/assets/roboto-cyrillic-ext-1gPz9dK9.woff2 +0 -0
  11. package/runtime/web-lite/assets/roboto-greek-DVLYEgdx.woff2 +0 -0
  12. package/runtime/web-lite/assets/roboto-greek-ext-D98D44EL.woff2 +0 -0
  13. package/runtime/web-lite/assets/roboto-latin-cAJwcdBQ.woff2 +0 -0
  14. package/runtime/web-lite/assets/roboto-latin-ext-t610Sqxq.woff2 +0 -0
  15. package/runtime/web-lite/assets/roboto-rest-C7JkyNHT.woff2 +0 -0
  16. package/runtime/web-lite/assets/roboto-vietnamese-w-p-nKZZ.woff2 +0 -0
  17. package/runtime/web-lite/assets/tween-t_XDy3Nd.js +1 -0
  18. package/runtime/web-lite/assets/vlist-gEB8Sn0S.js +1 -0
  19. package/runtime/web-lite/features.json +51 -0
  20. package/runtime/web-lite/index.html +23 -0
  21. package/runtime/web-lite/lite.js +249 -0
  22. package/src/commands/design/comments.ts +1 -1
  23. package/src/commands/design/context.ts +1 -1
  24. package/src/commands/design/index.ts +1 -1
  25. package/src/commands/dev.ts +17 -53
  26. package/src/commands/index.ts +2 -0
  27. package/src/commands/test.ts +55 -6
  28. package/src/commands/update/index.ts +8 -5
  29. package/src/commands/web.ts +171 -0
  30. package/src/compile/fonts.ts +5 -2
  31. package/src/dev/devServer.ts +20 -1
  32. package/src/dev/watchProject.ts +53 -0
  33. package/src/dev/webRunner.ts +10 -0
  34. package/src/hosts/archives.ts +66 -0
  35. package/src/hosts/liteBrowser.mjs +86 -0
  36. package/src/hosts/liteRunner.ts +386 -0
  37. package/src/hosts/peerInstall.ts +12 -8
  38. package/src/web/build.ts +194 -0
  39. package/src/web/preview.ts +50 -0
@@ -1,11 +1,11 @@
1
- import { existsSync, mkdirSync, readFileSync, watch, writeFileSync, type FSWatcher } from "node:fs"
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
2
  import { randomBytes } from "node:crypto"
3
3
  import { networkInterfaces } from "node:os"
4
4
  import { basename, join } from "node:path"
5
5
  import { findProjectRoot, readManifest, LECODES_DIR, type Manifest } from "../project/manifest"
6
- import { loadIgnore } from "../project/ignore"
7
6
  import { startDevServer } from "../dev/devServer"
8
7
  import { resolveWebRunner } from "../dev/webRunner"
8
+ import { watchProject } from "../dev/watchProject"
9
9
  import { openBrowser } from "../platform/browserAuth"
10
10
  import { runDesktopDev } from "../hosts/desktopRenderer"
11
11
  import { androidCrashLines, launchOnAndroid, type AndroidDevSession } from "../dev/androidDev"
@@ -14,7 +14,6 @@ import { readAppConfigAt } from "./app/shared"
14
14
  import { resolveScale } from "./desktop"
15
15
  import { CliError, bool, c, defineCommand, log, note, num, str, warnErr } from "../cli"
16
16
  import { bundleFlags } from "./shared"
17
- import { toKey } from "../project/paths"
18
17
 
19
18
  /*
20
19
  * `lecodes dev` — run the project on a device straight from this machine, with hot reload.
@@ -54,12 +53,6 @@ import { toKey } from "../project/paths"
54
53
  */
55
54
 
56
55
  const DEFAULT_PORT = 4499
57
- const DEBOUNCE_MS = 120
58
-
59
- // Never trigger a recompile (mirrors scanLocal's skip lists + our own artifacts).
60
- const WATCH_SKIP_DIRS = new Set([".lecodes", ".git", "node_modules"])
61
- const WATCH_SKIP_FILES = new Set(["tsconfig.json", "jsconfig.json", ".gitignore", ".lecodesignore", ".DS_Store"])
62
-
63
56
  /*
64
57
  * The origin a phone on the same Wi-Fi can reach, best first.
65
58
  *
@@ -92,7 +85,7 @@ export const lanCandidates = (): { address: string, iface: string }[] => {
92
85
  }
93
86
 
94
87
  /** The per-project dev token, persisted so the QR/URL stays stable across runs. */
95
- const loadDevToken = (root: string): string => {
88
+ export const loadDevToken = (root: string): string => {
96
89
  const path = join(root, LECODES_DIR, "dev.json")
97
90
  try {
98
91
  const token = (JSON.parse(readFileSync(path, "utf8")) as { token?: string }).token
@@ -195,45 +188,16 @@ export default defineCommand({
195
188
  }
196
189
 
197
190
  // ---- watcher: debounced whole-project recompile; a successful compile broadcasts the reload ----
198
- const ignore = loadIgnore(root)
199
- const shouldRecompile = (file: string | null): boolean => {
200
- if (file === null) return true // event without a filename — assume project content
201
- const parts = file.replace(/\\/g, "/").split("/")
202
- if (parts.some((p) => WATCH_SKIP_DIRS.has(p))) return false
203
- if (WATCH_SKIP_FILES.has(parts[parts.length - 1])) return false
204
- try {
205
- const key = toKey(file)
206
- if (ignore(key, false)) return false
207
- } catch { return false }
208
- return true
209
- }
210
-
211
- const debug = process.env.LECODES_DEV_DEBUG === "1"
212
- let timer: NodeJS.Timeout | null = null
213
- const onChange = (file: string | null) => {
214
- if (debug) note(`[watch] event: ${file ?? "(null)"} → ${shouldRecompile(file) ? "recompile" : "skip"}`)
215
- if (!shouldRecompile(file)) return
216
- if (timer) clearTimeout(timer)
217
- timer = setTimeout(async () => {
218
- timer = null
219
- const result = await server.recompile()
220
- if (!result.ok) {
221
- warnErr(`Compile failed (devices keep the last good version):\n${result.error}`)
222
- } else if (result.changed) {
223
- const devices = server.clientCount()
224
- note(`✓ v${result.v} → ${devices} device${devices === 1 ? "" : "s"}`)
225
- for (const w of result.warnings) warnErr(w)
226
- }
227
- }, DEBOUNCE_MS)
228
- }
229
-
230
- let watcher: FSWatcher | null = null
231
- try {
232
- watcher = watch(root, { recursive: true }, (_type, file) => onChange(file))
233
- } catch {
234
- warnErr("fs.watch(recursive) unavailable on this platform — hot reload disabled (restart to pick up changes).")
235
- }
236
- watcher?.on("error", (e) => warnErr(`Watcher error — hot reload may have stopped (restart lecodes dev): ${e.message}`))
191
+ const watcher = watchProject(root, async () => {
192
+ const result = await server.recompile()
193
+ if (!result.ok) {
194
+ warnErr(`Compile failed (devices keep the last good version):\n${result.error}`)
195
+ } else if (result.changed) {
196
+ const devices = server.clientCount()
197
+ note(`✓ v${result.v} → ${devices} device${devices === 1 ? "" : "s"}`)
198
+ for (const w of result.warnings) warnErr(w)
199
+ }
200
+ }, { warn: warnErr, debug: process.env.LECODES_DEV_DEBUG === "1" ? note : undefined })
237
201
 
238
202
  // ---- banner ------------------------------------------------------------------------------------
239
203
  log("")
@@ -270,13 +234,13 @@ export default defineCommand({
270
234
  try {
271
235
  session = launchOnAndroid({ port, url: androidUrl, serial: flags.serial, apk: flags.apk, packageName: flags.package })
272
236
  } catch (e) {
273
- server.close(); watcher?.close()
237
+ server.close(); watcher.close()
274
238
  throw e
275
239
  }
276
240
  note(`Android: ${session.serial} → ${androidUrl} (adb reverse; device logs stream here as [device:log])`)
277
241
  const finish = (code: number) => {
278
242
  session.stop()
279
- server.close(); watcher?.close()
243
+ server.close(); watcher.close()
280
244
  process.exit(code)
281
245
  }
282
246
  process.on("SIGINT", () => { note("Stopping the app on the phone…"); finish(0) })
@@ -306,13 +270,13 @@ export default defineCommand({
306
270
  try {
307
271
  session = launchOnIos({ simulatorUrl: `http://127.0.0.1:${port}${path}`, deviceUrl, udid: flags.udid, app: flags.app, bundleId: flags.bundle })
308
272
  } catch (e) {
309
- server.close(); watcher?.close()
273
+ server.close(); watcher.close()
310
274
  throw e
311
275
  }
312
276
  note(`iOS: ${session.name} → ${session.kind === "simulator" ? `http://127.0.0.1:${port}${path}` : deviceUrl} (device logs stream here as [device:log])`)
313
277
  const finish = (code: number) => {
314
278
  session.stop()
315
- server.close(); watcher?.close()
279
+ server.close(); watcher.close()
316
280
  process.exit(code)
317
281
  }
318
282
  process.on("SIGINT", () => { note(`Stopping the app on ${session.name}…`); finish(0) })
@@ -26,6 +26,7 @@ import status from "./status"
26
26
  import test from "./test"
27
27
  import thumbs from "./thumbs"
28
28
  import update from "./update"
29
+ import web from "./web"
29
30
 
30
31
  /*
31
32
  * THE command list. Adding a command = one file with a `defineCommand` default export + one line
@@ -35,6 +36,7 @@ export const commandGroups = (opts: { install: boolean }): Group[] => [
35
36
  { title: "Project", commands: [init, create, clone, link, status, diff, pull, push, login] },
36
37
  { title: "Run & test", commands: [
37
38
  dev,
39
+ web,
38
40
  compile,
39
41
  render,
40
42
  test,
@@ -7,6 +7,9 @@ import { buildBundle } from "./compile"
7
7
  import { findProjectRoot } from "../project/manifest"
8
8
  import { runDesktopRender } from "../hosts/desktopRenderer"
9
9
  import { evaluateDesktopRun, translateScenario } from "../hosts/desktopScript"
10
+ import { startLiteSession, type LiteSession } from "../hosts/liteRunner"
11
+ import { resolveLiteRunner } from "../dev/webRunner"
12
+ import { readManifest, type Manifest } from "../project/manifest"
10
13
  import { CliError, bool, c, defineCommand, log, logErr, note, num, str, warnErr, type FlagValues } from "../cli"
11
14
  import { bundleFlags, bundleOptions } from "./shared"
12
15
 
@@ -32,6 +35,10 @@ import { bundleFlags, bundleOptions } from "./shared"
32
35
  * coordinates, expect.log + expect.paint checked, the semantic expects reported skipped). The
33
36
  * design-derived flows need `expect.screen` and `fill`, which the host can't answer, so they stay
34
37
  * headless-only. The viewport / timing and --desktop flags are `lecodes render`'s own groups.
38
+ *
39
+ * `--lite` runs them on WEB LITE in a browser (liteRunner.ts: headless Chromium, the page's own
40
+ * pointer and key events): the same steps, targets and expects, judged by the same rules as
41
+ * --desktop. The clock is the browser's — a step that expects a log line waits for it.
35
42
  */
36
43
 
37
44
  type DesignEdge = { from: string, to: string, label?: string, activator?: string }
@@ -149,6 +156,7 @@ const testFlags = {
149
156
  ...viewportFlags,
150
157
  ...bundleFlags,
151
158
  ...desktopRenderFlags,
159
+ lite: bool("run the file scenarios on Web Lite, in a browser (needs Playwright): the same steps, targets and expects; the clock is the browser's, so a step that expects a log line waits for it; design flows stay headless"),
152
160
  desktop: bool("run the file scenarios through the NATIVE host: the same steps, targets and expects as headless (expect.audio and the json output excepted); design flows stay headless. --frames / --fixed-dt as in render --desktop"),
153
161
  }
154
162
  // (`sim` comes with desktopRenderFlags: the file scenarios through the WINDOWLESS host — no GPU, no
@@ -169,6 +177,9 @@ export default defineCommand({
169
177
  const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
170
178
  // --sim is the native path on the windowless host: everything --desktop does, minus pixels.
171
179
  const sim = flags.sim
180
+ const lite = flags.lite
181
+ if (lite && (flags.desktop || sim)) throw new CliError("--lite is a host of its own — drop --desktop / --sim.")
182
+ if (lite && flags.flows) throw new CliError("--flows needs the headless renderer (design flows read expect.screen and fill inputs) — drop --lite.")
172
183
  const desktop = flags.desktop || sim
173
184
  if (desktop && flags.flows) throw new CliError(`--flows needs the headless renderer (design flows read expect.screen and fill inputs) — drop --${sim ? "sim" : "desktop"}.`)
174
185
  // --desktop: ONE renderer decision (host binary + the staged .filamat variant). The root is
@@ -177,14 +188,14 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
177
188
  try { desktopRoot = findProjectRoot(process.cwd()) } catch { desktopRoot = process.cwd() }
178
189
  const desktopRenderer = desktop ? resolveDesktopRenderer(desktopRoot, flags.renderer) : undefined
179
190
  const native = desktop ? await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer }) : null
180
- const headless = desktop ? null : await compileHeadlessBundle({ entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"] })
191
+ const headless = desktop || lite ? null : await compileHeadlessBundle({ entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"] })
181
192
  const warnings = headless?.warnings ?? []
182
193
  const useLocalAssets = headless?.useLocalAssets ?? false
183
194
  const js = headless?.js ?? ""
184
195
  let root: string
185
196
  if (headless) root = headless.root
186
197
  else { try { root = findProjectRoot(process.cwd()) } catch { root = process.cwd() } }
187
- const renderer = desktop ? null : await requireRenderer()
198
+ const renderer = desktop || lite ? null : await requireRenderer()
188
199
  const nativeFrames = flags.frames || 60
189
200
  const nativeFixedDt = flags["fixed-dt"] ?? 16
190
201
  if (nativeFixedDt < 0 || nativeFixedDt > 1000) throw new CliError(`--fixed-dt wants a number of ms (0 = wall clock, max 1000), got "${nativeFixedDt}".`)
@@ -226,10 +237,10 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
226
237
  if (flowsOnly) cases.length = 0
227
238
  let skipped: SkippedFlow[] = []
228
239
  let entryScreen: string | null = null
229
- if (desktop && wantFlows && existsSync(designMetaPath)) {
230
- note("Design-derived flows skipped with --desktop (they read expect.screen and fill inputs; run `lecodes test` headless for them).")
240
+ if ((desktop || lite) && wantFlows && existsSync(designMetaPath)) {
241
+ note("Design-derived flows skipped with --desktop / --lite (they read expect.screen and fill inputs; run `lecodes test` headless for them).")
231
242
  }
232
- if (!desktop && (wantFlows || flowsOnly) && existsSync(designMetaPath)) {
243
+ if (!desktop && !lite && (wantFlows || flowsOnly) && existsSync(designMetaPath)) {
233
244
  let meta: DesignMeta
234
245
  try {
235
246
  meta = JSON.parse(readFileSync(designMetaPath, "utf8")) as DesignMeta
@@ -286,7 +297,7 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
286
297
  const reportCase = (result: CaseResult): void => {
287
298
  const skippedCount = result.steps.reduce((n, s) => n + (s.skipped?.length ?? 0), 0)
288
299
  if (result.ok) {
289
- out(`${c.green("✓")} ${result.name}${skippedCount > 0 ? c.dim(` (${skippedCount} semantic check${skippedCount === 1 ? "" : "s"} skipped on --desktop)`) : ""}`)
300
+ out(`${c.green("✓")} ${result.name}${skippedCount > 0 ? c.dim(` (${skippedCount} check${skippedCount === 1 ? "" : "s"} skipped on this host)`) : ""}`)
290
301
  } else {
291
302
  out(`${c.red("✗")} ${result.name}`)
292
303
  if (result.error) out(` ${c.red(result.error)}`)
@@ -324,8 +335,45 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
324
335
  }
325
336
  }
326
337
 
338
+ // Web Lite: one compile, one browser; a page of its own per case.
339
+ let liteSession: LiteSession | null = null
340
+ if (lite) {
341
+ const page = resolveLiteRunner(note)
342
+ if (!page) throw new CliError("No Web Lite page found: this CLI has no runtime/web-lite (a monorepo checkout needs `bun run build` in hosts/web-lite).")
343
+ let manifest: Manifest | null = null
344
+ try { manifest = readManifest(root) } catch { /* a folder standing alone */ }
345
+ liteSession = await startLiteSession({ root, manifest, name: manifest?.name ?? "app", lite: page, entry: flags.entry })
346
+ }
347
+ const runLiteCase = async (kase: FlowCase): Promise<CaseResult> => {
348
+ const caseDir = resolve(artifactsRoot, slug(kase.name))
349
+ const plan = translateScenario(kase.steps, { outDir: caseDir, headroomFrames: 0 })
350
+ mkdirSync(caseDir, { recursive: true })
351
+ const output = await liteSession!.run(plan, {
352
+ width: flags.width || kase.width || projectViewport?.width || 390,
353
+ height: flags.height || kase.height || projectViewport?.height || 844,
354
+ onConsole,
355
+ })
356
+ const r = evaluateDesktopRun(plan, output, (path) => existsSync(path) ? readFileSync(path, "utf8") : null)
357
+ return {
358
+ name: kase.name, kind: kase.kind, ok: r.ok,
359
+ steps: r.steps.map((s) => ({ desc: s.desc, ok: s.ok, error: s.error, skipped: s.skipped.length > 0 ? s.skipped : undefined })),
360
+ error: !r.loaded ? "the page did not load the app" : r.ended ? undefined : "the page stopped before the script finished",
361
+ artifacts: [],
362
+ }
363
+ }
364
+
327
365
  for (const kase of cases) {
328
366
  let result: CaseResult
367
+ if (lite) {
368
+ try {
369
+ result = await runLiteCase(kase)
370
+ } catch (e) {
371
+ result = { name: kase.name, kind: kase.kind, ok: false, steps: [], error: e instanceof Error ? e.message : String(e), artifacts: [] }
372
+ }
373
+ results.push(result)
374
+ reportCase(result)
375
+ continue
376
+ }
329
377
  if (desktop) {
330
378
  try {
331
379
  result = await runNativeCase(kase)
@@ -397,6 +445,7 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
397
445
  }, null, 2) + "\n")
398
446
  }
399
447
 
448
+ await liteSession?.close()
400
449
  clearTimeout(killer)
401
450
  process.exit(failed > 0 ? 1 : 0)
402
451
  }
@@ -6,6 +6,7 @@ import pkg from "../../../package.json"
6
6
  import { CliError, bool, c, defineCommand, log, note, readFlags, type Command } from "../../cli"
7
7
  import { OPTIONAL_PEERS, peerSource, peerVersion } from "../../hosts/peers"
8
8
  import { installPeers } from "../../hosts/peerInstall"
9
+ import { archiveSpec, archivedVersion } from "../../hosts/archives"
9
10
  import { updateDesktop } from "../../hosts/desktopRenderer"
10
11
  import { updateMatc } from "../../hosts/matcTool"
11
12
  import { refreshTypes } from "./types"
@@ -24,7 +25,8 @@ import app from "../app"
24
25
  * 6. cli the npm installs — every installed optional peer (lecodes-headless, lecodes-design,
25
26
  * lecodes-3d-editor, lecodes-assets, @napi-rs/canvas) and the CLI itself that is behind:
26
27
  * peers in ~/.lecodes/peers (first-use installs, peerInstall.ts) are re-installed there
27
- * — the renderer at THIS CLI's version, the rest at npm's latest in range; global ones
28
+ * — the renderer at THIS CLI's version, an editor at the newest one out that this
29
+ * CLI's peer range takes, the rest at npm's latest in range; global ones
28
30
  * (`npm install -g`, the old way) via `npm install -g …@latest`; the CLI itself last
29
31
  * (the standalone binary can't come from npm — it says so and does the peers only)
30
32
  *
@@ -173,9 +175,10 @@ export default defineCommand({
173
175
  for (const [name, probe] of OPTIONAL_PEERS) {
174
176
  const where = peerSource(name, probe)
175
177
  const installed = peerVersion(name, probe)
178
+ // an archived peer (the headless host, the editors) has no "latest" of a registry: the
179
+ // version this CLI installs is the one to have
176
180
  const latest = !installed ? null
177
- : where === "prefix" && name === "lecodes-headless" ? pkg.version
178
- : await npmLatest(name)
181
+ : await archivedVersion(name) ?? await npmLatest(name)
179
182
  rows.push({ name, installed, latest, where })
180
183
  }
181
184
  rows.push({ name: "lecodes-cli", installed: pkg.version, latest: await npmLatest("lecodes-cli"), where: "global" })
@@ -190,7 +193,7 @@ export default defineCommand({
190
193
  }
191
194
  if (behind.length === 0) return "ok"
192
195
  const prefixBehind = behind.filter((r) => r.where === "prefix")
193
- const globalSpecs = behind.filter((r) => r.where !== "prefix").map((r) => r.name + "@latest")
196
+ const globalSpecs = await Promise.all(behind.filter((r) => r.where !== "prefix").map(async (r) => await archiveSpec(r.name) ?? r.name + "@latest"))
194
197
  if (flags.check) {
195
198
  return ["updated", behind.length + " behind — run: lecodes update" + (globalSpecs.length ? " (global: npm install -g " + globalSpecs.join(" ") + ")" : "")]
196
199
  }
@@ -207,7 +210,7 @@ export default defineCommand({
207
210
  log(" " + c.dim("$") + " npm install -g " + toInstall.join(" "))
208
211
  const r = spawnSync("npm", ["install", "-g", ...toInstall], { stdio: "inherit", shell: true })
209
212
  if (r.status !== 0) throw new CliError("npm install -g exited with " + (r.status ?? "a signal"))
210
- done.push(...toInstall.map((s) => s.replace("@latest", "")))
213
+ done.push(...behind.filter((r) => r.where !== "prefix" && (!STANDALONE || r.name !== "lecodes-cli")).map((r) => r.name))
211
214
  }
212
215
  if (done.length === 0) return ["skipped", "only the standalone CLI itself is behind — download the release"]
213
216
  const cliToo = done.includes("lecodes-cli")
@@ -0,0 +1,171 @@
1
+ import { basename, relative } from "node:path"
2
+ import { findProjectRoot, readManifest, type Manifest } from "../project/manifest"
3
+ import { startDevServer } from "../dev/devServer"
4
+ import { resolveLiteRunner } from "../dev/webRunner"
5
+ import { watchProject } from "../dev/watchProject"
6
+ import { openBrowser } from "../platform/browserAuth"
7
+ import { CliError, bool, c, defineCommand, log, note, num, str, warnErr } from "../cli"
8
+ import { buildWeb } from "../web/build"
9
+ import { startPreview } from "../web/preview"
10
+ import { bundleFlags } from "./shared"
11
+ import { lanCandidates, loadDevToken } from "./dev"
12
+
13
+ /*
14
+ * `lecodes web` — the project as a WEB PAGE (Web Lite: DOM and CSS, the browser's layout, text and
15
+ * scroll — docs/plans/web-lite-plan.md). Not the web player of `lecodes dev --web`, which runs
16
+ * the app on the runtime as wasm and paints it to a canvas: that one is how the app looks on a
17
+ * phone, this one is what a site ships.
18
+ *
19
+ * dev the page, served from this machine and reloaded on every save
20
+ * build a folder any static hosting serves (web/build.ts)
21
+ * preview serve a built folder, as a hosting would
22
+ *
23
+ * Fully local: no login, no le.codes backend.
24
+ */
25
+
26
+ const DEFAULT_PORT = 4488
27
+ const DEFAULT_OUT = "dist/web"
28
+
29
+ const project = (): { root: string, manifest: Manifest | null, name: string } => {
30
+ let root: string
31
+ let manifest: Manifest | null
32
+ try {
33
+ root = findProjectRoot(process.cwd())
34
+ manifest = readManifest(root)
35
+ } catch {
36
+ root = process.cwd()
37
+ manifest = null
38
+ }
39
+ return { root, manifest, name: manifest?.name ?? basename(root) }
40
+ }
41
+
42
+ const litePage = (): string => {
43
+ const lite = resolveLiteRunner(note)
44
+ if (!lite) throw new CliError("No Web Lite page found: this CLI has no runtime/web-lite (a monorepo checkout needs `bun run build` in hosts/web-lite).")
45
+ return lite
46
+ }
47
+
48
+ const kb = (bytes: number): string => bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(2)} MB`
49
+
50
+ const dev = defineCommand({
51
+ name: "dev",
52
+ summary: "Open the project as a web page, reloaded on every save",
53
+ description: "Serves the project's bundle and assets with the page of Web Lite around them; a save recompiles and runs the app again, the page's console output and errors stream back here. The address's hash is the route the app is opened with (#/product/42 → app.launchUrl).",
54
+ flags: {
55
+ port: num("server port", { default: DEFAULT_PORT }),
56
+ entry: bundleFlags.entry,
57
+ "no-open": bool("don't open the browser"),
58
+ },
59
+ examples: ["lecodes web dev", "lecodes web dev --port 8080 --no-open"],
60
+ run: async ({ flags }) => {
61
+ const { root, manifest, name } = project()
62
+ const lite = litePage()
63
+
64
+ const port = flags.port || DEFAULT_PORT
65
+ // The page is opened on this machine; a phone on the same network reaches it by the LAN address.
66
+ const lan = lanCandidates()[0]?.address
67
+ const origin = `http://localhost:${port}`
68
+ const server = await startDevServer({
69
+ root, manifest, name, origin, port,
70
+ token: loadDevToken(root),
71
+ entryOverride: flags.entry,
72
+ lite,
73
+ log: note,
74
+ }).catch((e: unknown) => {
75
+ const msg = e instanceof Error ? e.message : String(e)
76
+ throw new CliError(msg.includes("EADDRINUSE") ? `Port ${port} is already in use — pass --port <n>.` : msg)
77
+ })
78
+
79
+ note("Compiling…")
80
+ const first = await server.recompile()
81
+ if (first.ok) for (const w of first.warnings) warnErr(w)
82
+ else warnErr(`Compile failed — fix it and save; the page loads once it compiles:\n${first.error}`)
83
+
84
+ const watcher = watchProject(root, async () => {
85
+ const result = await server.recompile()
86
+ if (!result.ok) warnErr(`Compile failed (the page keeps the last good version):\n${result.error}`)
87
+ else if (result.changed) {
88
+ note(`✓ v${result.v}`)
89
+ for (const w of result.warnings) warnErr(w)
90
+ }
91
+ }, { warn: warnErr, debug: process.env.LECODES_DEV_DEBUG === "1" ? note : undefined })
92
+
93
+ const url = server.liteUrl!
94
+ log("")
95
+ log(`${c.bold("LeCodes Web")} — ${name}`)
96
+ log("")
97
+ log(`In the browser: ${c.bold(url)}`)
98
+ if (lan) note(`On this network: ${url.replace("http://localhost:", `http://${lan}:`)}`)
99
+ log("")
100
+ log(`Watching ${root} — save a file to reload. Ctrl+C to stop.`)
101
+
102
+ if (!flags["no-open"]) openBrowser(url)
103
+
104
+ process.on("SIGINT", () => {
105
+ watcher.close()
106
+ server.close()
107
+ process.exit(0)
108
+ })
109
+ },
110
+ })
111
+
112
+ const build = defineCommand({
113
+ name: "build",
114
+ summary: "Build the project into a folder any static hosting serves",
115
+ description: "Writes the page, the bundle, the page's own files and the assets the bundle names into one folder (dist/web). Every url in it is relative: the folder works at a domain's root and inside a folder of one.\n\napp.json `web` sets the page: `title`, `themeColor`, `lang`, and `urls` — \"hash\" (default: a route is `#/product/42`, which any hosting serves) or \"path\" (`/product/42` under `base`: the hosting has to answer every path with the page; a 404.html is written for the ones that do it that way).",
116
+ flags: {
117
+ out: str("the folder to write (cleaned first)", { alias: "o", value: "<dir>", default: DEFAULT_OUT }),
118
+ entry: bundleFlags.entry,
119
+ sourcemap: bool("inline the source map (errors name the app's own lines; the bundle grows by its sources)"),
120
+ "no-minify": bundleFlags["no-minify"],
121
+ },
122
+ examples: ["lecodes web build", "lecodes web build -o public"],
123
+ run: async ({ flags }) => {
124
+ const { root, manifest, name } = project()
125
+ const lite = litePage()
126
+ note("Building…")
127
+ const result = await buildWeb({
128
+ root, manifest, name, lite, out: flags.out, entry: flags.entry,
129
+ sourcemap: flags.sourcemap, minify: flags["no-minify"] ? false : undefined,
130
+ })
131
+ for (const w of result.warnings) warnErr(w)
132
+ const total = result.files.reduce((sum, f) => sum + f.bytes, 0)
133
+ const shown = relative(process.cwd(), result.out) || "."
134
+ log("")
135
+ for (const f of [...result.files].sort((a, b) => b.bytes - a.bytes).slice(0, 8)) log(` ${kb(f.bytes).padStart(10)} ${f.path}`)
136
+ if (result.files.length > 8) log(c.dim(` …and ${result.files.length - 8} more`))
137
+ log("")
138
+ log(`${c.green("✓")} ${c.bold(shown)} — ${result.files.length} files, ${kb(total)}`)
139
+ note(`Look at it: lecodes web preview${flags.out === DEFAULT_OUT ? "" : ` ${flags.out}`}`)
140
+ },
141
+ })
142
+
143
+ const preview = defineCommand({
144
+ name: "preview",
145
+ summary: "Serve a built folder, as a static hosting would",
146
+ usage: "[dir]",
147
+ flags: {
148
+ port: num("server port", { default: DEFAULT_PORT + 1 }),
149
+ "no-open": bool("don't open the browser"),
150
+ },
151
+ examples: ["lecodes web preview", "lecodes web preview public --port 8080"],
152
+ run: async ({ args, flags }) => {
153
+ const dir = args[0] ?? DEFAULT_OUT
154
+ const server = await startPreview(dir, flags.port || DEFAULT_PORT + 1).catch((e: unknown) => {
155
+ const msg = e instanceof Error ? e.message : String(e)
156
+ throw new CliError(msg.includes("EADDRINUSE") ? `Port ${flags.port} is already in use — pass --port <n>.` : msg)
157
+ })
158
+ log(`${c.bold("LeCodes Web")} — ${dir}`)
159
+ log(`In the browser: ${c.bold(server.url)}`)
160
+ log("Ctrl+C to stop.")
161
+ if (!flags["no-open"]) openBrowser(server.url)
162
+ process.on("SIGINT", () => { server.close(); process.exit(0) })
163
+ },
164
+ })
165
+
166
+ export default defineCommand({
167
+ name: "web",
168
+ summary: "The project as a web page (Web Lite): serve it with reload on save, build it for a hosting",
169
+ description: "Web Lite shows the app with the browser's own layout, text and scroll: what a site ships. UI apps for now — a project's 3D and 2D scenes are not shown yet.",
170
+ commands: [dev, build, preview],
171
+ })
@@ -37,6 +37,9 @@ export type ProjectFontsOptions = {
37
37
  registry?: string
38
38
  /** Absolute-URL prefix of the compile (URL modes) — used for the header's entry-face URLs. */
39
39
  publicUrl?: string
40
+ /** The compile's urls are RELATIVE to the page that runs the bundle (a static web build): the
41
+ * header's urls are written as they are, with no origin before them. */
42
+ relative?: boolean
40
43
  }
41
44
 
42
45
  export const applyProjectFonts = async (entries: CompileEntry[], opts: ProjectFontsOptions): Promise<string | undefined> => {
@@ -68,9 +71,9 @@ export const applyProjectFonts = async (entries: CompileEntry[], opts: ProjectFo
68
71
  if (faces.length === 0) return undefined
69
72
 
70
73
  return buildFontsHeaderValue(faces, (path) => {
71
- if (!opts.publicUrl || !/^https?:/i.test(opts.publicUrl)) return null
74
+ if (!opts.relative && (!opts.publicUrl || !/^https?:/i.test(opts.publicUrl))) return null
72
75
  const fileSrc = byPath().get(path.replace(/^\//, ""))?.fileSrc
73
- return fileSrc ? opts.publicUrl + fileSrc : null
76
+ return fileSrc ? (opts.publicUrl ?? "") + fileSrc : null
74
77
  }) || undefined
75
78
  }
76
79
 
@@ -31,6 +31,8 @@ import type { WebRunner } from "./webRunner"
31
31
  * player/?dev=../.. — it loads bundle.js?t=web, the bundle WITHOUT
32
32
  * the native prelude; the player owns reload (ws / version
33
33
  * polling) and the log backchannel. A phone is sent here at once
34
+ * GET /t/<tok>/lite/[file] the page of Web Lite (lecodes-web-lite's dist), opened as
35
+ * lite/?dev=.. — the same bundle.js?t=web, the same reload and logs
34
36
  *
35
37
  * Compile is eager: the command calls recompile() on every file change; a successful compile bumps
36
38
  * `v` and broadcasts {type:"reload", v} to connected sockets (polling clients see /version move).
@@ -51,6 +53,8 @@ export type DevServerOptions = {
51
53
  /** What is served under /web/ (src/dev/webRunner.ts): the player, and the page around it; absent =
52
54
  * no browser route. */
53
55
  web?: WebRunner | null
56
+ /** The page of Web Lite (lecodes-web-lite's dist), served under /lite/; absent = no such route. */
57
+ lite?: string | null
54
58
  /** Diagnostics (compile errors, device logs); defaults to stderr-style silence-safe noop. */
55
59
  log?: (msg: string) => void
56
60
  }
@@ -63,6 +67,8 @@ export type DevServer = {
63
67
  url: string
64
68
  /** The browser runner page (`dev --web`), or null when no runner is vendored. */
65
69
  webUrl: string | null
70
+ /** The page of Web Lite (`lecodes web dev`), or null when none is served. */
71
+ liteUrl: string | null
66
72
  /** The token base path ("/t/<token>"). */
67
73
  base: string
68
74
  /** The actually-bound port (differs from options.port when 0 was passed, e.g. in tests). */
@@ -84,7 +90,7 @@ const MIME: Record<string, string> = {
84
90
  css: "text/css", html: "text/html", wasm: "application/wasm", map: "application/json",
85
91
  }
86
92
 
87
- const contentType = (path: string): string => {
93
+ export const contentType = (path: string): string => {
88
94
  const i = path.lastIndexOf(".")
89
95
  return (i >= 0 && MIME[path.slice(i + 1).toLowerCase()]) || "application/octet-stream"
90
96
  }
@@ -233,6 +239,7 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
233
239
  return transport === "web" ? js : js + "\n" + renderPrelude({ origin: reqOrigin, base, transport, v })
234
240
  }
235
241
  const web = options.web ?? null
242
+ const lite = options.lite ?? null
236
243
  /** A file of a built folder; the entry files revalidate — a CLI update swaps them under the same
237
244
  * url — while what is under assets/ carries its hash in its name. */
238
245
  const sendBuilt = (res: ServerResponse, dir: string, rel: string) => {
@@ -284,6 +291,17 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
284
291
  if (rel === "") return redirect(`${base}/web/${query()}`)
285
292
  return sendBuilt(res, web.page, rel === "/" ? "index.html" : rel.slice(1))
286
293
  }
294
+ if (route === "/lite" || route.startsWith("/lite/")) {
295
+ if (!lite) return sendText(res, 404, "text/plain", "No Web Lite page with this CLI (lecodes-web-lite's dist).")
296
+ const rel = route.slice("/lite".length)
297
+ if (rel === "" || (rel === "/" && !url.searchParams.has("dev"))) {
298
+ url.searchParams.set("dev", "..")
299
+ res.writeHead(302, { location: `${base}/lite/?${url.searchParams.toString()}${url.hash}` })
300
+ res.end()
301
+ return
302
+ }
303
+ return sendBuilt(res, lite, rel === "/" ? "index.html" : rel.slice(1))
304
+ }
287
305
  if (route === "/version") {
288
306
  return sendJson(res, 200, { v })
289
307
  }
@@ -363,6 +381,7 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
363
381
  return {
364
382
  url: `${origin}${base}/dev.js`,
365
383
  webUrl: web ? `${origin}${base}/web/` : null,
384
+ liteUrl: lite ? `${origin}${base}/lite/` : null,
366
385
  base,
367
386
  port: boundPort,
368
387
  v: () => v,
@@ -0,0 +1,53 @@
1
+ import { watch, type FSWatcher } from "node:fs"
2
+ import { loadIgnore } from "../project/ignore"
3
+ import { toKey } from "../project/paths"
4
+
5
+ /*
6
+ * The watcher of a dev command: a change of the project's content calls `onChange`, debounced — a
7
+ * save of several files is one recompile. What never counts: the CLI's own folders, the editors'
8
+ * files, what the project ignores.
9
+ */
10
+
11
+ const DEBOUNCE_MS = 120
12
+
13
+ // Never trigger a recompile (mirrors scanLocal's skip lists + our own artifacts).
14
+ const SKIP_DIRS = new Set([".lecodes", ".git", "node_modules", "dist"])
15
+ const SKIP_FILES = new Set(["tsconfig.json", "jsconfig.json", ".gitignore", ".lecodesignore", ".DS_Store"])
16
+
17
+ export type ProjectWatcher = { close(): void }
18
+
19
+ export const watchProject = (root: string, onChange: () => void, log: { warn(message: string): void, debug?(message: string): void }): ProjectWatcher => {
20
+ const ignore = loadIgnore(root)
21
+ const counts = (file: string | null): boolean => {
22
+ if (file === null) return true // an event without a name: assume the project's content
23
+ const parts = file.replace(/\\/g, "/").split("/")
24
+ if (parts.some((p) => SKIP_DIRS.has(p))) return false
25
+ if (SKIP_FILES.has(parts[parts.length - 1]!)) return false
26
+ try {
27
+ if (ignore(toKey(file), false)) return false
28
+ } catch { return false }
29
+ return true
30
+ }
31
+
32
+ let timer: NodeJS.Timeout | null = null
33
+ let watcher: FSWatcher | null = null
34
+ try {
35
+ watcher = watch(root, { recursive: true }, (_type, file) => {
36
+ const name = file === null ? null : String(file)
37
+ log.debug?.(`[watch] event: ${name ?? "(null)"} → ${counts(name) ? "recompile" : "skip"}`)
38
+ if (!counts(name)) return
39
+ if (timer) clearTimeout(timer)
40
+ timer = setTimeout(() => { timer = null; onChange() }, DEBOUNCE_MS)
41
+ })
42
+ } catch {
43
+ log.warn("fs.watch(recursive) unavailable on this platform — hot reload disabled (restart to pick up changes).")
44
+ }
45
+ watcher?.on("error", (e) => log.warn(`Watcher error — hot reload may have stopped (restart the command): ${e.message}`))
46
+
47
+ return {
48
+ close() {
49
+ if (timer) clearTimeout(timer)
50
+ watcher?.close()
51
+ },
52
+ }
53
+ }