lecodes-cli 2.0.1 → 2.0.3

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.
@@ -0,0 +1,171 @@
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
+ * the tween engine
19
+ * assets/<path> the project's assets — only the ones the bundle names
20
+ * 404.html in `path` mode: the page again, for the hostings that answer an unknown
21
+ * path with it
22
+ *
23
+ * Every url in the folder is RELATIVE: it works at the root of a domain and in a folder of one.
24
+ * In `path` mode (app.json `web.urls: "path"`) the routes are paths under `web.base`, so the page
25
+ * names that base (`<base href>`) and the hosting has to answer every path under it with the page.
26
+ */
27
+
28
+ export type WebConfig = {
29
+ /** How a route is written in the address: `#/product/42` (default) or `/product/42`. */
30
+ urls?: "hash" | "path"
31
+ /** `path` mode: the folder the site is served from ("/" by default). */
32
+ base?: string
33
+ /** The page's title (default: the app's name). */
34
+ title?: string
35
+ /** The browser's chrome around the page (a phone's address bar); default black. */
36
+ themeColor?: string
37
+ /** The page's language (`<html lang>`). */
38
+ lang?: string
39
+ }
40
+
41
+ export type WebBuildOptions = {
42
+ root: string
43
+ manifest: Manifest | null
44
+ name: string
45
+ /** The built page of Web Lite (lecodes-web-lite's dist). */
46
+ lite: string
47
+ out: string
48
+ entry?: string
49
+ /** Inline the source map into the bundle (errors name the app's lines; the bundle grows). */
50
+ sourcemap?: boolean
51
+ minify?: boolean
52
+ }
53
+
54
+ export type WebBuildResult = {
55
+ out: string
56
+ bundle: string
57
+ files: { path: string, bytes: number }[]
58
+ warnings: string[]
59
+ }
60
+
61
+ const escapeHtml = (text: string): string => text.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]!))
62
+
63
+ const normalBase = (base: string | undefined): string => {
64
+ const b = (base ?? "/").trim() || "/"
65
+ return (b.startsWith("/") ? b : `/${b}`).replace(/\/*$/, "/")
66
+ }
67
+
68
+ const walk = (dir: string, prefix = ""): { path: string, bytes: number }[] => {
69
+ const out: { path: string, bytes: number }[] = []
70
+ for (const name of readdirSync(dir)) {
71
+ const abs = join(dir, name)
72
+ const stat = statSync(abs)
73
+ if (stat.isDirectory()) out.push(...walk(abs, `${prefix}${name}/`))
74
+ else out.push({ path: prefix + name, bytes: stat.size })
75
+ }
76
+ return out
77
+ }
78
+
79
+ export const buildWeb = async (options: WebBuildOptions): Promise<WebBuildResult> => {
80
+ const { root, manifest, name, lite } = options
81
+ const out = resolve(root, options.out)
82
+ if (out === resolve(root) || resolve(root).startsWith(out + "\\") || resolve(root).startsWith(out + "/")) {
83
+ throw new CliError(`The output folder is cleaned before a build: "${options.out}" holds the project itself.`)
84
+ }
85
+ const config: WebConfig = (readAppConfigAt(root) as { web?: WebConfig } | null)?.web ?? {}
86
+ const mode = config.urls === "path" ? "path" : "hash"
87
+ const base = normalBase(config.base)
88
+ const warnings: string[] = []
89
+
90
+ // ---- the bundle: every asset a url relative to the page ------------------------------------------------
91
+ const assetUrl = (path: string) => "assets" + encodeURI(path)
92
+ const collected = collectLocalEntries(root, manifest, "assets")
93
+ const entries = collected.entries.filter((e) => {
94
+ // what a build made is no part of the project
95
+ const abs = e.absPath ? resolve(e.absPath) : null
96
+ return !(abs && (abs === out || abs.startsWith(out + "\\") || abs.startsWith(out + "/")))
97
+ })
98
+ for (const w of collected.warnings) if (!/shaders compile on the server/.test(w)) warnings.push(w)
99
+ const fontsHeader = await applyProjectFonts(entries, {
100
+ mode: "vendor", root, vendorDir: root, vendorPrefix: "", publicUrl: "", relative: true,
101
+ makeResourceEntry: (path, absPath) => ({ path, type: "resource", fileSrc: assetUrl(path), absPath }),
102
+ })
103
+ const iconsHeader = await applyAppAssetIcons(entries)
104
+ if (!options.entry) {
105
+ const ambiguous = ambiguousEntryWarning(entries)
106
+ if (ambiguous) warnings.push(ambiguous)
107
+ }
108
+ const compiled = await compileProject({
109
+ entries, name, publicUrl: "", entryOverride: options.entry, header: true, fontsHeader, iconsHeader,
110
+ sourcemap: options.sourcemap === true, minify: options.minify,
111
+ onWarning: (w) => warnings.push(w),
112
+ })
113
+ // The same project builds into the same bytes: the bundle's name is its hash, and a hosting
114
+ // keeps a file of that name for good. The header's time stamp is the one line that would
115
+ // differ (the line stays, for the source map's count of lines).
116
+ const js = compiled.replace(/^\/\/ last-updated: .*$/m, "// last-updated: -")
117
+ const header = js.slice(0, 2000)
118
+ if (!/^\/\/ gl: disable/m.test(header) || /^\/\/ 2d: enable/m.test(header)) {
119
+ 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.)")
120
+ }
121
+
122
+ // ---- the folder ----------------------------------------------------------------------------------------------
123
+ rmSync(out, { recursive: true, force: true })
124
+ mkdirSync(out, { recursive: true })
125
+ cpSync(lite, out, { recursive: true })
126
+
127
+ const bundle = `app.${createHash("sha256").update(js).digest("hex").slice(0, 10)}.js`
128
+ writeFileSync(join(out, bundle), js)
129
+
130
+ // the project's assets: the ones the bundle names
131
+ for (const entry of entries) {
132
+ if (entry.type !== "resource" || !entry.absPath || !entry.fileSrc) continue
133
+ if (!js.includes(entry.fileSrc)) continue
134
+ const to = join(out, "assets", ...entry.path.split("/").filter(Boolean))
135
+ 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 }
136
+ mkdirSync(dirname(to), { recursive: true })
137
+ copyFileSync(entry.absPath, to)
138
+ }
139
+
140
+ // ---- the page ----------------------------------------------------------------------------------------------
141
+ const app = readAppConfigAt(root)
142
+ let html = readFileSync(join(lite, "index.html"), "utf8")
143
+ const head: string[] = []
144
+ head.push(`<meta name="theme-color" content="${escapeHtml(config.themeColor ?? "#000000")}" />`)
145
+ if (app?.icon) {
146
+ const icon = resolve(root, app.icon)
147
+ if (existsSync(icon)) {
148
+ const file = `icon${extname(icon).toLowerCase() || ".png"}`
149
+ copyFileSync(icon, join(out, file))
150
+ head.push(`<link rel="icon" href="${file}" />`, `<link rel="apple-touch-icon" href="${file}" />`)
151
+ } else {
152
+ warnings.push(`app.json icon "${app.icon}" is not there — the page has no icon`)
153
+ }
154
+ }
155
+ // in path mode a route is a path: what the page names relatively is under the base, whatever the route
156
+ if (mode === "path") head.unshift(`<base href="${escapeHtml(base)}" />`)
157
+ const attrs = [`data-src="${bundle}"`, ...(mode === "path" ? [`data-urls="path"`, `data-base="${escapeHtml(base)}"`] : [])].join(" ")
158
+ const replace = (from: string | RegExp, to: string) => {
159
+ const next = html.replace(from, to)
160
+ 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.`)
161
+ html = next
162
+ }
163
+ replace(/<title>[^<]*<\/title>/, `<title>${escapeHtml(config.title ?? app?.name ?? name)}</title>`)
164
+ replace(`<div id="app"></div>`, `<div id="app" ${attrs}></div>`)
165
+ replace("</head>", ` ${head.join("\n ")}\n </head>`)
166
+ if (config.lang) replace(/<html lang="[^"]*">/, `<html lang="${escapeHtml(config.lang)}">`)
167
+ writeFileSync(join(out, "index.html"), html)
168
+ if (mode === "path") writeFileSync(join(out, "404.html"), html)
169
+
170
+ return { out, bundle, files: walk(out), warnings }
171
+ }
@@ -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
+ }