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.
- package/dist/index.js +1013 -127
- package/package.json +13 -12
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/assets/{inspect-C7x78PNs.js → inspect-DdJ50Kun.js} +2 -2
- package/runtime/web/player.js +18 -18
- package/runtime/web-lite/assets/canvas-C6G_Pb2Q.js +1 -0
- package/runtime/web-lite/assets/pager-O712dZv9.js +1 -0
- package/runtime/web-lite/assets/roboto-cyrillic-DVBNKCCH.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-cyrillic-ext-1gPz9dK9.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-greek-DVLYEgdx.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-greek-ext-D98D44EL.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-latin-cAJwcdBQ.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-latin-ext-t610Sqxq.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-rest-C7JkyNHT.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-vietnamese-w-p-nKZZ.woff2 +0 -0
- package/runtime/web-lite/assets/tween-t_XDy3Nd.js +1 -0
- package/runtime/web-lite/assets/vlist-gEB8Sn0S.js +1 -0
- package/runtime/web-lite/features.json +51 -0
- package/runtime/web-lite/index.html +23 -0
- package/runtime/web-lite/lite.js +249 -0
- package/src/commands/design/comments.ts +1 -1
- package/src/commands/design/context.ts +1 -1
- package/src/commands/design/index.ts +1 -1
- package/src/commands/dev.ts +17 -53
- package/src/commands/index.ts +2 -0
- package/src/commands/test.ts +55 -6
- package/src/commands/update/index.ts +8 -5
- package/src/commands/web.ts +171 -0
- package/src/compile/fonts.ts +5 -2
- package/src/dev/devServer.ts +20 -1
- package/src/dev/watchProject.ts +53 -0
- package/src/dev/webRunner.ts +10 -0
- package/src/hosts/archives.ts +66 -0
- package/src/hosts/liteBrowser.mjs +86 -0
- package/src/hosts/liteRunner.ts +386 -0
- package/src/hosts/peerInstall.ts +12 -8
- package/src/web/build.ts +194 -0
- package/src/web/preview.ts +50 -0
package/src/web/build.ts
ADDED
|
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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
|
+
}
|