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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file bin/commands/pruneAssets.js
|
|
3
|
+
* @description `flet-box prune-assets` β deletes the images a generated app never
|
|
4
|
+
* names.
|
|
5
|
+
*
|
|
6
|
+
* This exists because of what `flet-box create` ships: a demo asset library
|
|
7
|
+
* (dishes, drinks, gallery photos) that no screen of a fresh app imports, which
|
|
8
|
+
* weighs the repository down and β worse β gets copied into any release whose
|
|
9
|
+
* asset glob is broad enough to catch it. The failure is silent: the site works,
|
|
10
|
+
* the bundle is 1.5 GB, and the visitor on a bad connection never sees the
|
|
11
|
+
* difference because those files were never requested.
|
|
12
|
+
*
|
|
13
|
+
* A file is kept when its path or its exact filename appears in the app's own
|
|
14
|
+
* sources. `www/` is deliberately not a source: it is generated output, so a name
|
|
15
|
+
* that only appears there is a stale bundle, not a consumer.
|
|
16
|
+
*
|
|
17
|
+
* Deletion is the one thing this command will not do unasked: it reports, and
|
|
18
|
+
* `--apply` deletes. Everything it removes is a tracked file in a generated
|
|
19
|
+
* project, so `git checkout -- assets/` puts it back.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { c, section, dot } from "../utils/colors.js";
|
|
25
|
+
import { readAppSources, references, protectedMaster } from "../utils/appSources.js";
|
|
26
|
+
|
|
27
|
+
const USAGE = `Usage: flet-box prune-assets [dir] [options]
|
|
28
|
+
|
|
29
|
+
dir Asset folder to clean (default: ./assets)
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
--apply Actually delete. Without it this is a report only.
|
|
33
|
+
--root <dir> Project root the sources are read from (default: .)
|
|
34
|
+
--keep <name> Extra filename to protect, repeatable
|
|
35
|
+
--quiet Only the summary line
|
|
36
|
+
--help This page`;
|
|
37
|
+
|
|
38
|
+
const IMAGE_FILE = /\.(png|jpe?g|webp|gif|svg|ico|avif)$/i;
|
|
39
|
+
const SKIPPED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".cache", "www", "vendor", "ios", "android"]);
|
|
40
|
+
|
|
41
|
+
const size = (bytes) =>
|
|
42
|
+
bytes >= 1024 * 1024
|
|
43
|
+
? `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
44
|
+
: bytes >= 1024
|
|
45
|
+
? `${Math.round(bytes / 1024)} KB`
|
|
46
|
+
: `${bytes} B`;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} dir
|
|
50
|
+
* @returns {string[]} Image files under it, absolute, sorted.
|
|
51
|
+
*/
|
|
52
|
+
const collectImages = (dir) => {
|
|
53
|
+
const found = [];
|
|
54
|
+
const walk = (current) => {
|
|
55
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
56
|
+
const full = path.join(current, entry.name);
|
|
57
|
+
if (entry.isDirectory()) {
|
|
58
|
+
if (!SKIPPED_DIRS.has(entry.name)) walk(full);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (entry.isFile() && IMAGE_FILE.test(entry.name)) found.push(full);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
if (fs.existsSync(dir)) walk(dir);
|
|
65
|
+
return found.sort();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Runs the report, and the deletion if asked.
|
|
70
|
+
*
|
|
71
|
+
* @param {string[]} args - CLI arguments after the command name.
|
|
72
|
+
* @returns {Promise<{exitCode: number}>}
|
|
73
|
+
*/
|
|
74
|
+
export const pruneAssets = async (args = []) => {
|
|
75
|
+
const has = (flag) => args.includes(flag);
|
|
76
|
+
const valueOf = (flag) => {
|
|
77
|
+
const i = args.indexOf(flag);
|
|
78
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (has("--help") || has("-h")) {
|
|
82
|
+
console.log(USAGE);
|
|
83
|
+
return { exitCode: 0 };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const apply = has("--apply");
|
|
87
|
+
const quiet = has("--quiet");
|
|
88
|
+
const root = path.resolve(valueOf("--root") || ".");
|
|
89
|
+
const handKeep = new Set(args.filter((_, i) => args[i - 1] === "--keep"));
|
|
90
|
+
|
|
91
|
+
// Resolved against --root, not against the shell's cwd: a caller that names a
|
|
92
|
+
// project root means that project's assets, and the two arguments disagreeing
|
|
93
|
+
// is how a cleanup run ends up pointing at someone else's folder.
|
|
94
|
+
const positional = args.filter((a, i) => !a.startsWith("--") && !["--root", "--keep"].includes(args[i - 1]));
|
|
95
|
+
const dir = path.resolve(root, positional[0] || "assets");
|
|
96
|
+
if (!dir.startsWith(root)) {
|
|
97
|
+
console.log(c("red", `Refusing: ${dir} is outside the project root ${root}.`));
|
|
98
|
+
return { exitCode: 1 };
|
|
99
|
+
}
|
|
100
|
+
if (!fs.existsSync(dir)) {
|
|
101
|
+
console.log(c("red", `No such folder: ${path.relative(root, dir) || dir}`));
|
|
102
|
+
return { exitCode: 1 };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const text = readAppSources(root);
|
|
106
|
+
const images = collectImages(dir);
|
|
107
|
+
const dead = [];
|
|
108
|
+
const alive = [];
|
|
109
|
+
const protectedByHand = [];
|
|
110
|
+
for (const file of images) {
|
|
111
|
+
const rel = path.relative(root, file).split(path.sep).join("/");
|
|
112
|
+
const master = protectedMaster(rel);
|
|
113
|
+
if (master) {
|
|
114
|
+
alive.push(file);
|
|
115
|
+
protectedByHand.push({ rel, why: master.why });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (handKeep.has(rel) || handKeep.has(path.basename(file))) {
|
|
119
|
+
alive.push(file);
|
|
120
|
+
protectedByHand.push({ rel, why: "kept by hand" });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
(references(text, rel) ? alive : dead).push(file);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Measured before anything is deleted: the report that runs after `--apply`
|
|
127
|
+
// would otherwise stat files that no longer exist and crash on its own summary.
|
|
128
|
+
const sizes = new Map(images.map((f) => [f, fs.statSync(f).size]));
|
|
129
|
+
const bytesOf = (list) => list.reduce((s, f) => s + sizes.get(f), 0);
|
|
130
|
+
|
|
131
|
+
if (!quiet) {
|
|
132
|
+
console.log(section("π§Ή", `prune-assets β ${path.relative(root, dir) || "."}`));
|
|
133
|
+
if (!dead.length) console.log(` ${dot("#34d399")} every one of the ${images.length} images here is referenced.`);
|
|
134
|
+
for (const file of dead.slice(0, 40)) {
|
|
135
|
+
console.log(` ${dot("#ef4444")} ${path.relative(root, file)} β ${c("dim", size(sizes.get(file)))}`);
|
|
136
|
+
}
|
|
137
|
+
if (dead.length > 40) console.log(c("gray", ` β¦ ${dead.length - 40} more`));
|
|
138
|
+
for (const { rel, why } of protectedByHand) {
|
|
139
|
+
console.log(` ${dot("#34d399")} ${rel} β kept: ${why}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const summary = `${images.length} images Β· ${alive.length} referenced (${size(bytesOf(alive))}) Β· ${dead.length} unreferenced (${size(bytesOf(dead))})`;
|
|
144
|
+
if (!dead.length) {
|
|
145
|
+
console.log(`\n ${summary}\n`);
|
|
146
|
+
return { exitCode: 0 };
|
|
147
|
+
}
|
|
148
|
+
if (!apply) {
|
|
149
|
+
console.log(`\n ${summary}`);
|
|
150
|
+
console.log(c("yellow", " dry run β nothing deleted. Add --apply to remove them.\n"));
|
|
151
|
+
return { exitCode: 0 };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const emptied = new Set();
|
|
155
|
+
for (const file of dead) {
|
|
156
|
+
fs.rmSync(file, { force: true });
|
|
157
|
+
emptied.add(path.dirname(file));
|
|
158
|
+
}
|
|
159
|
+
// A directory the purge emptied is dead weight in the repo and in every future
|
|
160
|
+
// glob, so it goes with its files.
|
|
161
|
+
for (const d of [...emptied].sort().reverse()) {
|
|
162
|
+
if (d !== dir && fs.existsSync(d) && fs.readdirSync(d).length === 0) fs.rmSync(d, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
console.log(`\n ${summary}`);
|
|
165
|
+
console.log(c("green", ` deleted ${dead.length} files (${size(bytesOf(dead))}).`));
|
|
166
|
+
console.log(c("gray", " Undo while the delete is uncommitted: git checkout -- assets/\n"));
|
|
167
|
+
return { exitCode: 0 };
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export default pruneAssets;
|
package/bin/commands/release.js
CHANGED
|
@@ -27,6 +27,7 @@ import fs from "fs";
|
|
|
27
27
|
import path from "path";
|
|
28
28
|
import { c, banner, gradient } from "../utils/colors.js";
|
|
29
29
|
import { createBundle } from "./createBundle.js";
|
|
30
|
+
import { resolveUpdateUrl } from "../utils/bundleConfig.js";
|
|
30
31
|
import { INITIAL_VERSION, incrementVersion, isValidVersion } from "../utils/version.js";
|
|
31
32
|
|
|
32
33
|
/**
|
|
@@ -183,7 +184,39 @@ export const release = async (argv = []) => {
|
|
|
183
184
|
c("gray", ` β¬οΈ Publish it: ${gradient(`git push && git push origin ${tag}`, "#22d3ee", "#38bdf8")}`),
|
|
184
185
|
);
|
|
185
186
|
}
|
|
187
|
+
|
|
188
|
+
// The live URL is the first thing people look for after a release, and the
|
|
189
|
+
// Actions run page hides it (it appears under the environment chip, never in
|
|
190
|
+
// the log) β so print the exact address this bundle was built for, plus the
|
|
191
|
+
// two steps that decide whether it is serving yet.
|
|
192
|
+
const siteUrl = resolveUpdateUrl(projectRoot);
|
|
193
|
+
console.log(
|
|
194
|
+
c("gray", " π Web: ") +
|
|
195
|
+
(siteUrl
|
|
196
|
+
? gradient(siteUrl, "#22d3ee", "#38bdf8")
|
|
197
|
+
: c("yellow", "unknown β no origin remote set yet")),
|
|
198
|
+
);
|
|
186
199
|
console.log(
|
|
187
|
-
c(
|
|
200
|
+
c(
|
|
201
|
+
"dim",
|
|
202
|
+
` Deploys: every push to main publishes www/ to GitHub Pages, so the\n` +
|
|
203
|
+
` site is already live. Only the OTA feed is gated: the build\n` +
|
|
204
|
+
` keeps www/config.json when its commit carries a v* tag, and\n` +
|
|
205
|
+
` drops it otherwise, so an ordinary push never offers itself\n` +
|
|
206
|
+
` to installed apps.\n` +
|
|
207
|
+
` Release: a tag push cannot deploy β the github-pages environment\n` +
|
|
208
|
+
` refuses runs that do not start from the default branch. So\n` +
|
|
209
|
+
` after ${tag} exists, run the workflow once more by hand\n` +
|
|
210
|
+
` (Actions β Deploy β Run workflow) to publish the feed.\n` +
|
|
211
|
+
` Where: Actions β the run β Deploy to Pages β the link under the\n` +
|
|
212
|
+
` "github-pages" environment box. Not in the log.\n` +
|
|
213
|
+
` Setup: Settings β Pages β Source = "GitHub Actions", or even a\n` +
|
|
214
|
+
` green build leaves the old site standing.`,
|
|
215
|
+
),
|
|
188
216
|
);
|
|
217
|
+
if (push) {
|
|
218
|
+
console.log(
|
|
219
|
+
c("gray", ` β³ CI is publishing ${tag} now β open the run to follow it.`),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
189
222
|
};
|
|
@@ -1,21 +1,34 @@
|
|
|
1
1
|
// bin/commands/runCloudflare.js
|
|
2
2
|
import { spawn, spawnSync } from "child_process";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
3
5
|
import { c, banner, divider } from "../utils/colors.js";
|
|
4
6
|
import { spinner } from "../utils/spinner.js";
|
|
7
|
+
import { createBundle } from "./createBundle.js";
|
|
5
8
|
import { runDevServer } from "./runServer.js";
|
|
6
9
|
|
|
7
10
|
const TUNNEL_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
|
-
*
|
|
13
|
+
* Builds the production bundle and exposes it through a free Cloudflare quick
|
|
11
14
|
* tunnel (no account required), printing the public HTTPS link to the console.
|
|
12
15
|
*
|
|
13
|
-
*
|
|
16
|
+
* The bundle is the thing on the tunnel, never the source tree: `index.html`
|
|
17
|
+
* maps the bare `flet-box` specifier to `./node_modules/...`, so serving `src/`
|
|
18
|
+
* publicly only works once `npm install` has run on that machine, and a fresh
|
|
19
|
+
* clone, a phone on 4G or a deep link showed a module-resolution error instead
|
|
20
|
+
* of the app. `createBundle` inlines the framework, so what the visitor gets is
|
|
21
|
+
* exactly what would be deployed.
|
|
22
|
+
*
|
|
23
|
+
* @param {{ target?: string, port?: number, logRequests?: boolean }} [options]
|
|
14
24
|
* @returns {Promise<void>}
|
|
15
25
|
*/
|
|
16
|
-
export const runCloudflareDemo = async (
|
|
17
|
-
|
|
18
|
-
|
|
26
|
+
export const runCloudflareDemo = async ({
|
|
27
|
+
target = "www",
|
|
28
|
+
port = 8000,
|
|
29
|
+
logRequests = false,
|
|
30
|
+
} = {}) => {
|
|
31
|
+
// Check the tunnel binary before spending a build on a command that cannot run.
|
|
19
32
|
const probe = spawnSync("cloudflared", ["--version"], { encoding: "utf-8" });
|
|
20
33
|
if (probe.error) {
|
|
21
34
|
console.error(c("red", "β cloudflared is not installed"));
|
|
@@ -27,11 +40,36 @@ export const runCloudflareDemo = async (options = {}) => {
|
|
|
27
40
|
process.exit(1);
|
|
28
41
|
}
|
|
29
42
|
|
|
30
|
-
console.log(banner("βοΈ FletBox Cloudflare Demo",
|
|
43
|
+
console.log(banner("βοΈ FletBox Cloudflare Demo", `bundle ${target}/ β public HTTPS tunnel`));
|
|
44
|
+
|
|
45
|
+
await createBundle(target);
|
|
46
|
+
|
|
47
|
+
// The bundle's `update.url` points at the production OTA source (the GitHub
|
|
48
|
+
// Pages site). A tunnel is not that origin, so every visitor's first action was
|
|
49
|
+
// a cross-origin fetch that the browser logged as a CORS error. Same-origin is
|
|
50
|
+
// both quieter and honest: the demo checks itself and reports "up to date".
|
|
51
|
+
try {
|
|
52
|
+
const configPath = path.join(process.cwd(), target, "config.json");
|
|
53
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
54
|
+
if (cfg?.update) {
|
|
55
|
+
cfg.update.url = "/";
|
|
56
|
+
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n");
|
|
57
|
+
}
|
|
58
|
+
} catch (e) {
|
|
59
|
+
console.warn(c("yellow", `β οΈ Could not retarget the OTA url: ${e.message}`));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log(
|
|
63
|
+
`\n ${c("gray", `Bundle built Β· serving ${target}/ (SPA, hot reload OFF)`)}\n`,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// Serve from inside the bundle so every relative path the visitor requests
|
|
67
|
+
// resolves against the build output.
|
|
68
|
+
process.chdir(target);
|
|
31
69
|
|
|
32
70
|
await runDevServer({
|
|
33
|
-
spaMode:
|
|
34
|
-
hotReload:
|
|
71
|
+
spaMode: true,
|
|
72
|
+
hotReload: false,
|
|
35
73
|
port,
|
|
36
74
|
askPort: false,
|
|
37
75
|
logRequests,
|
|
@@ -53,7 +91,7 @@ export const runCloudflareDemo = async (options = {}) => {
|
|
|
53
91
|
console.log(c("brightCyan", " π Public link (open this):"));
|
|
54
92
|
console.log(` ${c("bold", c("brightGreen", url))}`);
|
|
55
93
|
console.log("");
|
|
56
|
-
console.log(c("gray", ` Local: http://localhost:${port}
|
|
94
|
+
console.log(c("gray", ` Local: http://localhost:${port} Β· ${target}/`));
|
|
57
95
|
console.log(
|
|
58
96
|
c("gray", " The tunnel stays alive while this process runs. Ctrl+C to stop."),
|
|
59
97
|
);
|
|
@@ -3,7 +3,7 @@ import http from "http";
|
|
|
3
3
|
import fs from "fs";
|
|
4
4
|
import path from "path";
|
|
5
5
|
import readline from "readline";
|
|
6
|
-
import { WebSocketServer } from "ws";
|
|
6
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
7
7
|
import chokidar from "chokidar";
|
|
8
8
|
import { c, banner, rainbow, dot, typewriter } from "../utils/colors.js";
|
|
9
9
|
import { spinner } from "../utils/spinner.js";
|
|
@@ -59,7 +59,12 @@ export const runDevServer = async (options = {}) => {
|
|
|
59
59
|
let watcher = null;
|
|
60
60
|
let PORT = Number(port) || 8000;
|
|
61
61
|
|
|
62
|
-
console.log(
|
|
62
|
+
console.log(
|
|
63
|
+
banner(
|
|
64
|
+
"π‘ FletBox Server",
|
|
65
|
+
`${spaMode ? "SPA" : "Static"}${hotReload ? " + Hot Reload" : ""}`,
|
|
66
|
+
),
|
|
67
|
+
);
|
|
63
68
|
|
|
64
69
|
// Interactive port: if no --port was passed and there is a TTY, ask
|
|
65
70
|
if (options.askPort && process.stdin.isTTY) {
|
|
@@ -80,6 +85,8 @@ export const runDevServer = async (options = {}) => {
|
|
|
80
85
|
".json": "application/json",
|
|
81
86
|
".png": "image/png",
|
|
82
87
|
".jpg": "image/jpeg",
|
|
88
|
+
".jpeg": "image/jpeg",
|
|
89
|
+
".webp": "image/webp",
|
|
83
90
|
".gif": "image/gif",
|
|
84
91
|
".svg": "image/svg+xml",
|
|
85
92
|
".ico": "image/x-icon",
|
|
@@ -89,8 +96,11 @@ export const runDevServer = async (options = {}) => {
|
|
|
89
96
|
|
|
90
97
|
const broadcast = (message) => {
|
|
91
98
|
const msg = typeof message === "string" ? message : JSON.stringify(message);
|
|
99
|
+
// `readyState` is a `WebSocket` constant, not a `WebSocketServer` one:
|
|
100
|
+
// comparing against `WebSocketServer.OPEN` (undefined) matched no client and
|
|
101
|
+
// silently disabled hot reload for everybody.
|
|
92
102
|
clients.forEach((client) => {
|
|
93
|
-
if (client.readyState ===
|
|
103
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
94
104
|
client.send(msg);
|
|
95
105
|
}
|
|
96
106
|
});
|
|
@@ -144,7 +154,11 @@ export const runDevServer = async (options = {}) => {
|
|
|
144
154
|
url = req.url;
|
|
145
155
|
}
|
|
146
156
|
|
|
147
|
-
|
|
157
|
+
// The query is part of the request, not of the file name: `?theme=dark`
|
|
158
|
+
// and the CSS hot-swap's `?t=` cache-buster were being resolved on disk
|
|
159
|
+
// and answered with a 404.
|
|
160
|
+
const pathname = url.split("?")[0].split("#")[0];
|
|
161
|
+
const safePath = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, "");
|
|
148
162
|
let filePath = path.join(
|
|
149
163
|
projectRoot,
|
|
150
164
|
safePath === "/" ? "index.html" : safePath,
|
|
@@ -153,9 +167,11 @@ export const runDevServer = async (options = {}) => {
|
|
|
153
167
|
|
|
154
168
|
fs.access(filePath, fs.constants.F_OK, (err) => {
|
|
155
169
|
// SPA fallback: if the file doesn't exist and has no extension, serve index.html
|
|
170
|
+
let isFallback = false;
|
|
156
171
|
if (err && spaMode && (!ext || ext === "")) {
|
|
157
172
|
filePath = path.join(projectRoot, "index.html");
|
|
158
173
|
ext = ".html";
|
|
174
|
+
isFallback = true;
|
|
159
175
|
}
|
|
160
176
|
|
|
161
177
|
fs.readFile(filePath, (err2, content) => {
|
|
@@ -170,6 +186,23 @@ export const runDevServer = async (options = {}) => {
|
|
|
170
186
|
|
|
171
187
|
const contentType = mimeTypes[ext] || "text/plain";
|
|
172
188
|
|
|
189
|
+
// A rewritten deep link is served from `index.html`, but the browser
|
|
190
|
+
// still resolves `./loader.js` against `/demo/forms/`, so the page came
|
|
191
|
+
// up blank on every shared link. Anchoring the page at the served root
|
|
192
|
+
// keeps the app's relative URLs working whatever the route is.
|
|
193
|
+
//
|
|
194
|
+
// The root document needs the same anchor: a client router pushes
|
|
195
|
+
// `/widget/Button` without a reload, and from then on the browser
|
|
196
|
+
// re-resolves a relative `<link rel="icon">` against that path. The
|
|
197
|
+
// favicon then 404s on every in-app navigation.
|
|
198
|
+
if (spaMode && ext === ".html" && (isFallback || safePath === "/")) {
|
|
199
|
+
let html = content.toString("utf-8");
|
|
200
|
+
if (!/<base [^>]*href=/i.test(html)) {
|
|
201
|
+
html = html.replace(/<head(\s[^>]*)?>/i, (m) => `${m}\n<base href="/">`);
|
|
202
|
+
}
|
|
203
|
+
content = html;
|
|
204
|
+
}
|
|
205
|
+
|
|
173
206
|
if (logRequests) {
|
|
174
207
|
const sizeKB = (content.length / 1024).toFixed(1);
|
|
175
208
|
console.log(
|
|
@@ -195,29 +228,40 @@ export const runDevServer = async (options = {}) => {
|
|
|
195
228
|
c("blue", "π§ Framework mode: using direct HMR import"),
|
|
196
229
|
);
|
|
197
230
|
} else {
|
|
198
|
-
//
|
|
231
|
+
// A static import, not `import('flet-box').then(...)`: the client
|
|
232
|
+
// restores the state snapshot and that must run before the app's
|
|
233
|
+
// own `useState` call. A `.then()` is a microtask later, so the
|
|
234
|
+
// app would already have booted with defaults.
|
|
199
235
|
hmrScript = `
|
|
200
236
|
<script type="module">
|
|
201
|
-
import
|
|
202
|
-
|
|
203
|
-
initHMR();
|
|
204
|
-
}
|
|
205
|
-
}).catch(e => console.warn('HMR not available:', e));
|
|
237
|
+
import { initHMR } from 'flet-box';
|
|
238
|
+
initHMR();
|
|
206
239
|
</script>
|
|
207
240
|
`;
|
|
208
241
|
}
|
|
209
242
|
|
|
210
243
|
if (!html.includes("initHMR")) {
|
|
211
|
-
|
|
244
|
+
// Inserted before the app's module tag, so module execution order
|
|
245
|
+
// puts the restore first; `</head>` and `</body>` are the fallbacks
|
|
246
|
+
// for a page that has no module script yet.
|
|
247
|
+
const anchor = /<script[^>]*type=["']module["'][^>]*>/i.exec(html);
|
|
248
|
+
if (anchor)
|
|
249
|
+
html = html.slice(0, anchor.index) + hmrScript + html.slice(anchor.index);
|
|
250
|
+
else if (html.includes("</head>"))
|
|
251
|
+
html = html.replace("</head>", `${hmrScript}</head>`);
|
|
252
|
+
else if (html.includes("</body>"))
|
|
212
253
|
html = html.replace("</body>", `${hmrScript}</body>`);
|
|
213
|
-
|
|
214
|
-
html += hmrScript;
|
|
215
|
-
}
|
|
254
|
+
else html += hmrScript;
|
|
216
255
|
content = html;
|
|
217
256
|
}
|
|
218
257
|
}
|
|
219
258
|
|
|
220
|
-
|
|
259
|
+
// A dev server that lets the browser cache a module is a hot reload
|
|
260
|
+
// that "does nothing": the page reloads and re-gets the old code.
|
|
261
|
+
res.writeHead(200, {
|
|
262
|
+
"Content-Type": contentType,
|
|
263
|
+
"Cache-Control": "no-store, no-cache, must-revalidate",
|
|
264
|
+
});
|
|
221
265
|
res.end(content);
|
|
222
266
|
});
|
|
223
267
|
});
|
|
@@ -296,11 +340,21 @@ export const runDevServer = async (options = {}) => {
|
|
|
296
340
|
// WebSocket para HMR
|
|
297
341
|
if (hotReload) {
|
|
298
342
|
const wss = new WebSocketServer({ server });
|
|
299
|
-
|
|
343
|
+
|
|
344
|
+
// A browser tab that vanishes mid-flight (a tunnel closing an idle socket is
|
|
345
|
+
// enough) makes `ws` emit "error". With no listener that is an uncaught
|
|
346
|
+
// exception and the dev server dies with the tab β the usual reason hot
|
|
347
|
+
// reload "crashes" during a live demo.
|
|
348
|
+
wss.on("error", (err) => {
|
|
349
|
+
console.warn(c("yellow", `β οΈ HMR socket error: ${err && err.message}`));
|
|
350
|
+
});
|
|
300
351
|
|
|
301
352
|
wss.on("connection", (ws) => {
|
|
302
353
|
clients.add(ws);
|
|
303
|
-
console.log(c("green", "π HMR client connected"));
|
|
354
|
+
if (logRequests) console.log(c("green", "π HMR client connected"));
|
|
355
|
+
ws.on("error", () => {
|
|
356
|
+
// The peer is already gone; `close` follows and drops it from the set.
|
|
357
|
+
});
|
|
304
358
|
ws.send(JSON.stringify({ type: "request-modules" }));
|
|
305
359
|
ws.on("message", (data) => {
|
|
306
360
|
try {
|
|
@@ -317,16 +371,16 @@ export const runDevServer = async (options = {}) => {
|
|
|
317
371
|
});
|
|
318
372
|
ws.on("close", () => {
|
|
319
373
|
clients.delete(ws);
|
|
320
|
-
console.log(c("gray", "π HMR client disconnected"));
|
|
374
|
+
if (logRequests) console.log(c("gray", "π HMR client disconnected"));
|
|
321
375
|
});
|
|
322
376
|
});
|
|
323
377
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
378
|
+
// chokidar 4 removed glob support: `src/**/*.js` is watched as a literal
|
|
379
|
+
// path that never exists, so the watcher silently reported "Watchingβ¦" and
|
|
380
|
+
// never fired once. Watch the real roots and filter by extension instead.
|
|
381
|
+
const watchPaths = ["src", "index.html"];
|
|
382
|
+
const isWatched = (filePath) =>
|
|
383
|
+
/\.(js|mjs|css)$/.test(filePath) || filePath === "index.html";
|
|
330
384
|
const moduleVersions = new Map();
|
|
331
385
|
|
|
332
386
|
watcher = chokidar.watch(watchPaths, {
|
|
@@ -334,22 +388,32 @@ export const runDevServer = async (options = {}) => {
|
|
|
334
388
|
persistent: true,
|
|
335
389
|
cwd: projectRoot,
|
|
336
390
|
ignoreInitial: true,
|
|
391
|
+
// An editor saves in two or three writes (write, rename, chmod). Firing on
|
|
392
|
+
// each one reloads the page several times per Ctrl+S.
|
|
393
|
+
awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 25 },
|
|
337
394
|
});
|
|
338
395
|
|
|
396
|
+
const lastSent = new Map();
|
|
397
|
+
|
|
339
398
|
watcher.on("change", async (filePath) => {
|
|
399
|
+
if (!isWatched(filePath)) return;
|
|
340
400
|
const fullPath = path.join(projectRoot, filePath);
|
|
341
401
|
try {
|
|
342
402
|
const newContent = fs.readFileSync(fullPath, "utf-8");
|
|
343
403
|
const oldContent = moduleVersions.get(filePath);
|
|
344
404
|
const changeType = getChangeType(filePath, oldContent, newContent);
|
|
345
405
|
moduleVersions.set(filePath, newContent);
|
|
406
|
+
const key = `${filePath}:${changeType}`;
|
|
407
|
+
const now = Date.now();
|
|
408
|
+
if (now - (lastSent.get(key) || 0) < 700) return;
|
|
409
|
+
lastSent.set(key, now);
|
|
346
410
|
console.log(c("yellow", `π Changed: ${filePath} (${changeType})`));
|
|
347
411
|
broadcast(
|
|
348
412
|
JSON.stringify({
|
|
349
413
|
type: "hmr",
|
|
350
414
|
file: filePath,
|
|
351
415
|
changeType,
|
|
352
|
-
timestamp:
|
|
416
|
+
timestamp: now,
|
|
353
417
|
}),
|
|
354
418
|
);
|
|
355
419
|
} catch (err) {
|
|
@@ -358,11 +422,13 @@ export const runDevServer = async (options = {}) => {
|
|
|
358
422
|
});
|
|
359
423
|
|
|
360
424
|
watcher.on("add", (filePath) => {
|
|
425
|
+
if (!isWatched(filePath)) return;
|
|
361
426
|
console.log(c("green", `β Added: ${filePath}`));
|
|
362
427
|
broadcast(JSON.stringify({ type: "reload", reason: "file-added" }));
|
|
363
428
|
});
|
|
364
429
|
|
|
365
430
|
watcher.on("unlink", (filePath) => {
|
|
431
|
+
if (!isWatched(filePath)) return;
|
|
366
432
|
console.log(c("red", `β Removed: ${filePath}`));
|
|
367
433
|
broadcast(JSON.stringify({ type: "reload", reason: "file-removed" }));
|
|
368
434
|
});
|