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.
- package/README.md +28 -1
- package/bin/cli.js +85 -18
- package/bin/commands/auditFix.js +682 -0
- package/bin/commands/checkApi.js +7 -5
- package/bin/commands/create.js +40 -10
- package/bin/commands/createBundle.js +54 -1
- package/bin/commands/images.js +349 -0
- package/bin/commands/pruneAssets.js +170 -0
- package/bin/commands/release.js +34 -1
- package/bin/commands/runCloudflare.js +47 -9
- package/bin/commands/runServer.js +81 -24
- package/bin/commands/toPng.js +346 -0
- package/bin/commands/unpack.js +1173 -0
- package/bin/ota/loader.js +90 -0
- package/bin/utils/appSources.js +101 -0
- package/bin/utils/bundleConfig.js +166 -0
- package/bin/utils/dtsProps.js +147 -0
- package/bin/utils/imageTools.js +271 -0
- package/bin/utils/images/bmp.js +99 -0
- package/bin/utils/images/gif.js +244 -0
- package/bin/utils/images/index.js +163 -0
- package/bin/utils/images/jpeg.js +643 -0
- package/bin/utils/images/png.js +486 -0
- package/bin/utils/images/unsupported.js +29 -0
- package/bin/utils/neutralise.js +109 -6
- package/bin/utils/templates.js +307 -64
- package/docs/cli/README.md +82 -0
- package/docs/guides/online-updates.md +57 -9
- package/docs/widget/Accordion.md +1 -0
- package/docs/widget/AdMob.md +1 -0
- package/docs/widget/AdSense.md +1 -0
- package/docs/widget/AnimatedBox.md +1 -0
- package/docs/widget/AnimatedText.md +1 -0
- package/docs/widget/Audio.md +2 -1
- package/docs/widget/Avatar.md +12 -3
- package/docs/widget/Badge.md +1 -0
- package/docs/widget/Button.md +1 -0
- package/docs/widget/Card.md +1 -0
- package/docs/widget/Carousel.md +1 -0
- package/docs/widget/Chart.md +1 -0
- package/docs/widget/Checkbox.md +1 -0
- package/docs/widget/Chip.md +1 -0
- package/docs/widget/CircularBar.md +1 -0
- package/docs/widget/CircularChart.md +1 -0
- package/docs/widget/CodeViewer.md +1 -0
- package/docs/widget/Column.md +1 -0
- package/docs/widget/Container.md +1 -0
- package/docs/widget/DataTable.md +1 -0
- package/docs/widget/Divider.md +1 -0
- package/docs/widget/DraggBox.md +3 -2
- package/docs/widget/Dropdown.md +3 -0
- package/docs/widget/DroppBox.md +2 -1
- package/docs/widget/FloatingActionButton.md +1 -0
- package/docs/widget/GridView.md +1 -0
- package/docs/widget/Icon.md +1 -0
- package/docs/widget/Image.md +11 -3
- package/docs/widget/Input.md +1 -0
- package/docs/widget/InstallButton.md +1 -0
- package/docs/widget/ListTile.md +1 -0
- package/docs/widget/ListView.md +1 -0
- package/docs/widget/Maps.md +188 -0
- package/docs/widget/Markdown.md +1 -0
- package/docs/widget/MatrixRain.md +1 -0
- package/docs/widget/Pagination.md +1 -0
- package/docs/widget/ParallaxBox.md +1 -0
- package/docs/widget/ProgressBar.md +1 -0
- package/docs/widget/QRCode.md +1 -0
- package/docs/widget/README.md +12 -10
- package/docs/widget/Radio.md +1 -0
- package/docs/widget/Rating.md +1 -0
- package/docs/widget/Row.md +1 -0
- package/docs/widget/Skeleton.md +1 -0
- package/docs/widget/Slider.md +1 -0
- package/docs/widget/Stack.md +1 -0
- package/docs/widget/Stepper.md +1 -0
- package/docs/widget/Switch.md +1 -0
- package/docs/widget/Text.md +1 -0
- package/docs/widget/Tooltip.md +11 -13
- package/docs/widget/TreeView.md +1 -0
- package/docs/widget/Video.md +3 -2
- package/package.json +2 -2
- package/src/core/hmr-client.js +129 -34
- package/src/index.d.ts +132 -0
- package/src/index.js +1 -0
- package/src/navigations/AppBar.js +15 -4
- package/src/navigations/Drawer.js +9 -1
- package/src/widget-factory/translateProps.js +32 -1
- package/src/widgets/Avatar.js +228 -131
- package/src/widgets/Container.js +13 -0
- package/src/widgets/Dropdown.js +33 -1
- package/src/widgets/Image.js +102 -3
- package/src/widgets/Maps.js +1495 -0
- package/src/widgets/Tooltip.js +12 -2
- package/src/widgets/index.js +1 -0
package/bin/commands/checkApi.js
CHANGED
|
@@ -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;
|
package/bin/commands/create.js
CHANGED
|
@@ -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
|
-
|
|
60
|
+
const arg = args[i];
|
|
61
|
+
if (arg === "--adaptive") {
|
|
59
62
|
template = "adaptive";
|
|
60
|
-
} else if (
|
|
63
|
+
} else if (arg === "--blank") {
|
|
61
64
|
template = "blank";
|
|
62
|
-
} else if (
|
|
65
|
+
} else if (arg === "--full") {
|
|
63
66
|
template = "full";
|
|
64
|
-
} else if (
|
|
67
|
+
} else if (arg === "--sidebar") {
|
|
65
68
|
template = "sidebar";
|
|
66
|
-
} else if (
|
|
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 (!
|
|
72
|
-
|
|
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
|
|
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
|
|
468
|
-
|
|
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;
|