flet-box 1.0.5 → 1.0.7

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 (94) hide show
  1. package/README.md +28 -1
  2. package/bin/cli.js +85 -18
  3. package/bin/commands/auditFix.js +682 -0
  4. package/bin/commands/checkApi.js +7 -5
  5. package/bin/commands/create.js +40 -10
  6. package/bin/commands/createBundle.js +54 -1
  7. package/bin/commands/images.js +349 -0
  8. package/bin/commands/pruneAssets.js +170 -0
  9. package/bin/commands/release.js +34 -1
  10. package/bin/commands/runCloudflare.js +47 -9
  11. package/bin/commands/runServer.js +81 -24
  12. package/bin/commands/toPng.js +346 -0
  13. package/bin/commands/unpack.js +1173 -0
  14. package/bin/ota/loader.js +90 -0
  15. package/bin/utils/appSources.js +101 -0
  16. package/bin/utils/bundleConfig.js +166 -0
  17. package/bin/utils/dtsProps.js +147 -0
  18. package/bin/utils/imageTools.js +271 -0
  19. package/bin/utils/images/bmp.js +99 -0
  20. package/bin/utils/images/gif.js +244 -0
  21. package/bin/utils/images/index.js +163 -0
  22. package/bin/utils/images/jpeg.js +643 -0
  23. package/bin/utils/images/png.js +486 -0
  24. package/bin/utils/images/unsupported.js +29 -0
  25. package/bin/utils/neutralise.js +109 -6
  26. package/bin/utils/templates.js +307 -64
  27. package/docs/cli/README.md +82 -0
  28. package/docs/guides/online-updates.md +57 -9
  29. package/docs/widget/Accordion.md +1 -0
  30. package/docs/widget/AdMob.md +1 -0
  31. package/docs/widget/AdSense.md +1 -0
  32. package/docs/widget/AnimatedBox.md +1 -0
  33. package/docs/widget/AnimatedText.md +1 -0
  34. package/docs/widget/Audio.md +2 -1
  35. package/docs/widget/Avatar.md +12 -3
  36. package/docs/widget/Badge.md +1 -0
  37. package/docs/widget/Button.md +1 -0
  38. package/docs/widget/Card.md +1 -0
  39. package/docs/widget/Carousel.md +1 -0
  40. package/docs/widget/Chart.md +1 -0
  41. package/docs/widget/Checkbox.md +1 -0
  42. package/docs/widget/Chip.md +1 -0
  43. package/docs/widget/CircularBar.md +1 -0
  44. package/docs/widget/CircularChart.md +1 -0
  45. package/docs/widget/CodeViewer.md +1 -0
  46. package/docs/widget/Column.md +1 -0
  47. package/docs/widget/Container.md +1 -0
  48. package/docs/widget/DataTable.md +1 -0
  49. package/docs/widget/Divider.md +1 -0
  50. package/docs/widget/DraggBox.md +3 -2
  51. package/docs/widget/Dropdown.md +3 -0
  52. package/docs/widget/DroppBox.md +2 -1
  53. package/docs/widget/FloatingActionButton.md +1 -0
  54. package/docs/widget/GridView.md +1 -0
  55. package/docs/widget/Icon.md +1 -0
  56. package/docs/widget/Image.md +11 -3
  57. package/docs/widget/Input.md +1 -0
  58. package/docs/widget/InstallButton.md +1 -0
  59. package/docs/widget/ListTile.md +1 -0
  60. package/docs/widget/ListView.md +1 -0
  61. package/docs/widget/Maps.md +188 -0
  62. package/docs/widget/Markdown.md +1 -0
  63. package/docs/widget/MatrixRain.md +1 -0
  64. package/docs/widget/Pagination.md +1 -0
  65. package/docs/widget/ParallaxBox.md +1 -0
  66. package/docs/widget/ProgressBar.md +1 -0
  67. package/docs/widget/QRCode.md +1 -0
  68. package/docs/widget/README.md +12 -10
  69. package/docs/widget/Radio.md +1 -0
  70. package/docs/widget/Rating.md +1 -0
  71. package/docs/widget/Row.md +1 -0
  72. package/docs/widget/Skeleton.md +1 -0
  73. package/docs/widget/Slider.md +1 -0
  74. package/docs/widget/Stack.md +1 -0
  75. package/docs/widget/Stepper.md +1 -0
  76. package/docs/widget/Switch.md +1 -0
  77. package/docs/widget/Text.md +1 -0
  78. package/docs/widget/Tooltip.md +11 -13
  79. package/docs/widget/TreeView.md +1 -0
  80. package/docs/widget/Video.md +3 -2
  81. package/package.json +2 -2
  82. package/src/core/hmr-client.js +129 -34
  83. package/src/index.d.ts +132 -0
  84. package/src/index.js +1 -0
  85. package/src/navigations/AppBar.js +15 -4
  86. package/src/navigations/Drawer.js +9 -1
  87. package/src/widget-factory/translateProps.js +32 -1
  88. package/src/widgets/Avatar.js +228 -131
  89. package/src/widgets/Container.js +13 -0
  90. package/src/widgets/Dropdown.js +33 -1
  91. package/src/widgets/Image.js +102 -3
  92. package/src/widgets/Maps.js +1495 -0
  93. package/src/widgets/Tooltip.js +12 -2
  94. package/src/widgets/index.js +1 -0
