lecodes-cli 2.0.3 → 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.
@@ -25,7 +25,8 @@ import app from "../app"
25
25
  * 6. cli the npm installs — every installed optional peer (lecodes-headless, lecodes-design,
26
26
  * lecodes-3d-editor, lecodes-assets, @napi-rs/canvas) and the CLI itself that is behind:
27
27
  * peers in ~/.lecodes/peers (first-use installs, peerInstall.ts) are re-installed there
28
- * — 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
29
30
  * (`npm install -g`, the old way) via `npm install -g …@latest`; the CLI itself last
30
31
  * (the standalone binary can't come from npm — it says so and does the peers only)
31
32
  *
@@ -175,9 +176,9 @@ export default defineCommand({
175
176
  const where = peerSource(name, probe)
176
177
  const installed = peerVersion(name, probe)
177
178
  // an archived peer (the headless host, the editors) has no "latest" of a registry: the
178
- // version this CLI is for is the one to have
179
+ // version this CLI installs is the one to have
179
180
  const latest = !installed ? null
180
- : archivedVersion(name) ?? await npmLatest(name)
181
+ : await archivedVersion(name) ?? await npmLatest(name)
181
182
  rows.push({ name, installed, latest, where })
182
183
  }
183
184
  rows.push({ name: "lecodes-cli", installed: pkg.version, latest: await npmLatest("lecodes-cli"), where: "global" })
@@ -192,7 +193,7 @@ export default defineCommand({
192
193
  }
193
194
  if (behind.length === 0) return "ok"
194
195
  const prefixBehind = behind.filter((r) => r.where === "prefix")
195
- const globalSpecs = behind.filter((r) => r.where !== "prefix").map((r) => archiveSpec(r.name) ?? 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"))
196
197
  if (flags.check) {
197
198
  return ["updated", behind.length + " behind — run: lecodes update" + (globalSpecs.length ? " (global: npm install -g " + globalSpecs.join(" ") + ")" : "")]
198
199
  }
@@ -5,8 +5,10 @@ import pkg from "../../package.json"
5
5
  * `npm install <url>` puts the package in place under its own name.
6
6
  *
7
7
  * A url names one version, and which one is this CLI's to say: the headless host at the CLI's own
8
- * version (one ABI set), an editor at the version the CLI was published against (its peer range,
9
- * snapped to it by scripts/sync-peer-versions.ts). */
8
+ * version (one ABI set); an editor at the NEWEST version out that the CLI's peer range takes — the
9
+ * package's index.json in the bucket names what is out. So an editor is released with no release
10
+ * of the CLI: `lecodes update` brings it. With the index out of reach it is the range's own
11
+ * version, the one the CLI was published against (scripts/sync-peer-versions.ts). */
10
12
 
11
13
  export const ARCHIVED_PEERS: ReadonlySet<string> = new Set(["lecodes-headless", "lecodes-design", "lecodes-3d-editor"])
12
14
 
@@ -16,17 +18,49 @@ export const packagesBase = (): string => (process.env.LECODES_PACKAGES_URL || B
16
18
 
17
19
  export const archiveUrl = (name: string, version: string): string => `${packagesBase()}/${name}/${name}-${version}.tgz`
18
20
 
21
+ const numbers = (version: string): number[] => version.split(/[.-]/).slice(0, 3).map(Number)
22
+ const byVersion = (a: string, b: string): number => {
23
+ const pa = numbers(a), pb = numbers(b)
24
+ for (let i = 0; i < 3; i++) { const d = (pa[i] ?? 0) - (pb[i] ?? 0); if (d) return d }
25
+ return 0
26
+ }
27
+
28
+ /** `^floor` takes it: not older, and the same leftmost number that is not zero. */
29
+ export const caretTakes = (floor: string, version: string): boolean => {
30
+ if (!/^\d+\.\d+\.\d+$/.test(version) || byVersion(version, floor) < 0) return false
31
+ const f = numbers(floor), v = numbers(version)
32
+ const held = f[0] ? 1 : f[1] ? 2 : 3
33
+ for (let i = 0; i < held; i++) if (f[i] !== v[i]) return false
34
+ return true
35
+ }
36
+
37
+ const asked = new Map<string, Promise<string[]>>()
38
+ /** What is out of a package, as its index names it; none with the index out of reach. */
39
+ const versionsOut = (name: string): Promise<string[]> => {
40
+ const url = `${packagesBase()}/${name}/index.json`
41
+ let answer = asked.get(url)
42
+ if (!answer) {
43
+ answer = fetch(`${url}?t=${Date.now()}`, { signal: AbortSignal.timeout(4000) })
44
+ .then(async (res) => res.ok ? ((await res.json()) as { versions?: string[] }).versions ?? [] : [])
45
+ .catch(() => [])
46
+ asked.set(url, answer)
47
+ }
48
+ return answer
49
+ }
50
+
19
51
  /** The version of an archived peer this CLI installs; null for a peer that is none. */
20
- export const archivedVersion = (name: string): string | null => {
52
+ export const archivedVersion = async (name: string): Promise<string | null> => {
21
53
  if (!ARCHIVED_PEERS.has(name)) return null
22
54
  if (name === "lecodes-headless") return pkg.version
23
55
  const range = (pkg.peerDependencies as Record<string, string> | undefined)?.[name]
24
- const version = range?.replace(/^[\^~>=\s]+/, "")
25
- return version && /^\d+\.\d+\.\d+/.test(version) ? version : null
56
+ const floor = range?.replace(/^[\^~>=\s]+/, "")
57
+ if (!floor || !/^\d+\.\d+\.\d+/.test(floor)) return null
58
+ const newest = (await versionsOut(name)).filter((version) => caretTakes(floor, version)).sort(byVersion).pop()
59
+ return newest ?? floor
26
60
  }
27
61
 
28
62
  /** What an archived peer is installed by; null for a peer that is none. */
29
- export const archiveSpec = (name: string): string | null => {
30
- const version = archivedVersion(name)
63
+ export const archiveSpec = async (name: string): Promise<string | null> => {
64
+ const version = await archivedVersion(name)
31
65
  return version ? archiveUrl(name, version) : null
32
66
  }
@@ -31,10 +31,10 @@ const CANVAS_RANGE_FALLBACK = "^0.1.65"
31
31
  const readJson = (path: string): any => { try { return JSON.parse(readFileSync(path, "utf8")) } catch { return null } }
32
32
 
33
33
  /** What a peer installs by: the url of its archive — the renderer at exactly this CLI's version (one
34
- * ABI set), an editor at the version this CLI was published against — or, for npm's, a spec: canvas
35
- * at the range the installed renderer declares, lecodes-assets at this CLI's peer range. */
36
- export const peerSpec = (name: string): string => {
37
- const archive = archiveSpec(name)
34
+ * ABI set), an editor at the newest version out that this CLI's peer range takes — or, for npm's, a
35
+ * spec: canvas at the range the installed renderer declares, lecodes-assets at this CLI's peer range. */
36
+ export const peerSpec = async (name: string): Promise<string> => {
37
+ const archive = await archiveSpec(name)
38
38
  if (archive) return archive
39
39
  if (name === "@napi-rs/canvas") {
40
40
  const renderer = readJson(join(peerDirInPrefix("lecodes-headless"), "package.json"))
@@ -71,7 +71,7 @@ export const installPeers = async (names: string[], why: string, opts: { yes?: b
71
71
  }
72
72
  // The renderer must be in place before canvas's range can be read off it — install in order.
73
73
  const ordered = [...names].sort((a, b) => (a === "lecodes-headless" ? -1 : b === "lecodes-headless" ? 1 : 0))
74
- const specsNow = ordered.map(peerSpec)
74
+ const specsNow = await Promise.all(ordered.map(peerSpec))
75
75
 
76
76
  const yes = opts.yes === true || !!process.env.LECODES_YES
77
77
  if (!yes) {
@@ -90,7 +90,7 @@ export const installPeers = async (names: string[], why: string, opts: { yes?: b
90
90
  if (!tool) throw new CliError(`Neither npm nor bun is on PATH, so ${names.join(" + ")} can't be installed automatically. Install one, or run on a machine with Node: ${manualCommand(dir, specsNow)}`)
91
91
 
92
92
  for (const name of ordered) {
93
- const spec = peerSpec(name) // canvas's range comes from the renderer installed a step earlier
93
+ const spec = await peerSpec(name) // canvas's range comes from the renderer installed a step earlier
94
94
  const argv = tool === "npm"
95
95
  ? ["install", "--prefix", dir, "--no-audit", "--no-fund", "--loglevel=error", spec]
96
96
  : ["add", "--cwd", dir, spec]
@@ -157,7 +157,7 @@ export async function loadPeer<T>(name: string, subpath: string | undefined, opt
157
157
  if (opts.install === false) return null
158
158
  // A global install of a DIFFERENT version than the probe expects would have resolved above; here
159
159
  // the peer is nowhere. (Globally installed but broken → the error above was not "not found".)
160
- if (peerVersionGlobal(name, spec) !== null) throw new CliError(`${name} is installed but "${spec}" can't be loaded from it — reinstall it: npm install -g ${peerSpec(name)}`)
160
+ if (peerVersionGlobal(name, spec) !== null) throw new CliError(`${name} is installed but "${spec}" can't be loaded from it — reinstall it: npm install -g ${await peerSpec(name)}`)
161
161
  await installPeers(companions(name), opts.for)
162
162
  }
163
163
  return (await import(pathToFileURL(resolveEntry(dir, subpath)).href)) as T
package/src/web/build.ts CHANGED
@@ -14,8 +14,12 @@ import { CliError } from "../cli"
14
14
  *
15
15
  * index.html the page (name, icon, theme color of app.json)
16
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
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)
19
23
  * assets/<path> the project's assets — only the ones the bundle names
20
24
  * 404.html in `path` mode: the page again, for the hostings that answer an unknown
21
25
  * path with it
@@ -36,6 +40,10 @@ export type WebConfig = {
36
40
  themeColor?: string
37
41
  /** The page's language (`<html lang>`). */
38
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"
39
47
  }
40
48
 
41
49
  export type WebBuildOptions = {
@@ -123,6 +131,17 @@ export const buildWeb = async (options: WebBuildOptions): Promise<WebBuildResult
123
131
  rmSync(out, { recursive: true, force: true })
124
132
  mkdirSync(out, { recursive: true })
125
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
+ }
126
145
 
127
146
  const bundle = `app.${createHash("sha256").update(js).digest("hex").slice(0, 10)}.js`
128
147
  writeFileSync(join(out, bundle), js)
@@ -154,7 +173,11 @@ export const buildWeb = async (options: WebBuildOptions): Promise<WebBuildResult
154
173
  }
155
174
  // in path mode a route is a path: what the page names relatively is under the base, whatever the route
156
175
  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(" ")
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(" ")
158
181
  const replace = (from: string | RegExp, to: string) => {
159
182
  const next = html.replace(from, to)
160
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.`)