flet-box 1.0.6 β†’ 1.0.8

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 (162) hide show
  1. package/README.md +28 -1
  2. package/bin/cli.js +90 -11
  3. package/bin/commands/auditFix.js +682 -0
  4. package/bin/commands/checkApi.js +20 -6
  5. package/bin/commands/checkUi.js +1369 -0
  6. package/bin/commands/create.js +86 -12
  7. package/bin/commands/createBundle.js +75 -1
  8. package/bin/commands/images.js +349 -0
  9. package/bin/commands/pruneAssets.js +170 -0
  10. package/bin/commands/release.js +34 -1
  11. package/bin/commands/runCloudflare.js +47 -9
  12. package/bin/commands/runServer.js +91 -25
  13. package/bin/commands/toPng.js +346 -0
  14. package/bin/commands/unpack.js +1173 -0
  15. package/bin/ota/loader.js +90 -0
  16. package/bin/utils/appSources.js +101 -0
  17. package/bin/utils/bundleConfig.js +166 -0
  18. package/bin/utils/dtsProps.js +147 -0
  19. package/bin/utils/imageTools.js +271 -0
  20. package/bin/utils/images/bmp.js +99 -0
  21. package/bin/utils/images/gif.js +244 -0
  22. package/bin/utils/images/index.js +163 -0
  23. package/bin/utils/images/jpeg.js +643 -0
  24. package/bin/utils/images/png.js +486 -0
  25. package/bin/utils/images/unsupported.js +29 -0
  26. package/bin/utils/neutralise.js +109 -6
  27. package/bin/utils/pwaShell.js +137 -0
  28. package/bin/utils/templates.js +1653 -471
  29. package/docs/README.md +2 -0
  30. package/docs/cli/README.md +144 -1
  31. package/docs/guides/README.md +2 -0
  32. package/docs/guides/clean-ui.md +211 -0
  33. package/docs/guides/minification-and-protection.md +1 -1
  34. package/docs/guides/online-updates.md +105 -9
  35. package/docs/tools/README.md +1 -1
  36. package/docs/tools/animation.md +1 -1
  37. package/docs/tools/markdown.md +3 -3
  38. package/docs/tools/theme.md +41 -1
  39. package/docs/widget/Accordion.md +4 -0
  40. package/docs/widget/AdMob.md +5 -1
  41. package/docs/widget/AdSense.md +4 -0
  42. package/docs/widget/AnimatedBox.md +7 -3
  43. package/docs/widget/AnimatedText.md +4 -0
  44. package/docs/widget/Audio.md +5 -1
  45. package/docs/widget/Avatar.md +15 -3
  46. package/docs/widget/Badge.md +4 -0
  47. package/docs/widget/Button.md +4 -0
  48. package/docs/widget/COMMON_PROPS.md +52 -0
  49. package/docs/widget/Card.md +4 -0
  50. package/docs/widget/Carousel.md +4 -0
  51. package/docs/widget/Chart.md +4 -0
  52. package/docs/widget/Checkbox.md +4 -0
  53. package/docs/widget/Chip.md +4 -0
  54. package/docs/widget/CircularBar.md +4 -0
  55. package/docs/widget/CircularChart.md +4 -0
  56. package/docs/widget/CodeViewer.md +4 -0
  57. package/docs/widget/Column.md +4 -0
  58. package/docs/widget/Container.md +4 -0
  59. package/docs/widget/DataTable.md +4 -0
  60. package/docs/widget/Divider.md +4 -0
  61. package/docs/widget/DraggBox.md +6 -2
  62. package/docs/widget/Dropdown.md +6 -0
  63. package/docs/widget/DroppBox.md +5 -1
  64. package/docs/widget/FloatingActionButton.md +4 -0
  65. package/docs/widget/GridView.md +4 -0
  66. package/docs/widget/Icon.md +4 -0
  67. package/docs/widget/Image.md +14 -3
  68. package/docs/widget/Input.md +4 -0
  69. package/docs/widget/Inspector.md +2 -2
  70. package/docs/widget/InstallButton.md +4 -0
  71. package/docs/widget/ListTile.md +4 -0
  72. package/docs/widget/ListView.md +4 -0
  73. package/docs/widget/Maps.md +191 -0
  74. package/docs/widget/Markdown.md +4 -0
  75. package/docs/widget/MatrixRain.md +4 -0
  76. package/docs/widget/Pagination.md +4 -0
  77. package/docs/widget/ParallaxBox.md +4 -0
  78. package/docs/widget/ProgressBar.md +4 -0
  79. package/docs/widget/QRCode.md +4 -0
  80. package/docs/widget/README.md +12 -10
  81. package/docs/widget/Radio.md +5 -1
  82. package/docs/widget/Rating.md +4 -0
  83. package/docs/widget/Row.md +4 -0
  84. package/docs/widget/Skeleton.md +4 -0
  85. package/docs/widget/Slider.md +4 -0
  86. package/docs/widget/Stack.md +4 -0
  87. package/docs/widget/Stepper.md +4 -0
  88. package/docs/widget/Switch.md +4 -0
  89. package/docs/widget/Tabs.md +5 -5
  90. package/docs/widget/Text.md +4 -0
  91. package/docs/widget/Tooltip.md +14 -13
  92. package/docs/widget/TreeView.md +4 -0
  93. package/docs/widget/Video.md +6 -2
  94. package/package.json +2 -2
  95. package/src/animations/AnimatedBox.js +4 -1
  96. package/src/animations/MatrixRain.js +3 -3
  97. package/src/core/hmr-client.js +129 -34
  98. package/src/index.d.ts +195 -7
  99. package/src/index.js +2 -0
  100. package/src/navigations/AppBar.js +22 -16
  101. package/src/navigations/BottomNavigation.js +8 -13
  102. package/src/navigations/CollapsibleSideBar.js +30 -8
  103. package/src/navigations/Drawer.js +25 -15
  104. package/src/navigations/DrawerItem.js +3 -1
  105. package/src/navigations/Scaffold.js +26 -12
  106. package/src/navigations/Tabs.js +104 -59
  107. package/src/tools/color.js +54 -0
  108. package/src/tools/index.js +9 -1
  109. package/src/tools/scale.js +41 -0
  110. package/src/tools/shadow.js +20 -0
  111. package/src/utils/index.js +1 -0
  112. package/src/utils/readableInk.js +19 -0
  113. package/src/utils/syntaxHighlight.js +78 -10
  114. package/src/utils/themes.js +217 -54
  115. package/src/utils/units.js +33 -11
  116. package/src/widget-factory/effects.js +56 -5
  117. package/src/widget-factory/processProps.js +10 -1
  118. package/src/widget-factory/remTool.js +15 -29
  119. package/src/widget-factory/stackPosition.js +18 -3
  120. package/src/widget-factory/translateProps.js +60 -1
  121. package/src/widgets/Accordion.js +20 -23
  122. package/src/widgets/AdMob.js +7 -2
  123. package/src/widgets/Avatar.js +236 -133
  124. package/src/widgets/Badge.js +3 -2
  125. package/src/widgets/BottomSheet.js +14 -13
  126. package/src/widgets/Button.js +69 -30
  127. package/src/widgets/Card.js +6 -2
  128. package/src/widgets/Carousel.js +12 -10
  129. package/src/widgets/Chart.js +28 -5
  130. package/src/widgets/Checkbox.js +76 -18
  131. package/src/widgets/Chip.js +31 -10
  132. package/src/widgets/CircularBar.js +7 -7
  133. package/src/widgets/CircularChart.js +26 -8
  134. package/src/widgets/CodeViewer.js +93 -20
  135. package/src/widgets/Container.js +13 -0
  136. package/src/widgets/DataTable.js +17 -11
  137. package/src/widgets/DraggBox.js +2 -1
  138. package/src/widgets/Dropdown.js +59 -14
  139. package/src/widgets/DroppBox.js +4 -2
  140. package/src/widgets/FloatingActionButton.js +13 -7
  141. package/src/widgets/Icon.js +3 -1
  142. package/src/widgets/Image.js +102 -3
  143. package/src/widgets/Input.js +30 -23
  144. package/src/widgets/Inspector.js +54 -1
  145. package/src/widgets/InstallButton.js +6 -4
  146. package/src/widgets/ListTile.js +4 -3
  147. package/src/widgets/ListView.js +30 -12
  148. package/src/widgets/Maps.js +1495 -0
  149. package/src/widgets/Markdown.js +37 -26
  150. package/src/widgets/Modal.js +14 -11
  151. package/src/widgets/Pagination.js +16 -11
  152. package/src/widgets/ProgressBar.js +6 -4
  153. package/src/widgets/Radio.js +73 -15
  154. package/src/widgets/Rating.js +24 -6
  155. package/src/widgets/Skeleton.js +31 -31
  156. package/src/widgets/Slider.js +29 -18
  157. package/src/widgets/SnackBar.js +35 -39
  158. package/src/widgets/Stepper.js +13 -9
  159. package/src/widgets/Switch.js +59 -18
  160. package/src/widgets/Tooltip.js +24 -12
  161. package/src/widgets/TreeView.js +27 -15
  162. package/src/widgets/index.js +1 -0