@@ -31,6 +31,7 @@ import { createRequire } from "node:module";
31
31
  import { fileURLToPath, pathToFileURL } from "node:url";
32
32
  import {
33
33
  parseInterfaces,
34
+ parseSignatures,
34
35
  resolveInterfaceProps,
35
36
  } from "../utils/dtsProps.js";
36
37
  import { c, banner, section } from "../utils/colors.js";
@@ -42,7 +43,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
42
43
  const CLI_PACKAGE_DIR = path.join(__dirname, "..", "..");
43
44
 
44
45
  /** Keys the factory itself reads, so they are never "invented props". */
45
- const STRUCTURAL_PROPS = new Set([
46
+ export const STRUCTURAL_PROPS = new Set([
46
47
  "child",
47
48
  "children",
48
49
  "tag",
@@ -129,7 +130,7 @@ const resolvePackage = (projectRoot) => {
129
130
  * @param {string} projectRoot
130
131
  * @returns {Promise<object>}
131
132
  */
132
- const loadApi = async (projectRoot) => {
133
+ export const loadApi = async (projectRoot) => {
133
134
  const { dir, pkg } = resolvePackage(projectRoot);
134
135
  const barrel = await import(pathToFileURL(path.join(dir, "src", "index.js")).href);
135
136
  const registry = await import(
@@ -143,6 +144,7 @@ const loadApi = async (projectRoot) => {
143
144
  PROP_MAP: registry.PROP_MAP,
144
145
  ALL_PROPS: registry.ALL_PROPS,
145
146
  interfaces: parseInterfaces(dts),
147
+ signatures: parseSignatures(dts),
146
148
  };
147
149
  };
148
150
 
@@ -153,7 +155,7 @@ const loadApi = async (projectRoot) => {
153
155
  * @param {string} b
154
156
  * @returns {number}
155
157
  */
156
- const distance = (a, b) => {
158
+ export const distance = (a, b) => {
157
159
  if (a === b) return 0;
158
160
  const m = a.length;
159
161
  const n = b.length;
@@ -180,7 +182,7 @@ const distance = (a, b) => {
180
182
  * @param {Iterable<string>} candidates
181
183
  * @returns {string | null}
182
184
  */
183
- const nearest = (value, candidates) => {
185
+ export const nearest = (value, candidates) => {
184
186
  let best = null;
185
187
  let bestScore = 4; // never suggest for names that are simply unrelated
186
188
  const lower = value.toLowerCase();
@@ -200,7 +202,7 @@ const nearest = (value, candidates) => {
200
202
  * @param {string} projectRoot
201
203
  * @returns {string[]}
202
204
  */
203
- const collectSources = (projectRoot) => {
205
+ export const collectSources = (projectRoot) => {
204
206
  const files = [];
205
207
  const walk = (dir) => {
206
208
  let entries;
@@ -18,6 +18,7 @@ import { createFile, copyFile, makeExecutable } from "../utils/helpers.js";
18
18
  import * as templates from "../utils/templates.js";
19
19
  import { parseGithubRepo } from "../utils/bundleConfig.js";
20
20
  import { checkApi } from "../commands/checkApi.js";
21
+ import { unpack } from "../commands/unpack.js";
21
22
  import { c, banner, gradient } from "../utils/colors.js";
22
23
 
23
24
  const __filename = fileURLToPath(import.meta.url);
@@ -51,25 +52,38 @@ const capacitorAppId = (projectName) => {
51
52
  * @returns {{ template: string, projectName: string|null, repo: string|null }}
52
53
  */
53
54
  const parseCreateArgs = (args) => {
55
+ const TEMPLATES = ["basic", "adaptive", "blank", "full", "sidebar"];
54
56
  let template = "basic"; // default template
55
57
  let projectName = null;
56
58
  let repo = null;
57
59
  for (let i = 0; i < args.length; i++) {
58
- if (args[i] === "--adaptive") {
60
+ const arg = args[i];
61
+ if (arg === "--adaptive") {
59
62
  template = "adaptive";
60
- } else if (args[i] === "--blank") {
63
+ } else if (arg === "--blank") {
61
64
  template = "blank";
62
- } else if (args[i] === "--full") {
65
+ } else if (arg === "--full") {
63
66
  template = "full";
64
- } else if (args[i] === "--sidebar") {
67
+ } else if (arg === "--sidebar") {
65
68
  template = "sidebar";
66
- } else if (args[i] === "--repo") {
69
+ } else if (arg === "--template" || arg.startsWith("--template=")) {
70
+ // `--template blank` was read as a bare positional, so the value became the
71
+ // project name and `flet-box create myapp --template blank` built a folder
72
+ // called `blank` running the `basic` template.
73
+ const inline = arg.startsWith("--template=");
74
+ const value = inline ? arg.slice("--template=".length) : args[++i];
75
+ if (TEMPLATES.includes(value)) template = value;
76
+ else if (value !== undefined)
77
+ console.warn(c("yellow", `⚠️ Unknown template "${value}", using ${template}`));
78
+ } else if (arg === "--repo") {
67
79
  if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
68
80
  repo = args[i + 1];
69
81
  i++;
70
82
  }
71
- } else if (!args[i].startsWith("--")) {
72
- projectName = args[i];
83
+ } else if (!arg.startsWith("--") && projectName === null) {
84
+ // The FIRST positional is the name. A later one is an unclaimed flag value
85
+ // and must not silently rename the project.
86
+ projectName = arg;
73
87
  }
74
88
  }
75
89
  return { template, projectName, repo };
@@ -214,7 +228,11 @@ export const createProject = async (projectName, rawArgs = []) => {
214
228
  createFile(path.join(projectPath, ".gitignore"), templates.gitignore());
215
229
  createFile(
216
230
  path.join(projectPath, ".github/workflows/build.yml"),
217
- templates.githubWorkflow(finalName, appId),
231
+ templates.githubWorkflow(finalName),
232
+ );
233
+ createFile(
234
+ path.join(projectPath, ".github/workflows/build-native.yml"),
235
+ templates.nativeWorkflow(finalName),
218
236
  );
219
237
  createFile(
220
238
  path.join(projectPath, "README.md"),
@@ -418,6 +436,17 @@ export const createProject = async (projectName, rawArgs = []) => {
418
436
  print: (line) => console.log(line),
419
437
  });
420
438
 
439
+ // The machine-readable twin of the cheatsheet: one entry per widget per prop,
440
+ // each with the engine route, the default and a sample value that the types
441
+ // prove. The agent inside the app writes calls from this file.
442
+ const dump = await unpack(["--all", "--md", "--root", projectPath], { print: () => {} });
443
+ if (dump.exitCode !== 0)
444
+ console.warn(
445
+ c("yellow", "⚠️ Could not generate .fletbox/api/api.json — run `npm run api` after installing."),
446
+ );
447
+ else
448
+ console.log(` ${c("gray", ".fletbox/api/api.json + api.md — the verified surface, for the agent")}`);
449
+
421
450
  makeExecutable(path.join(projectPath, "run.sh"));
422
451
 
423
452
  console.log(
@@ -464,8 +493,9 @@ export const createProject = async (projectName, rawArgs = []) => {
464
493
  console.log(
465
494
  c(
466
495
  "gray",
467
- "CI: .github/workflows/build.yml builds web + Android + iOS on every push.\n" +
468
- " Enable GitHub Pages (Settings → Pages → Source: GitHub Actions) for the web preview.\n",
496
+ "CI: .github/workflows/build.yml publishes the web to GitHub Pages on every push.\n" +
497
+ " Enable it once: Settings → Pages → Source: GitHub Actions. The job prints the URL.\n" +
498
+ " Android and iOS are manual: .github/workflows/build-native.yml, Run workflow.\n",
469
499
  ),
470
500
  );
471
501
  console.log(
@@ -17,7 +17,7 @@ import fs from "fs";
17
17
  import path from "path";
18
18
  import { fileURLToPath } from "url";
19
19
  import { c, banner, gradient, section } from "../utils/colors.js";
20
- import { buildBundleConfig, getGitRemoteUrl } from "../utils/bundleConfig.js";
20
+ import { buildBundleConfig, collectAssets, getGitRemoteUrl, resolveAssetPatterns, absoluteShareTags } from "../utils/bundleConfig.js";
21
21
  import { INITIAL_VERSION, incrementVersion, isValidVersion } from "../utils/version.js";
22
22
 
23
23
  const __filename = fileURLToPath(import.meta.url);
@@ -191,6 +191,20 @@ export const createBundle = async (targetArg) => {
191
191
  console.log(c("green", "✅ Copied: src/assets (fonts + PWA icons)"));
192
192
  }
193
193
 
194
+ // The icon font is a stylesheet, not a bundle input, so it is copied as
195
+ // written. A generated app keeps it in src/assets/fonts (covered above); the
196
+ // framework root keeps it in src/fonts, which is the path its own index.html
197
+ // links — without this the bundled demo 404s the font and every <Icon/> is
198
+ // spelled as text.
199
+ if (fs.existsSync(path.join(srcDir, "fonts"))) {
200
+ fs.cpSync(
201
+ path.join(srcDir, "fonts"),
202
+ path.join(targetDir, "src", "fonts"),
203
+ { recursive: true },
204
+ );
205
+ console.log(c("green", "✅ Copied: src/fonts (icon font)"));
206
+ }
207
+
194
208
  // Copy global CSS (loaded directly by index.html, not bundled by esbuild).
195
209
  const globalCss = path.join(srcDir, "styles", "global.css");
196
210
  if (fs.existsSync(globalCss)) {
@@ -264,10 +278,31 @@ export const createBundle = async (targetArg) => {
264
278
  const stampedCode = header + bundledCode;
265
279
  fs.writeFileSync(outAppJs, stampedCode);
266
280
 
281
+ // ---- Release assets -----------------------------------------------------
282
+ // `update.assets` in fletbox.config.json lists files the app serves that are
283
+ // not part of the JS bundle (markdown docs, JSON data, images). They are copied
284
+ // into the bundle AND named in config.json with a hash, so an app that updated
285
+ // its bundle by OTA can still fetch the current copy of a file that its
286
+ // packaged build predates.
287
+ const assets = collectAssets(projectRoot, resolveAssetPatterns(projectRoot));
288
+ for (const asset of assets) {
289
+ const from = path.join(projectRoot, asset.path);
290
+ const to = path.join(targetDir, asset.path);
291
+ fs.mkdirSync(path.dirname(to), { recursive: true });
292
+ fs.copyFileSync(from, to);
293
+ }
294
+ if (assets.length) {
295
+ const kb = (assets.reduce((sum, a) => sum + a.size, 0) / 1024).toFixed(0);
296
+ console.log(
297
+ c("green", `✅ Copied: ${assets.length} OTA asset file(s) (${kb} KB)`),
298
+ );
299
+ }
300
+
267
301
  const bundleConfig = buildBundleConfig({
268
302
  projectRoot,
269
303
  bundleContent: stampedCode,
270
304
  bundlePath: "src/app.js",
305
+ assets,
271
306
  });
272
307
  fs.writeFileSync(
273
308
  path.join(targetDir, "config.json"),
@@ -355,6 +390,24 @@ export const createBundle = async (targetArg) => {
355
390
  }
356
391
  }
357
392
 
393
+ // ---- Social preview card --------------------------------------------------
394
+ // A scraper fetches the HTML and resolves the tags itself; it does not run the
395
+ // app and does not treat `./assets/hero.jpg` as the page origin does. Left
396
+ // relative, every share of the site posts a headline with no picture — and the
397
+ // site looks perfect to anyone who opens it, so nobody notices. The base is the
398
+ // URL OTA already derived, so the card points where the bundle is actually
399
+ // reachable, and only the built copy changes: `index.html` in the project stays
400
+ // host-agnostic so the same folder still runs on :8000 and through a tunnel.
401
+ const shareIndex = path.join(targetDir, "index.html");
402
+ if (fs.existsSync(shareIndex) && bundleConfig.update?.url) {
403
+ const html = fs.readFileSync(shareIndex, "utf8");
404
+ const stamped = absoluteShareTags(html, bundleConfig.update.url);
405
+ if (stamped !== html) {
406
+ fs.writeFileSync(shareIndex, stamped);
407
+ console.log(c("green", `✅ share card → absolute images at ${bundleConfig.update.url}`));
408
+ }
409
+ }
410
+
358
411
  // Preserve the app source tree so the in-app "View code" feature can read it.
359
412
  for (const dir of ["modules", "screens", "database", "components"]) {
360
413
  const src = path.join(srcDir, dir);
@@ -0,0 +1,349 @@
1
+ /**
2
+ * @file bin/commands/images.js
3
+ * @description `flet-box images` — shrinks a folder of photos to a byte budget
4
+ * with the platform's own WebP encoder.
5
+ *
6
+ * The problem this solves is a connection, not a disk: a page that pulls 8 MB of
7
+ * photographs never finishes loading, and every visitor pays for all of them at
8
+ * once. So the unit of work is the per-file budget, and the search goes in the
9
+ * order that costs quality least — throw away pixels nobody can see first, then
10
+ * ask the encoder harder.
11
+ *
12
+ * `to-png` is the sibling command for flat art. It stays lossless and needs no
13
+ * binary at all; this one needs a real encoder and says which one it found,
14
+ * because a photograph has no lossless answer. What it also cannot do is verify
15
+ * pixels: with no WebP decoder in the package, the read-back check is the
16
+ * container plus the alpha plane, which is why `--replace` asks the app's own
17
+ * sources before it deletes a master.
18
+ */
19
+
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { c, section, dot } from "../utils/colors.js";
23
+ import { decodeImage, readImageHeader, UnsupportedImage } from "../utils/images/index.js";
24
+ import { readAppSources, references, protectedMaster } from "../utils/appSources.js";
25
+ import { ensureEncoder, findEncoder, encode, tempIn, describeMachine, hasTransparency, webpCarriesAlpha } from "../utils/imageTools.js";
26
+
27
+ const USAGE = `Usage: flet-box images [dir] [options]
28
+
29
+ dir Folder of photos (default: ./assets, or ./public/assets)
30
+
31
+ Options:
32
+ --budget <KB> Largest file to accept, per image (default: 40)
33
+ --width <px> Never exceed this pixel width (default: the source width)
34
+ --quality <n> Highest quality to try (default: 80, floor 62)
35
+ --referenced Only convert sources whose .webp the app's own code names
36
+ --root <dir> Project root the sources are read from (default: .)
37
+ --force Overwrite an existing WebP with the same name
38
+ --replace Delete each source after its WebP was written and read back
39
+ --install Install the encoder this machine is missing
40
+ --dry-run Report what would happen; write nothing
41
+ --quiet Only the summary line
42
+ --help This page`;
43
+
44
+ const DEFAULT_DIRS = ["assets", "public/assets", "src/assets"];
45
+ const SKIPPED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".cache", "www"]);
46
+ const SOURCE_FILE = /\.(jpe?g|png)$/i;
47
+
48
+ /**
49
+ * Pixels before quality, always in that order. A soft 540px photo at q70 is a
50
+ * better picture than a blocky 540px one at q44, and nobody can see the pixels
51
+ * between 2x the width the layout paints and the sensor's native 2000.
52
+ */
53
+ const WIDTH_SCALES = [1, 0.9, 0.8, 0.7, 0.62];
54
+ const QUALITY_FLOOR = 62;
55
+
56
+ const size = (bytes) =>
57
+ bytes >= 1024 * 1024
58
+ ? `${(bytes / 1024 / 1024).toFixed(1)} MB`
59
+ : bytes >= 1024
60
+ ? `${Math.round(bytes / 1024)} KB`
61
+ : `${bytes} B`;
62
+
63
+ /**
64
+ * @param {string} dir
65
+ * @returns {string[]} Absolute paths, sorted.
66
+ */
67
+ const collect = (dir) => {
68
+ const found = [];
69
+ const walk = (current) => {
70
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
71
+ const full = path.join(current, entry.name);
72
+ if (entry.isDirectory()) {
73
+ if (!SKIPPED_DIRS.has(entry.name)) walk(full);
74
+ continue;
75
+ }
76
+ if (entry.isFile() && SOURCE_FILE.test(entry.name)) found.push(full);
77
+ }
78
+ };
79
+ if (fs.existsSync(dir)) walk(dir);
80
+ return found.sort();
81
+ };
82
+
83
+ /**
84
+ * Confirms the bytes on disk are a WebP container.
85
+ *
86
+ * @param {Buffer} buf
87
+ * @returns {boolean}
88
+ */
89
+ const isWebPBytes = (buf) =>
90
+ buf.length > 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP";
91
+
92
+ /**
93
+ * Runs the shrink.
94
+ *
95
+ * @param {string[]} args - CLI arguments after the command name.
96
+ * @returns {Promise<{exitCode: number}>} Non-zero when a file failed to encode.
97
+ */
98
+ export const images = async (args = []) => {
99
+ const has = (flag) => args.includes(flag);
100
+ const valueOf = (flag) => {
101
+ const i = args.indexOf(flag);
102
+ return i >= 0 ? args[i + 1] : undefined;
103
+ };
104
+
105
+ if (has("--help") || has("-h")) {
106
+ console.log(USAGE);
107
+ return { exitCode: 0 };
108
+ }
109
+
110
+ const quiet = has("--quiet");
111
+ const dryRun = has("--dry-run");
112
+ const force = has("--force");
113
+ const replace = has("--replace");
114
+ const referenced = has("--referenced");
115
+ const budgetKB = Number(valueOf("--budget") || 40);
116
+ const capWidth = Number(valueOf("--width") || 0);
117
+ const topQuality = Number(valueOf("--quality") || 80);
118
+ const root = path.resolve(valueOf("--root") || ".");
119
+
120
+ if (!Number.isFinite(budgetKB) || budgetKB <= 0) {
121
+ console.log(c("red", `--budget wants a number of kilobytes, got: ${valueOf("--budget")}`));
122
+ return { exitCode: 1 };
123
+ }
124
+
125
+ const positional = args.filter(
126
+ (a, i) => !a.startsWith("--") && !["--budget", "--width", "--quality", "--root"].includes(args[i - 1]),
127
+ );
128
+ let target = positional[0] ? path.resolve(positional[0]) : null;
129
+ if (!target) {
130
+ target = DEFAULT_DIRS.map((d) => path.resolve(d)).find((d) => fs.existsSync(d)) || null;
131
+ }
132
+ if (!target || !fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
133
+ console.log(c("red", `Nothing to convert: ${target || DEFAULT_DIRS.join(", ")} is not a folder here.`));
134
+ console.log(c("gray", " Pass one: flet-box images assets/landing --budget 40"));
135
+ return { exitCode: 1 };
136
+ }
137
+
138
+ const encoder = await ensureEncoder(findEncoder(), { autoInstall: has("--install"), quiet });
139
+ if (!encoder) {
140
+ console.log("");
141
+ return { exitCode: 1 };
142
+ }
143
+
144
+ // The name gate is read from the app's own sources, comments stripped, so a
145
+ // note like `mojito.jpg is the frappé` does not count as a consumer. `--replace`
146
+ // always loads it: deleting a master because its WebP exists is only safe when
147
+ // something in the project already asks for that WebP by name.
148
+ const namesSources = referenced || replace;
149
+ const text = namesSources ? readAppSources(root) : "";
150
+ const keyOf = (file) => {
151
+ const rel = path.relative(root, file).split(path.sep).join("/");
152
+ return rel && !rel.startsWith("..") ? rel : path.basename(file);
153
+ };
154
+ const named = (file) => references(text, keyOf(file));
155
+
156
+ const qualities = [];
157
+ for (let q = Math.max(QUALITY_FLOOR, Math.min(topQuality, 100)); q >= QUALITY_FLOOR; q -= 4) qualities.push(q);
158
+ if (qualities[qualities.length - 1] !== QUALITY_FLOOR) qualities.push(QUALITY_FLOOR);
159
+
160
+ const files = collect(target);
161
+ const lines = [];
162
+ let written = 0;
163
+ let inside = 0;
164
+ let over = 0;
165
+ let failed = 0;
166
+ let clash = 0;
167
+ let removed = 0;
168
+ let masters = 0;
169
+ let unreferenced = 0;
170
+ let sourceKept = 0;
171
+ let beforeBytes = 0;
172
+ let afterBytes = 0;
173
+
174
+ for (const file of files) {
175
+ const relative = path.relative(process.cwd(), file) || path.basename(file);
176
+ const out = path.join(path.dirname(file), path.basename(file, path.extname(file)) + ".webp");
177
+
178
+ const master = protectedMaster(keyOf(file));
179
+ if (master) {
180
+ masters++;
181
+ lines.push(` ${dot("#64748b")} ${relative} — ${c("dim", `left alone: ${master.why}`)}`);
182
+ continue;
183
+ }
184
+
185
+ // The WebP, not the source: an app that loads `assets/favicon.png` by name
186
+ // means it — a browser tab icon and a social preview are declared formats, and
187
+ // a `favicon.webp` beside them is a file nothing requests. Point the code at
188
+ // the .webp and this converts it.
189
+ if (referenced && !named(out)) {
190
+ unreferenced++;
191
+ lines.push(` ${dot("#64748b")} ${relative} — ${c("dim", `not referenced: no source loads ${path.basename(out)}`)}`);
192
+ continue;
193
+ }
194
+
195
+ let dims;
196
+ let sourceBytes;
197
+ try {
198
+ sourceBytes = fs.readFileSync(file);
199
+ } catch (error) {
200
+ failed++;
201
+ lines.push(` ${dot("#ef4444")} ${relative} — ${c("red", "unreadable")} — ${error.message}`);
202
+ continue;
203
+ }
204
+ let fromHeader = false;
205
+ try {
206
+ dims = decodeImage(sourceBytes);
207
+ } catch (error) {
208
+ if (!(error instanceof UnsupportedImage)) {
209
+ failed++;
210
+ lines.push(` ${dot("#ef4444")} ${relative} — ${c("red", "failed")} — ${error.message}`);
211
+ continue;
212
+ }
213
+ // A progressive JPEG is a ordinary export from Photoshop or macOS and every
214
+ // browser paints it; the pixel decoder declines multi-scan coding because
215
+ // that is the honest thing for a lossless converter to do. A budget search
216
+ // needs no pixels: the frame size and the presence of an alpha plane are
217
+ // both in the header, and the encoder reads the file itself.
218
+ try {
219
+ dims = readImageHeader(sourceBytes);
220
+ fromHeader = true;
221
+ } catch {
222
+ failed++;
223
+ lines.push(` ${dot("#ef4444")} ${relative} — ${c("red", "declined")} — ${error.message}`);
224
+ continue;
225
+ }
226
+ }
227
+
228
+ const needsAlpha = fromHeader ? dims.hasAlpha === true : hasTransparency(dims.rgba);
229
+ const fullWidth = capWidth > 0 ? Math.min(dims.width, capWidth) : dims.width;
230
+ beforeBytes += sourceBytes.length;
231
+
232
+ // A file already inside the budget is not re-encoded: another lossy pass on
233
+ // bytes the same encoder already chose buys nothing anyone can see.
234
+ if (!force && fs.existsSync(out) && fs.statSync(out).size <= budgetKB * 1024) {
235
+ inside++;
236
+ afterBytes += fs.statSync(out).size;
237
+ continue;
238
+ }
239
+ if (fs.existsSync(out) && !force) {
240
+ clash++;
241
+ lines.push(` ${dot("#f59e0b")} ${relative} — ${c("yellow", "kept")} — ${path.basename(out)} already exists (--force to replace it)`);
242
+ continue;
243
+ }
244
+
245
+ const tmp = tempIn(path.dirname(file), path.basename(file, path.extname(file)));
246
+ // The widest frame that still fits at an acceptable quality. Every scale is
247
+ // tried even when none of them fits, and the smallest attempt is kept: a file
248
+ // that cannot make the budget should still ship as small as it can, not be
249
+ // skipped and left at 900 KB.
250
+ let chosen = null;
251
+ let smallest = null;
252
+ for (const scale of WIDTH_SCALES) {
253
+ const width = Math.max(1, Math.round(fullWidth * scale));
254
+ for (const quality of qualities) {
255
+ const bytes = encode(encoder, { src: file, width, quality, out: tmp });
256
+ if (bytes === null) continue;
257
+ const candidate = { width, quality, bytes };
258
+ if (!smallest || candidate.bytes < smallest.bytes) smallest = candidate;
259
+ if (bytes <= budgetKB * 1024) {
260
+ chosen = candidate;
261
+ break;
262
+ }
263
+ }
264
+ if (chosen) break;
265
+ }
266
+ fs.rmSync(tmp, { force: true });
267
+ const final = chosen || smallest;
268
+
269
+ if (!final) {
270
+ failed++;
271
+ lines.push(` ${dot("#ef4444")} ${relative} — ${c("red", "failed")} — ${encoder.bin} produced nothing for it`);
272
+ continue;
273
+ }
274
+
275
+ const overBudget = final.bytes > budgetKB * 1024;
276
+ afterBytes += final.bytes;
277
+ if (overBudget) over++;
278
+ const height = Math.max(1, Math.round((final.width / dims.width) * dims.height));
279
+
280
+ if (dryRun) {
281
+ written++;
282
+ lines.push(
283
+ ` ${dot("#22d3ee")} ${relative} → ${path.basename(out)} ${final.width}×${height} q${final.quality} ${size(sourceBytes.length)} → ${c(overBudget ? "yellow" : "green", size(final.bytes))}${overBudget ? c("yellow", " OVER") : ""}${needsAlpha ? c("dim", " ·alpha") : ""}${c("dim", " [dry run]")}`,
284
+ );
285
+ continue;
286
+ }
287
+
288
+ encode(encoder, { src: file, width: final.width, quality: final.quality, out });
289
+ let back = null;
290
+ try {
291
+ back = fs.readFileSync(out);
292
+ } catch {
293
+ back = null;
294
+ }
295
+ if (!back || !isWebPBytes(back)) {
296
+ failed++;
297
+ lines.push(` ${dot("#ef4444")} ${relative} — ${c("red", "failed")} — the WebP written back is not readable`);
298
+ continue;
299
+ }
300
+ // The one pixel-level promise this container allows: if the source had a
301
+ // transparent pixel, the output must still have an alpha plane. A sticker
302
+ // flattened onto white is a different picture, and it is the kind of loss a
303
+ // byte budget cannot report.
304
+ if (needsAlpha && !webpCarriesAlpha(back)) {
305
+ failed++;
306
+ fs.rmSync(out, { force: true });
307
+ lines.push(` ${dot("#ef4444")} ${relative} — ${c("red", "failed")} — ${path.basename(out)} has no alpha plane; the source's transparency was dropped`);
308
+ continue;
309
+ }
310
+ written++;
311
+ let gone = "";
312
+ if (replace && named(out)) {
313
+ fs.rmSync(file);
314
+ removed++;
315
+ gone = c("dim", " · source removed");
316
+ } else if (replace) {
317
+ sourceKept++;
318
+ gone = c("dim", ` · source kept: the app names ${path.basename(file)}, not ${path.basename(out)}`);
319
+ }
320
+ lines.push(
321
+ ` ${dot("#34d399")} ${relative} → ${path.basename(out)} ${final.width}×${height} q${final.quality} ${size(sourceBytes.length)} → ${c(overBudget ? "yellow" : "green", size(final.bytes))}${overBudget ? c("yellow", ` OVER ${budgetKB}KB`) : ""}${needsAlpha ? c("dim", " ·alpha") : ""}${gone}`,
322
+ );
323
+ }
324
+
325
+ if (!quiet) {
326
+ console.log(section("🖼", `images — ${path.relative(process.cwd(), target) || "."}`));
327
+ for (const line of lines) console.log(line);
328
+ }
329
+ if (!files.length) console.log(c("gray", " No JPG or PNG files in that folder."));
330
+
331
+ const parts = [
332
+ `${written} written`,
333
+ inside ? `${inside} already inside budget` : null,
334
+ `${over} over ${budgetKB}KB`,
335
+ masters ? `${masters} master kept` : null,
336
+ unreferenced ? `${unreferenced} not referenced` : null,
337
+ clash ? `${clash} name clash` : null,
338
+ removed ? `${removed} sources removed` : null,
339
+ sourceKept ? `${sourceKept} sources kept` : null,
340
+ failed ? `${failed} failed` : null,
341
+ size(beforeBytes) + " → " + c(afterBytes < beforeBytes ? "green" : "yellow", size(afterBytes)),
342
+ ].filter(Boolean);
343
+ console.log(`\n ${describeMachine()} · ${encoder.bin}\n`);
344
+ console.log(` ${parts.join(" · ")}${dryRun ? c("yellow", " · nothing written (--dry-run)") : ""}\n`);
345
+
346
+ return { exitCode: failed ? 1 : 0 };
347
+ };
348
+
349
+ export default images;