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.
- package/README.md +28 -1
- package/bin/cli.js +90 -11
- package/bin/commands/auditFix.js +682 -0
- package/bin/commands/checkApi.js +20 -6
- package/bin/commands/checkUi.js +1369 -0
- package/bin/commands/create.js +86 -12
- package/bin/commands/createBundle.js +75 -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 +91 -25
- 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/pwaShell.js +137 -0
- package/bin/utils/templates.js +1653 -471
- package/docs/README.md +2 -0
- package/docs/cli/README.md +144 -1
- package/docs/guides/README.md +2 -0
- package/docs/guides/clean-ui.md +211 -0
- package/docs/guides/minification-and-protection.md +1 -1
- package/docs/guides/online-updates.md +105 -9
- package/docs/tools/README.md +1 -1
- package/docs/tools/animation.md +1 -1
- package/docs/tools/markdown.md +3 -3
- package/docs/tools/theme.md +41 -1
- package/docs/widget/Accordion.md +4 -0
- package/docs/widget/AdMob.md +5 -1
- package/docs/widget/AdSense.md +4 -0
- package/docs/widget/AnimatedBox.md +7 -3
- package/docs/widget/AnimatedText.md +4 -0
- package/docs/widget/Audio.md +5 -1
- package/docs/widget/Avatar.md +15 -3
- package/docs/widget/Badge.md +4 -0
- package/docs/widget/Button.md +4 -0
- package/docs/widget/COMMON_PROPS.md +52 -0
- package/docs/widget/Card.md +4 -0
- package/docs/widget/Carousel.md +4 -0
- package/docs/widget/Chart.md +4 -0
- package/docs/widget/Checkbox.md +4 -0
- package/docs/widget/Chip.md +4 -0
- package/docs/widget/CircularBar.md +4 -0
- package/docs/widget/CircularChart.md +4 -0
- package/docs/widget/CodeViewer.md +4 -0
- package/docs/widget/Column.md +4 -0
- package/docs/widget/Container.md +4 -0
- package/docs/widget/DataTable.md +4 -0
- package/docs/widget/Divider.md +4 -0
- package/docs/widget/DraggBox.md +6 -2
- package/docs/widget/Dropdown.md +6 -0
- package/docs/widget/DroppBox.md +5 -1
- package/docs/widget/FloatingActionButton.md +4 -0
- package/docs/widget/GridView.md +4 -0
- package/docs/widget/Icon.md +4 -0
- package/docs/widget/Image.md +14 -3
- package/docs/widget/Input.md +4 -0
- package/docs/widget/Inspector.md +2 -2
- package/docs/widget/InstallButton.md +4 -0
- package/docs/widget/ListTile.md +4 -0
- package/docs/widget/ListView.md +4 -0
- package/docs/widget/Maps.md +191 -0
- package/docs/widget/Markdown.md +4 -0
- package/docs/widget/MatrixRain.md +4 -0
- package/docs/widget/Pagination.md +4 -0
- package/docs/widget/ParallaxBox.md +4 -0
- package/docs/widget/ProgressBar.md +4 -0
- package/docs/widget/QRCode.md +4 -0
- package/docs/widget/README.md +12 -10
- package/docs/widget/Radio.md +5 -1
- package/docs/widget/Rating.md +4 -0
- package/docs/widget/Row.md +4 -0
- package/docs/widget/Skeleton.md +4 -0
- package/docs/widget/Slider.md +4 -0
- package/docs/widget/Stack.md +4 -0
- package/docs/widget/Stepper.md +4 -0
- package/docs/widget/Switch.md +4 -0
- package/docs/widget/Tabs.md +5 -5
- package/docs/widget/Text.md +4 -0
- package/docs/widget/Tooltip.md +14 -13
- package/docs/widget/TreeView.md +4 -0
- package/docs/widget/Video.md +6 -2
- package/package.json +2 -2
- package/src/animations/AnimatedBox.js +4 -1
- package/src/animations/MatrixRain.js +3 -3
- package/src/core/hmr-client.js +129 -34
- package/src/index.d.ts +195 -7
- package/src/index.js +2 -0
- package/src/navigations/AppBar.js +22 -16
- package/src/navigations/BottomNavigation.js +8 -13
- package/src/navigations/CollapsibleSideBar.js +30 -8
- package/src/navigations/Drawer.js +25 -15
- package/src/navigations/DrawerItem.js +3 -1
- package/src/navigations/Scaffold.js +26 -12
- package/src/navigations/Tabs.js +104 -59
- package/src/tools/color.js +54 -0
- package/src/tools/index.js +9 -1
- package/src/tools/scale.js +41 -0
- package/src/tools/shadow.js +20 -0
- package/src/utils/index.js +1 -0
- package/src/utils/readableInk.js +19 -0
- package/src/utils/syntaxHighlight.js +78 -10
- package/src/utils/themes.js +217 -54
- package/src/utils/units.js +33 -11
- package/src/widget-factory/effects.js +56 -5
- package/src/widget-factory/processProps.js +10 -1
- package/src/widget-factory/remTool.js +15 -29
- package/src/widget-factory/stackPosition.js +18 -3
- package/src/widget-factory/translateProps.js +60 -1
- package/src/widgets/Accordion.js +20 -23
- package/src/widgets/AdMob.js +7 -2
- package/src/widgets/Avatar.js +236 -133
- package/src/widgets/Badge.js +3 -2
- package/src/widgets/BottomSheet.js +14 -13
- package/src/widgets/Button.js +69 -30
- package/src/widgets/Card.js +6 -2
- package/src/widgets/Carousel.js +12 -10
- package/src/widgets/Chart.js +28 -5
- package/src/widgets/Checkbox.js +76 -18
- package/src/widgets/Chip.js +31 -10
- package/src/widgets/CircularBar.js +7 -7
- package/src/widgets/CircularChart.js +26 -8
- package/src/widgets/CodeViewer.js +93 -20
- package/src/widgets/Container.js +13 -0
- package/src/widgets/DataTable.js +17 -11
- package/src/widgets/DraggBox.js +2 -1
- package/src/widgets/Dropdown.js +59 -14
- package/src/widgets/DroppBox.js +4 -2
- package/src/widgets/FloatingActionButton.js +13 -7
- package/src/widgets/Icon.js +3 -1
- package/src/widgets/Image.js +102 -3
- package/src/widgets/Input.js +30 -23
- package/src/widgets/Inspector.js +54 -1
- package/src/widgets/InstallButton.js +6 -4
- package/src/widgets/ListTile.js +4 -3
- package/src/widgets/ListView.js +30 -12
- package/src/widgets/Maps.js +1495 -0
- package/src/widgets/Markdown.js +37 -26
- package/src/widgets/Modal.js +14 -11
- package/src/widgets/Pagination.js +16 -11
- package/src/widgets/ProgressBar.js +6 -4
- package/src/widgets/Radio.js +73 -15
- package/src/widgets/Rating.js +24 -6
- package/src/widgets/Skeleton.js +31 -31
- package/src/widgets/Slider.js +29 -18
- package/src/widgets/SnackBar.js +35 -39
- package/src/widgets/Stepper.js +13 -9
- package/src/widgets/Switch.js +59 -18
- package/src/widgets/Tooltip.js +24 -12
- package/src/widgets/TreeView.js +27 -15
- package/src/widgets/index.js +1 -0
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 };
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
468
|
-
|
|
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;
|