@@ -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 };
@@ -94,6 +108,21 @@ const askRepo = () =>
94
108
  );
95
109
  });
96
110
 
111
+ /**
112
+ * The GitHub Pages address a repo value implies, or null when the value is not
113
+ * a GitHub repo. Kept apart from `resolveOtaUrl` because the workflow header
114
+ * names the *Pages* site: a custom-domain OTA URL is not an address this file
115
+ * can publish to, so it must not be printed as one.
116
+ *
117
+ * @param {string|null} input - "user/repo" or a github.com URL.
118
+ * @returns {string|null} Base URL ending in "/", or null.
119
+ */
120
+ const pagesSiteUrl = (input) => {
121
+ if (!input) return null;
122
+ const gh = parseGithubRepo(input.trim());
123
+ return gh ? `https://${gh.user}.github.io/${gh.repo}/` : null;
124
+ };
125
+
97
126
  /**
98
127
  * Normalizes the user's OTA target into a base URL ending in "/".
99
128
  * - "user/repo" (or any GitHub URL shape) β†’ GitHub Pages URL.
@@ -109,8 +138,7 @@ const resolveOtaUrl = (input) => {
109
138
  if (/^https?:\/\//i.test(value)) {
110
139
  return value.endsWith("/") ? value : `${value}/`;
111
140
  }
112
- const gh = parseGithubRepo(value);
113
- return gh ? `https://${gh.user}.github.io/${gh.repo}/` : null;
141
+ return pagesSiteUrl(value);
114
142
  };
115
143
 
116
144
  /**
@@ -153,6 +181,10 @@ export const createProject = async (projectName, rawArgs = []) => {
153
181
  repoInput = await askRepo();
154
182
  }
155
183
  const otaUrl = resolveOtaUrl(repoInput);
184
+ // The Pages address the workflow file prints as its own header. Derived from
185
+ // the repo alone: `flet-box create myapp --repo me/my-website` says so in
186
+ // build.yml, and a missing or unparsable repo leaves the placeholder to edit.
187
+ const siteUrl = pagesSiteUrl(repoInput);
156
188
  if (repoInput && !otaUrl) {
157
189
  console.warn(
158
190
  c(
@@ -184,6 +216,8 @@ export const createProject = async (projectName, rawArgs = []) => {
184
216
  ".fletbox/api",
185
217
  ".fletbox/memory/notes",
186
218
  ".claude",
219
+ ".claude/skills/fletbox-clean-ui",
220
+ ".qoder/skills/fletbox-clean-ui",
187
221
  ".cursor/rules",
188
222
  ];
189
223
  const extraDirs =
@@ -214,7 +248,11 @@ export const createProject = async (projectName, rawArgs = []) => {
214
248
  createFile(path.join(projectPath, ".gitignore"), templates.gitignore());
215
249
  createFile(
216
250
  path.join(projectPath, ".github/workflows/build.yml"),
217
- templates.githubWorkflow(finalName, appId),
251
+ templates.githubWorkflow(finalName, siteUrl),
252
+ );
253
+ createFile(
254
+ path.join(projectPath, ".github/workflows/build-native.yml"),
255
+ templates.nativeWorkflow(finalName),
218
256
  );
219
257
  createFile(
220
258
  path.join(projectPath, "README.md"),
@@ -398,6 +436,29 @@ export const createProject = async (projectName, rawArgs = []) => {
398
436
  path.join(projectPath, ".cursor/rules/fletbox.mdc"),
399
437
  templates.cursorRules(),
400
438
  );
439
+
440
+ // The clean-UI skill. Claude Code loads `.claude/skills/<name>/SKILL.md` by
441
+ // description, so the agent picks it up whenever it touches a screen without
442
+ // being told. `.qoder/skills/` holds a byte-identical copy because Qoder reads
443
+ // the same shape from a different root β€” one text, two loaders, never two edits.
444
+ const cleanUiSkill = templates.cleanUiSkill();
445
+ createFile(
446
+ path.join(projectPath, ".claude/skills/fletbox-clean-ui/SKILL.md"),
447
+ cleanUiSkill,
448
+ );
449
+ createFile(
450
+ path.join(projectPath, ".qoder/skills/fletbox-clean-ui/SKILL.md"),
451
+ cleanUiSkill,
452
+ );
453
+ createFile(
454
+ path.join(projectPath, ".cursor/rules/fletbox-clean-ui.mdc"),
455
+ templates.cleanUiCursorRule(),
456
+ );
457
+ createFile(
458
+ path.join(projectPath, "scripts/check-ui.mjs"),
459
+ templates.checkUiScript(finalName),
460
+ );
461
+
401
462
  createFile(
402
463
  path.join(projectPath, ".fletbox/memory/MEMORY.md"),
403
464
  templates.memoryIndexMd(finalName),
@@ -418,6 +479,17 @@ export const createProject = async (projectName, rawArgs = []) => {
418
479
  print: (line) => console.log(line),
419
480
  });
420
481
 
482
+ // The machine-readable twin of the cheatsheet: one entry per widget per prop,
483
+ // each with the engine route, the default and a sample value that the types
484
+ // prove. The agent inside the app writes calls from this file.
485
+ const dump = await unpack(["--all", "--md", "--root", projectPath], { print: () => {} });
486
+ if (dump.exitCode !== 0)
487
+ console.warn(
488
+ c("yellow", "⚠️ Could not generate .fletbox/api/api.json β€” run `npm run api` after installing."),
489
+ );
490
+ else
491
+ console.log(` ${c("gray", ".fletbox/api/api.json + api.md β€” the verified surface, for the agent")}`);
492
+
421
493
  makeExecutable(path.join(projectPath, "run.sh"));
422
494
 
423
495
  console.log(
@@ -464,8 +536,10 @@ export const createProject = async (projectName, rawArgs = []) => {
464
536
  console.log(
465
537
  c(
466
538
  "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",
539
+ "CI: .github/workflows/build.yml publishes the web to GitHub Pages on every push.\n" +
540
+ ` It goes live at: ${siteUrl || `https://TU-USUARIO.github.io/${finalName}/`}\n` +
541
+ " Enable it once: Settings β†’ Pages β†’ Source: GitHub Actions. The job prints the URL.\n" +
542
+ " Android and iOS are manual: .github/workflows/build-native.yml, Run workflow.\n",
469
543
  ),
470
544
  );
471
545
  console.log(
@@ -17,7 +17,8 @@ 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
+ import { normaliseManifest, writeSpaFallback } from "../utils/pwaShell.js";
21
22
  import { INITIAL_VERSION, incrementVersion, isValidVersion } from "../utils/version.js";
22
23
 
23
24
  const __filename = fileURLToPath(import.meta.url);
@@ -57,6 +58,7 @@ const copyFolderSync = (src, dest) => {
57
58
  * ```
58
59
  * <target>/
59
60
  * index.html
61
+ * 404.html ← copy of the shell, so a refresh on a deep link serves the app
60
62
  * manifest.json
61
63
  * service-worker.js
62
64
  * run.sh
@@ -191,6 +193,20 @@ export const createBundle = async (targetArg) => {
191
193
  console.log(c("green", "βœ… Copied: src/assets (fonts + PWA icons)"));
192
194
  }
193
195
 
196
+ // The icon font is a stylesheet, not a bundle input, so it is copied as
197
+ // written. A generated app keeps it in src/assets/fonts (covered above); the
198
+ // framework root keeps it in src/fonts, which is the path its own index.html
199
+ // links β€” without this the bundled demo 404s the font and every <Icon/> is
200
+ // spelled as text.
201
+ if (fs.existsSync(path.join(srcDir, "fonts"))) {
202
+ fs.cpSync(
203
+ path.join(srcDir, "fonts"),
204
+ path.join(targetDir, "src", "fonts"),
205
+ { recursive: true },
206
+ );
207
+ console.log(c("green", "βœ… Copied: src/fonts (icon font)"));
208
+ }
209
+
194
210
  // Copy global CSS (loaded directly by index.html, not bundled by esbuild).
195
211
  const globalCss = path.join(srcDir, "styles", "global.css");
196
212
  if (fs.existsSync(globalCss)) {
@@ -264,10 +280,31 @@ export const createBundle = async (targetArg) => {
264
280
  const stampedCode = header + bundledCode;
265
281
  fs.writeFileSync(outAppJs, stampedCode);
266
282
 
283
+ // ---- Release assets -----------------------------------------------------
284
+ // `update.assets` in fletbox.config.json lists files the app serves that are
285
+ // not part of the JS bundle (markdown docs, JSON data, images). They are copied
286
+ // into the bundle AND named in config.json with a hash, so an app that updated
287
+ // its bundle by OTA can still fetch the current copy of a file that its
288
+ // packaged build predates.
289
+ const assets = collectAssets(projectRoot, resolveAssetPatterns(projectRoot));
290
+ for (const asset of assets) {
291
+ const from = path.join(projectRoot, asset.path);
292
+ const to = path.join(targetDir, asset.path);
293
+ fs.mkdirSync(path.dirname(to), { recursive: true });
294
+ fs.copyFileSync(from, to);
295
+ }
296
+ if (assets.length) {
297
+ const kb = (assets.reduce((sum, a) => sum + a.size, 0) / 1024).toFixed(0);
298
+ console.log(
299
+ c("green", `βœ… Copied: ${assets.length} OTA asset file(s) (${kb} KB)`),
300
+ );
301
+ }
302
+
267
303
  const bundleConfig = buildBundleConfig({
268
304
  projectRoot,
269
305
  bundleContent: stampedCode,
270
306
  bundlePath: "src/app.js",
307
+ assets,
271
308
  });
272
309
  fs.writeFileSync(
273
310
  path.join(targetDir, "config.json"),
@@ -355,6 +392,43 @@ export const createBundle = async (targetArg) => {
355
392
  }
356
393
  }
357
394
 
395
+ // ---- Social preview card --------------------------------------------------
396
+ // A scraper fetches the HTML and resolves the tags itself; it does not run the
397
+ // app and does not treat `./assets/hero.jpg` as the page origin does. Left
398
+ // relative, every share of the site posts a headline with no picture β€” and the
399
+ // site looks perfect to anyone who opens it, so nobody notices. The base is the
400
+ // URL OTA already derived, so the card points where the bundle is actually
401
+ // reachable, and only the built copy changes: `index.html` in the project stays
402
+ // host-agnostic so the same folder still runs on :8000 and through a tunnel.
403
+ const shareIndex = path.join(targetDir, "index.html");
404
+ if (fs.existsSync(shareIndex) && bundleConfig.update?.url) {
405
+ const html = fs.readFileSync(shareIndex, "utf8");
406
+ const stamped = absoluteShareTags(html, bundleConfig.update.url);
407
+ if (stamped !== html) {
408
+ fs.writeFileSync(shareIndex, stamped);
409
+ console.log(c("green", `βœ… share card β†’ absolute images at ${bundleConfig.update.url}`));
410
+ }
411
+ }
412
+
413
+ // ---- Installable, deep-linkable output ----------------------------------
414
+ // Both corrections belong to the deployable folder, not to the project: the
415
+ // app's own manifest.json stays host-agnostic and this bundle is what a static
416
+ // host serves. See bin/utils/pwaShell.js for the two failure modes.
417
+ const manifestFixes = normaliseManifest(targetDir);
418
+ if (manifestFixes.length) {
419
+ console.log(c("green", `βœ… manifest.json β†’ ${manifestFixes.length} correction(s)`));
420
+ for (const fix of manifestFixes) console.log(c("gray", ` ${fix}`));
421
+ }
422
+
423
+ if (writeSpaFallback(targetDir)) {
424
+ console.log(
425
+ c(
426
+ "green",
427
+ "βœ… 404.html β†’ copy of the shell (hosts without SPA rewrites now serve the app on a refresh)",
428
+ ),
429
+ );
430
+ }
431
+
358
432
  // Preserve the app source tree so the in-app "View code" feature can read it.
359
433
  for (const dir of ["modules", "screens", "database", "components"]) {
360
434
  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;