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
@@ -0,0 +1,194 @@
1
+ import { createHash } from "node:crypto"
2
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"
3
+ import { dirname, extname, join, resolve } from "node:path"
4
+ import { collectLocalEntries } from "../compile/collectLocal"
5
+ import { applyProjectFonts } from "../compile/fonts"
6
+ import { applyAppAssetIcons } from "../compile/assetIcons"
7
+ import { ambiguousEntryWarning, compileProject } from "../compile/projectCompile"
8
+ import { readAppConfigAt } from "../commands/app/shared"
9
+ import type { Manifest } from "../project/manifest"
10
+ import { CliError } from "../cli"
11
+
12
+ /*
13
+ * `lecodes web build`: the project as a FOLDER a static hosting serves.
14
+ *
15
+ * index.html the page (name, icon, theme color of app.json)
16
+ * app.<hash>.js the bundle — the same code every platform runs
17
+ * lite.js, assets/… the page of Web Lite (lecodes-web-lite's dist): its script, its font
18
+ * in parts by script (a browser fetches the ones a page shows; none
19
+ * with app.json `web.font: "system"`)
20
+ * and the FEATURES this app needs (the pager, the virtualized list, the
21
+ * tweens, the canvas — a chunk each; the page's features.json says what
22
+ * a bundle has to name for one to be needed, and whose files they are)
23
+ * assets/<path> the project's assets — only the ones the bundle names
24
+ * 404.html in `path` mode: the page again, for the hostings that answer an unknown
25
+ * path with it
26
+ *
27
+ * Every url in the folder is RELATIVE: it works at the root of a domain and in a folder of one.
28
+ * In `path` mode (app.json `web.urls: "path"`) the routes are paths under `web.base`, so the page
29
+ * names that base (`<base href>`) and the hosting has to answer every path under it with the page.
30
+ */
31
+
32
+ export type WebConfig = {
33
+ /** How a route is written in the address: `#/product/42` (default) or `/product/42`. */
34
+ urls?: "hash" | "path"
35
+ /** `path` mode: the folder the site is served from ("/" by default). */
36
+ base?: string
37
+ /** The page's title (default: the app's name). */
38
+ title?: string
39
+ /** The browser's chrome around the page (a phone's address bar); default black. */
40
+ themeColor?: string
41
+ /** The page's language (`<html lang>`). */
42
+ lang?: string
43
+ /** The font of a text that names none: `roboto` (default; the browser fetches the parts the
44
+ * page shows) or `system` — the device's own font, nothing fetched, the width of a text the
45
+ * device's. */
46
+ font?: "roboto" | "system"
47
+ }
48
+
49
+ export type WebBuildOptions = {
50
+ root: string
51
+ manifest: Manifest | null
52
+ name: string
53
+ /** The built page of Web Lite (lecodes-web-lite's dist). */
54
+ lite: string
55
+ out: string
56
+ entry?: string
57
+ /** Inline the source map into the bundle (errors name the app's lines; the bundle grows). */
58
+ sourcemap?: boolean
59
+ minify?: boolean
60
+ }
61
+
62
+ export type WebBuildResult = {
63
+ out: string
64
+ bundle: string
65
+ files: { path: string, bytes: number }[]
66
+ warnings: string[]
67
+ }
68
+
69
+ const escapeHtml = (text: string): string => text.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]!))
70
+
71
+ const normalBase = (base: string | undefined): string => {
72
+ const b = (base ?? "/").trim() || "/"
73
+ return (b.startsWith("/") ? b : `/${b}`).replace(/\/*$/, "/")
74
+ }
75
+
76
+ const walk = (dir: string, prefix = ""): { path: string, bytes: number }[] => {
77
+ const out: { path: string, bytes: number }[] = []
78
+ for (const name of readdirSync(dir)) {
79
+ const abs = join(dir, name)
80
+ const stat = statSync(abs)
81
+ if (stat.isDirectory()) out.push(...walk(abs, `${prefix}${name}/`))
82
+ else out.push({ path: prefix + name, bytes: stat.size })
83
+ }
84
+ return out
85
+ }
86
+
87
+ export const buildWeb = async (options: WebBuildOptions): Promise<WebBuildResult> => {
88
+ const { root, manifest, name, lite } = options
89
+ const out = resolve(root, options.out)
90
+ if (out === resolve(root) || resolve(root).startsWith(out + "\\") || resolve(root).startsWith(out + "/")) {
91
+ throw new CliError(`The output folder is cleaned before a build: "${options.out}" holds the project itself.`)
92
+ }
93
+ const config: WebConfig = (readAppConfigAt(root) as { web?: WebConfig } | null)?.web ?? {}
94
+ const mode = config.urls === "path" ? "path" : "hash"
95
+ const base = normalBase(config.base)
96
+ const warnings: string[] = []
97
+
98
+ // ---- the bundle: every asset a url relative to the page ------------------------------------------------
99
+ const assetUrl = (path: string) => "assets" + encodeURI(path)
100
+ const collected = collectLocalEntries(root, manifest, "assets")
101
+ const entries = collected.entries.filter((e) => {
102
+ // what a build made is no part of the project
103
+ const abs = e.absPath ? resolve(e.absPath) : null
104
+ return !(abs && (abs === out || abs.startsWith(out + "\\") || abs.startsWith(out + "/")))
105
+ })
106
+ for (const w of collected.warnings) if (!/shaders compile on the server/.test(w)) warnings.push(w)
107
+ const fontsHeader = await applyProjectFonts(entries, {
108
+ mode: "vendor", root, vendorDir: root, vendorPrefix: "", publicUrl: "", relative: true,
109
+ makeResourceEntry: (path, absPath) => ({ path, type: "resource", fileSrc: assetUrl(path), absPath }),
110
+ })
111
+ const iconsHeader = await applyAppAssetIcons(entries)
112
+ if (!options.entry) {
113
+ const ambiguous = ambiguousEntryWarning(entries)
114
+ if (ambiguous) warnings.push(ambiguous)
115
+ }
116
+ const compiled = await compileProject({
117
+ entries, name, publicUrl: "", entryOverride: options.entry, header: true, fontsHeader, iconsHeader,
118
+ sourcemap: options.sourcemap === true, minify: options.minify,
119
+ onWarning: (w) => warnings.push(w),
120
+ })
121
+ // The same project builds into the same bytes: the bundle's name is its hash, and a hosting
122
+ // keeps a file of that name for good. The header's time stamp is the one line that would
123
+ // differ (the line stays, for the source map's count of lines).
124
+ const js = compiled.replace(/^\/\/ last-updated: .*$/m, "// last-updated: -")
125
+ const header = js.slice(0, 2000)
126
+ if (!/^\/\/ gl: disable/m.test(header) || /^\/\/ 2d: enable/m.test(header)) {
127
+ warnings.push("This project uses 3D or 2D scenes, which Web Lite does not show yet: the page runs its UI, a scene's calls fail. (`lecodes dev --web` runs the whole app on the web player.)")
128
+ }
129
+
130
+ // ---- the folder ----------------------------------------------------------------------------------------------
131
+ rmSync(out, { recursive: true, force: true })
132
+ mkdirSync(out, { recursive: true })
133
+ cpSync(lite, out, { recursive: true })
134
+ // the features this bundle never names are not part of its page
135
+ const table = join(out, "features.json")
136
+ if (existsSync(table)) {
137
+ const { features, fonts } = JSON.parse(readFileSync(table, "utf8")) as { features: { name: string, uses: string[], files: string[] }[], fonts?: string[] }
138
+ if (config.font === "system") for (const file of fonts ?? []) rmSync(join(out, file), { force: true })
139
+ for (const feature of features) {
140
+ if (feature.uses.some((name) => js.includes(name))) continue
141
+ for (const file of feature.files) rmSync(join(out, file), { force: true })
142
+ }
143
+ rmSync(table)
144
+ }
145
+
146
+ const bundle = `app.${createHash("sha256").update(js).digest("hex").slice(0, 10)}.js`
147
+ writeFileSync(join(out, bundle), js)
148
+
149
+ // the project's assets: the ones the bundle names
150
+ for (const entry of entries) {
151
+ if (entry.type !== "resource" || !entry.absPath || !entry.fileSrc) continue
152
+ if (!js.includes(entry.fileSrc)) continue
153
+ const to = join(out, "assets", ...entry.path.split("/").filter(Boolean))
154
+ if (existsSync(to)) { warnings.push(`${entry.path}: a file of the page has this name in the built folder — the project's file was not copied`); continue }
155
+ mkdirSync(dirname(to), { recursive: true })
156
+ copyFileSync(entry.absPath, to)
157
+ }
158
+
159
+ // ---- the page ----------------------------------------------------------------------------------------------
160
+ const app = readAppConfigAt(root)
161
+ let html = readFileSync(join(lite, "index.html"), "utf8")
162
+ const head: string[] = []
163
+ head.push(`<meta name="theme-color" content="${escapeHtml(config.themeColor ?? "#000000")}" />`)
164
+ if (app?.icon) {
165
+ const icon = resolve(root, app.icon)
166
+ if (existsSync(icon)) {
167
+ const file = `icon${extname(icon).toLowerCase() || ".png"}`
168
+ copyFileSync(icon, join(out, file))
169
+ head.push(`<link rel="icon" href="${file}" />`, `<link rel="apple-touch-icon" href="${file}" />`)
170
+ } else {
171
+ warnings.push(`app.json icon "${app.icon}" is not there — the page has no icon`)
172
+ }
173
+ }
174
+ // in path mode a route is a path: what the page names relatively is under the base, whatever the route
175
+ if (mode === "path") head.unshift(`<base href="${escapeHtml(base)}" />`)
176
+ const attrs = [
177
+ `data-src="${bundle}"`,
178
+ ...(mode === "path" ? [`data-urls="path"`, `data-base="${escapeHtml(base)}"`] : []),
179
+ ...(config.font === "system" ? [`data-font="system"`] : []),
180
+ ].join(" ")
181
+ const replace = (from: string | RegExp, to: string) => {
182
+ const next = html.replace(from, to)
183
+ if (next === html) throw new CliError(`The page of Web Lite has no ${String(from)} to fill — this CLI and its page are of different versions.`)
184
+ html = next
185
+ }
186
+ replace(/<title>[^<]*<\/title>/, `<title>${escapeHtml(config.title ?? app?.name ?? name)}</title>`)
187
+ replace(`<div id="app"></div>`, `<div id="app" ${attrs}></div>`)
188
+ replace("</head>", ` ${head.join("\n ")}\n </head>`)
189
+ if (config.lang) replace(/<html lang="[^"]*">/, `<html lang="${escapeHtml(config.lang)}">`)
190
+ writeFileSync(join(out, "index.html"), html)
191
+ if (mode === "path") writeFileSync(join(out, "404.html"), html)
192
+
193
+ return { out, bundle, files: walk(out), warnings }
194
+ }
@@ -0,0 +1,50 @@
1
+ import { createReadStream, existsSync, statSync } from "node:fs"
2
+ import { createServer, type Server } from "node:http"
3
+ import { join, resolve } from "node:path"
4
+ import { contentType } from "../dev/devServer"
5
+
6
+ /*
7
+ * `lecodes web preview`: a built folder, served as a static hosting serves it — files by their
8
+ * path, `index.html` for a folder. A folder built in `path` mode (it has a 404.html) gets what
9
+ * such a hosting is set up to give: the page for every path that is no file.
10
+ */
11
+
12
+ export type PreviewServer = { url: string, port: number, close(): void }
13
+
14
+ export const startPreview = async (dir: string, port: number): Promise<PreviewServer> => {
15
+ const root = resolve(dir)
16
+ const fallback = existsSync(join(root, "404.html")) ? join(root, "index.html") : null
17
+
18
+ const server: Server = createServer((req, res) => {
19
+ let path: string
20
+ try {
21
+ path = decodeURIComponent(new URL(req.url ?? "/", "http://localhost").pathname)
22
+ } catch {
23
+ res.writeHead(400); res.end(); return
24
+ }
25
+ const parts = path.split("/").filter(Boolean)
26
+ if (parts.some((p) => p === ".." || p === ".")) { res.writeHead(400); res.end(); return }
27
+ let file = join(root, ...parts)
28
+ if (existsSync(file) && statSync(file).isDirectory()) file = join(file, "index.html")
29
+ let status = 200
30
+ if (!existsSync(file) || !statSync(file).isFile()) {
31
+ // a file that is missing is missing; a route is the page's
32
+ if (!fallback || /\.[a-z0-9]+$/i.test(parts[parts.length - 1] ?? "")) { res.writeHead(404, { "content-type": "text/plain" }); res.end("Not found"); return }
33
+ file = fallback
34
+ }
35
+ res.writeHead(status, {
36
+ "content-type": contentType(file),
37
+ // what carries its hash in its name never changes; the page and the entry are asked for again
38
+ "cache-control": /[.-][0-9a-f]{8,}\.[a-z0-9]+$/i.test(file) ? "max-age=31536000, immutable" : "no-cache",
39
+ })
40
+ createReadStream(file).pipe(res)
41
+ })
42
+
43
+ await new Promise<void>((done, reject) => {
44
+ server.once("error", reject)
45
+ server.listen(port, "0.0.0.0", () => { server.removeListener("error", reject); done() })
46
+ })
47
+ const address = server.address()
48
+ const bound = typeof address === "object" && address !== null ? address.port : port
49
+ return { url: `http://localhost:${bound}/`, port: bound, close: () => server.close() }
50
+ }