flet-box 1.0.6 → 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 +77 -11
- 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
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flet-box audit-fix` — write what `check-api` only reports.
|
|
3
|
+
*
|
|
4
|
+
* Two repairs, both anchored to spans the gate already knows how to find:
|
|
5
|
+
*
|
|
6
|
+
* imports a relative path that does not resolve, a name the target module
|
|
7
|
+
* never exports, a default import from a module without a default,
|
|
8
|
+
* and identifiers used with no import at all (a flet-box export, or
|
|
9
|
+
* a Node builtin used as `path.join(…)`).
|
|
10
|
+
* props a key inside `Widget({ … })` that neither the widget's declared
|
|
11
|
+
* props, the structural props, nor the engine registry knows —
|
|
12
|
+
* `chilldren` → `children`, `postion` → `position`.
|
|
13
|
+
*
|
|
14
|
+
* A rewrite only happens when the intended name is unambiguous: one candidate,
|
|
15
|
+
* edit distance small enough that no real name could collide with it. Anything
|
|
16
|
+
* else is printed as untouched. Guessing a prop is worse than leaving it dead —
|
|
17
|
+
* a wrong key that happens to be valid changes the UI silently, and the gate
|
|
18
|
+
* would no longer complain about it.
|
|
19
|
+
*/
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { c, banner, section } from "../utils/colors.js";
|
|
23
|
+
import { neutralise } from "../utils/neutralise.js";
|
|
24
|
+
import { resolveLocalModule, readModuleExports } from "../utils/moduleExports.js";
|
|
25
|
+
import { resolveInterfaceProps } from "../utils/dtsProps.js";
|
|
26
|
+
import {
|
|
27
|
+
STRUCTURAL_PROPS,
|
|
28
|
+
collectSources,
|
|
29
|
+
distance,
|
|
30
|
+
loadApi,
|
|
31
|
+
nearest,
|
|
32
|
+
} from "./checkApi.js";
|
|
33
|
+
|
|
34
|
+
const BUILTINS = [
|
|
35
|
+
"assert",
|
|
36
|
+
"buffer",
|
|
37
|
+
"child_process",
|
|
38
|
+
"crypto",
|
|
39
|
+
"dns",
|
|
40
|
+
"events",
|
|
41
|
+
"fs",
|
|
42
|
+
"http",
|
|
43
|
+
"http2",
|
|
44
|
+
"https",
|
|
45
|
+
"net",
|
|
46
|
+
"os",
|
|
47
|
+
"path",
|
|
48
|
+
"process",
|
|
49
|
+
"querystring",
|
|
50
|
+
"readline",
|
|
51
|
+
"stream",
|
|
52
|
+
"url",
|
|
53
|
+
"util",
|
|
54
|
+
"zlib",
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const IMPORT_RE = /import\s+([\s\S]*?)\s*from\s*['"]([^'"]+)['"]/g;
|
|
58
|
+
const RE_EXPORT_RE = /export\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
|
|
59
|
+
|
|
60
|
+
const escapeRe = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Index of the `)` closing the `(` at `open`, or -1.
|
|
64
|
+
*
|
|
65
|
+
* Safe on a neutralised body: string and comment characters are blanked, so
|
|
66
|
+
* every parenthesis here is real syntax.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} text
|
|
69
|
+
* @param {number} open
|
|
70
|
+
* @returns {number}
|
|
71
|
+
*/
|
|
72
|
+
const closeParen = (text, open) => {
|
|
73
|
+
let depth = 0;
|
|
74
|
+
for (let i = open; i < text.length; i++) {
|
|
75
|
+
if (text[i] === "(") depth++;
|
|
76
|
+
else if (text[i] === ")") {
|
|
77
|
+
depth--;
|
|
78
|
+
if (depth === 0) return i;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return -1;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Index of the character closing the one at `open`, or -1.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} text Neutralised source, so every bracket here is syntax.
|
|
88
|
+
* @param {number} open
|
|
89
|
+
* @returns {number}
|
|
90
|
+
*/
|
|
91
|
+
const matchPair = (text, open) => {
|
|
92
|
+
const close = { "{": "}", "[": "]", "(": ")" }[text[open]];
|
|
93
|
+
if (!close) return -1;
|
|
94
|
+
let depth = 0;
|
|
95
|
+
for (let i = open; i < text.length; i++) {
|
|
96
|
+
if (text[i] === text[open]) depth++;
|
|
97
|
+
else if (text[i] === close) {
|
|
98
|
+
depth--;
|
|
99
|
+
if (depth === 0) return i;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return -1;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Split a destructuring pattern on its top-level commas only.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} text
|
|
109
|
+
* @returns {string[]}
|
|
110
|
+
*/
|
|
111
|
+
const splitTopLevel = (text) => {
|
|
112
|
+
const parts = [];
|
|
113
|
+
let depth = 0;
|
|
114
|
+
let current = "";
|
|
115
|
+
for (const ch of text) {
|
|
116
|
+
if (ch === "{" || ch === "[" || ch === "(") depth++;
|
|
117
|
+
else if (ch === "}" || ch === "]" || ch === ")") depth--;
|
|
118
|
+
if (ch === "," && depth === 0) {
|
|
119
|
+
parts.push(current);
|
|
120
|
+
current = "";
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
current += ch;
|
|
124
|
+
}
|
|
125
|
+
parts.push(current);
|
|
126
|
+
return parts;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {string} text Neutralised source.
|
|
131
|
+
* @returns {Set<string>} every name the file binds itself.
|
|
132
|
+
*/
|
|
133
|
+
const collectBindings = (text) => {
|
|
134
|
+
const names = new Set();
|
|
135
|
+
const add = (raw) => {
|
|
136
|
+
for (const token of splitTopLevel(raw)) {
|
|
137
|
+
const head = token.trim();
|
|
138
|
+
if (!head || head.startsWith("...")) continue;
|
|
139
|
+
const t = head.split("=")[0].trim(); // `margin = 16` still binds margin
|
|
140
|
+
const aliased = /\bas\s+([A-Za-z_$][\w$]*)$/.exec(t);
|
|
141
|
+
if (aliased) {
|
|
142
|
+
names.add(aliased[1]);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const renamed = /^([A-Za-z_$][\w$]*)\s*:\s*([A-Za-z_$][\w$]*)$/.exec(t);
|
|
146
|
+
if (renamed) {
|
|
147
|
+
names.add(renamed[2]); // `{ marginTop: propMarginTop }` binds the right side
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const plain = /^[A-Za-z_$][\w$]*$/.exec(t);
|
|
151
|
+
if (plain) {
|
|
152
|
+
names.add(plain[0]);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const nested = /[[{]([\s\S]*)[\]}]$/.exec(t); // `{ a, b: { c } }` binds c
|
|
156
|
+
if (nested) add(nested[1]);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
for (const m of text.matchAll(IMPORT_RE)) {
|
|
160
|
+
const clause = m[1];
|
|
161
|
+
const braces = /\{([^}]*)\}/.exec(clause);
|
|
162
|
+
if (braces) add(braces[1]);
|
|
163
|
+
const head = clause.replace(/\{[^}]*\}/g, "").replace(/\*[\s\S]*$/, "").trim();
|
|
164
|
+
const def = /^([A-Za-z_$][\w$]*)/.exec(head);
|
|
165
|
+
if (def) names.add(def[1]);
|
|
166
|
+
const star = /\*\s+as\s+([A-Za-z_$][\w$]*)/.exec(clause);
|
|
167
|
+
if (star) names.add(star[1]);
|
|
168
|
+
}
|
|
169
|
+
for (const m of text.matchAll(/export\s*\{([^}]*)\}\s*from/g)) add(m[1]);
|
|
170
|
+
// Patterns are matched by bracket depth, not by regex: a default like
|
|
171
|
+
// `style = {}` ends a `[^}]*` capture early and loses every name after it.
|
|
172
|
+
for (const m of text.matchAll(/\b(?:const|let|var)\s*(?=[\[{A-Za-z_$])/g)) {
|
|
173
|
+
let i = m.index + m[0].length;
|
|
174
|
+
while (i < text.length) {
|
|
175
|
+
const c = text[i];
|
|
176
|
+
if (c === "{" || c === "[") {
|
|
177
|
+
const end = matchPair(text, i);
|
|
178
|
+
if (end < 0) break;
|
|
179
|
+
add(text.slice(i + 1, end));
|
|
180
|
+
i = end + 1;
|
|
181
|
+
} else if (/[A-Za-z_$]/.test(c)) {
|
|
182
|
+
const id = /^[A-Za-z_$][\w$]*/.exec(text.slice(i))[0];
|
|
183
|
+
names.add(id);
|
|
184
|
+
i += id.length;
|
|
185
|
+
} else break;
|
|
186
|
+
const rest = /^\s*=[\s\S]*?,\s*(?=[\[{A-Za-z_$])/.exec(text.slice(i));
|
|
187
|
+
if (!rest) break;
|
|
188
|
+
i += rest[0].length;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (const m of text.matchAll(/\b(?:function|class)\s*\*?\s*([A-Za-z_$][\w$]*)/g)) {
|
|
192
|
+
names.add(m[1]);
|
|
193
|
+
}
|
|
194
|
+
for (const m of text.matchAll(/\(([^()]*)\)\s*(?:=>|\{)/g)) add(m[1].replace(/=.*/g, ""));
|
|
195
|
+
for (const m of text.matchAll(/\bcatch\s*\(\s*([A-Za-z_$][\w$]*)/g)) names.add(m[1]);
|
|
196
|
+
return names;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Pick a file to point a broken relative specifier at.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} fromFile
|
|
203
|
+
* @param {string} source
|
|
204
|
+
* @param {string[]} allSources
|
|
205
|
+
* @returns {{ relative: string }|null}
|
|
206
|
+
*/
|
|
207
|
+
const suggestPath = (fromFile, source, allSources) => {
|
|
208
|
+
const wanted = path.basename(source).replace(/\.m?js$/, "");
|
|
209
|
+
const dir = path.resolve(path.dirname(fromFile), path.dirname(source));
|
|
210
|
+
const inSameDir = fs.existsSync(dir)
|
|
211
|
+
? fs
|
|
212
|
+
.readdirSync(dir)
|
|
213
|
+
.filter((name) => /\.m?js$/.test(name))
|
|
214
|
+
.map((name) => path.join(dir, name))
|
|
215
|
+
: [];
|
|
216
|
+
const pool = inSameDir.length ? inSameDir : allSources;
|
|
217
|
+
const hit = nearest(wanted, pool.map((file) => path.basename(file).replace(/\.m?js$/, "")));
|
|
218
|
+
if (!hit) return null;
|
|
219
|
+
const target = pool.find((file) => path.basename(file).replace(/\.m?js$/, "") === hit);
|
|
220
|
+
if (!target) return null;
|
|
221
|
+
let relative = path.relative(path.dirname(fromFile), target).replace(/\\/g, "/");
|
|
222
|
+
if (!relative.startsWith(".")) relative = `./${relative}`;
|
|
223
|
+
return { relative: /\.m?js$/.test(relative) ? relative : `${relative}.js` };
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The single near match for a mistyped name, or null when it is not safe to
|
|
228
|
+
* rewrite. Length gates the tolerance so `top` cannot become `tooltip`.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} wanted
|
|
231
|
+
* @param {Iterable<string>} candidates
|
|
232
|
+
* @returns {string|null}
|
|
233
|
+
*/
|
|
234
|
+
const strictMatch = (wanted, candidates) => {
|
|
235
|
+
const budget = wanted.length >= 9 ? 2 : wanted.length >= 4 ? 1 : 0;
|
|
236
|
+
const lower = wanted.toLowerCase();
|
|
237
|
+
let best = null;
|
|
238
|
+
let bestScore = budget + 1;
|
|
239
|
+
for (const candidate of candidates) {
|
|
240
|
+
if (candidate === wanted) return null;
|
|
241
|
+
const score = distance(lower, candidate.toLowerCase());
|
|
242
|
+
if (score < bestScore) {
|
|
243
|
+
bestScore = score;
|
|
244
|
+
best = candidate;
|
|
245
|
+
} else if (score === bestScore) {
|
|
246
|
+
best = null; // ambiguous — two names equally close
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return best;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Top-level keys of an object literal, with their spans.
|
|
254
|
+
*
|
|
255
|
+
* @param {string} code comment/string-free source
|
|
256
|
+
* @param {number} open index of `{`
|
|
257
|
+
* @returns {{ keys: Array<{ name: string, start: number, end: number }>, opaque: boolean }}
|
|
258
|
+
*/
|
|
259
|
+
const literalKeySpans = (code, open) => {
|
|
260
|
+
const keys = [];
|
|
261
|
+
let depth = 0;
|
|
262
|
+
let current = "";
|
|
263
|
+
let entryStart = open;
|
|
264
|
+
let opaque = false;
|
|
265
|
+
const flush = () => {
|
|
266
|
+
const text = current.trim();
|
|
267
|
+
const lead = current.indexOf(text.length ? text[0] : "");
|
|
268
|
+
const at = entryStart + (text ? lead : 0);
|
|
269
|
+
current = "";
|
|
270
|
+
if (!text) return;
|
|
271
|
+
if (text.startsWith("...")) {
|
|
272
|
+
opaque = true;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const key = /^(?:async\s+)?['"]?([A-Za-z_$][\w$]*)['"]?\s*:/.exec(text);
|
|
276
|
+
if (key) {
|
|
277
|
+
const start = at + text.indexOf(key[1]);
|
|
278
|
+
keys.push({ name: key[1], start, end: start + key[1].length });
|
|
279
|
+
} else if (/^[A-Za-z_$][\w$]*$/.test(text)) {
|
|
280
|
+
keys.push({ name: text, start: at, end: at + text.length });
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
for (let i = open; i < code.length; i++) {
|
|
284
|
+
const ch = code[i];
|
|
285
|
+
if (ch === "{") {
|
|
286
|
+
depth++;
|
|
287
|
+
if (depth === 1) {
|
|
288
|
+
entryStart = i + 1;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
} else if (ch === "}") {
|
|
292
|
+
depth--;
|
|
293
|
+
if (depth === 0) {
|
|
294
|
+
flush();
|
|
295
|
+
return { keys, opaque };
|
|
296
|
+
}
|
|
297
|
+
} else if (depth === 1 && ch === ",") {
|
|
298
|
+
flush();
|
|
299
|
+
entryStart = i + 1;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (depth >= 1) current += ch;
|
|
303
|
+
}
|
|
304
|
+
flush();
|
|
305
|
+
return { keys, opaque };
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* @typedef {{ start: number, end: number, text: string, note: string|null, kind: string }} Edit
|
|
310
|
+
*/
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Plan the import rewrites for one file.
|
|
314
|
+
*
|
|
315
|
+
* @param {string} file
|
|
316
|
+
* @param {string} code
|
|
317
|
+
* @param {object} api
|
|
318
|
+
* @param {string[]} allSources
|
|
319
|
+
* @returns {{ edits: Edit[], skipped: string[] }}
|
|
320
|
+
*/
|
|
321
|
+
const planImports = (file, code, api, allSources) => {
|
|
322
|
+
const edits = [];
|
|
323
|
+
const skipped = [];
|
|
324
|
+
const rel = path.relative(api.root, file);
|
|
325
|
+
const withStrings = neutralise(code, { keepStrings: true });
|
|
326
|
+
const spec = (match, source) => {
|
|
327
|
+
const at = match[0].lastIndexOf(source);
|
|
328
|
+
return { start: match.index + at, end: match.index + at + source.length };
|
|
329
|
+
};
|
|
330
|
+
const clauseSpan = (match, clause) => {
|
|
331
|
+
const at = match[0].indexOf(clause, 6);
|
|
332
|
+
return { start: match.index + at, end: match.index + at + clause.length };
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
for (const { re, isImport } of [
|
|
336
|
+
{ re: IMPORT_RE, isImport: true },
|
|
337
|
+
{ re: RE_EXPORT_RE, isImport: false },
|
|
338
|
+
]) {
|
|
339
|
+
for (const match of withStrings.matchAll(re)) {
|
|
340
|
+
let source = match[2];
|
|
341
|
+
if (!source.startsWith(".") && !source.startsWith("/")) continue;
|
|
342
|
+
const originalClause = match[1];
|
|
343
|
+
let fixedClause = isImport ? originalClause : `{${originalClause}}`;
|
|
344
|
+
const originalSpanText = fixedClause;
|
|
345
|
+
const notes = [];
|
|
346
|
+
let target = resolveLocalModule(file, source);
|
|
347
|
+
if (!target) {
|
|
348
|
+
const fix = suggestPath(file, source, allSources);
|
|
349
|
+
if (!fix) {
|
|
350
|
+
skipped.push(`${rel}: "${source}" has no near match`);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
edits.push({
|
|
354
|
+
...spec(match, source),
|
|
355
|
+
text: fix.relative,
|
|
356
|
+
note: `${c("brightRed", source)} → ${c("brightGreen", fix.relative)}`,
|
|
357
|
+
kind: "path",
|
|
358
|
+
});
|
|
359
|
+
// The repaired path is checked too: a typo usually breaks both the
|
|
360
|
+
// specifier and the names imported through it.
|
|
361
|
+
target = resolveLocalModule(file, fix.relative);
|
|
362
|
+
if (!target) continue;
|
|
363
|
+
source = fix.relative;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const surface = readModuleExports(target, api.moduleCache);
|
|
367
|
+
if (surface.opaque) continue;
|
|
368
|
+
|
|
369
|
+
if (isImport) {
|
|
370
|
+
const head = fixedClause.replace(/\{[^}]*\}/g, "").replace(/\*[\s\S]*$/, "").trim();
|
|
371
|
+
const def = /^([A-Za-z_$][\w$]*)\s*,?\s*$/.exec(head);
|
|
372
|
+
if (def && !surface.hasDefault) {
|
|
373
|
+
const local = def[1];
|
|
374
|
+
const real = strictMatch(local, surface.named);
|
|
375
|
+
const hint = nearest(local, [...surface.named]);
|
|
376
|
+
if (!real) {
|
|
377
|
+
skipped.push(
|
|
378
|
+
`${rel}: "${source}" has no default export and "${local}" matches none of its names${
|
|
379
|
+
hint ? ` — closest is ${c("brightGreen", hint)}${c("gray", ", but that is a guess")}` : ""
|
|
380
|
+
}`,
|
|
381
|
+
);
|
|
382
|
+
} else {
|
|
383
|
+
fixedClause = real === local ? `{ ${local} }` : `{ ${real} as ${local} }`;
|
|
384
|
+
notes.push(
|
|
385
|
+
`${c("brightRed", `import ${local} from`)} ${c("gray", "→")} ${c("brightGreen", `import { ${real} } from`)} ${c("gray", source)}`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const braces = /\{([^}]*)\}/.exec(fixedClause);
|
|
392
|
+
if (braces) {
|
|
393
|
+
for (const part of braces[1].split(",")) {
|
|
394
|
+
const token = part.trim();
|
|
395
|
+
if (!token) continue;
|
|
396
|
+
const aliased = /^(\S+)\s+as\s+(\S+)$/.exec(token);
|
|
397
|
+
const wanted = aliased ? aliased[1] : token;
|
|
398
|
+
if (wanted === "default" || surface.named.has(wanted)) continue;
|
|
399
|
+
const real = strictMatch(wanted, surface.named);
|
|
400
|
+
if (!real) {
|
|
401
|
+
skipped.push(`${rel}: "${wanted}" is not exported by "${source}" — too far from any name`);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
fixedClause = fixedClause.replace(
|
|
405
|
+
new RegExp(`(?<![\\w$])${escapeRe(wanted)}(?![\\w$])`),
|
|
406
|
+
real,
|
|
407
|
+
);
|
|
408
|
+
let uses = 0;
|
|
409
|
+
if (!aliased) {
|
|
410
|
+
const span = clauseSpan(match, originalSpanText);
|
|
411
|
+
const useRe = new RegExp(`(?<![\\w$])${escapeRe(wanted)}(?![\\w$])`, "g");
|
|
412
|
+
for (const use of withStrings.matchAll(useRe)) {
|
|
413
|
+
if (use.index >= span.start && use.index < span.end) continue;
|
|
414
|
+
uses++;
|
|
415
|
+
edits.push({
|
|
416
|
+
start: use.index,
|
|
417
|
+
end: use.index + wanted.length,
|
|
418
|
+
text: real,
|
|
419
|
+
note: null,
|
|
420
|
+
kind: "use",
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
notes.push(
|
|
425
|
+
`${c("brightRed", wanted)} → ${c("brightGreen", real)}${uses ? ` ${c("gray", `(+${uses} use${uses > 1 ? "s" : ""})`)}` : ""}`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (fixedClause !== (isImport ? originalClause : `{${originalClause}}`)) {
|
|
431
|
+
edits.push({
|
|
432
|
+
...clauseSpan(match, isImport ? originalClause : `{${originalClause}}`),
|
|
433
|
+
text: fixedClause,
|
|
434
|
+
note: notes.join(` ${c("gray", "·")} `),
|
|
435
|
+
kind: "name",
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// identifiers used with no import at all
|
|
442
|
+
const body = neutralise(code);
|
|
443
|
+
const bound = collectBindings(body);
|
|
444
|
+
const addFlet = [];
|
|
445
|
+
const addBuiltin = [];
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Does this file really *use* `name`, or only mention it?
|
|
449
|
+
*
|
|
450
|
+
* Two shapes look like a free call and are not: a method definition
|
|
451
|
+
* (`rgba() { … }`, `filter(cb) { … }`) and a DOM constructor (`new Image()`).
|
|
452
|
+
* Adding an import for either shadows the very thing being used.
|
|
453
|
+
*
|
|
454
|
+
* @param {string} name
|
|
455
|
+
* @param {boolean} memberOnly `true` for builtins, matched as `name.method`
|
|
456
|
+
* @returns {boolean}
|
|
457
|
+
*/
|
|
458
|
+
const usedAs = (name, memberOnly) => {
|
|
459
|
+
const re = new RegExp(`(?<![\\w$.])${escapeRe(name)}\\s*(${memberOnly ? "\\." : "[.(]"})`, "g");
|
|
460
|
+
for (const match of body.matchAll(re)) {
|
|
461
|
+
if (/new\s*$/.test(body.slice(0, match.index))) continue;
|
|
462
|
+
if (match[1] === "(") {
|
|
463
|
+
const close = closeParen(body, body.indexOf("(", match.index));
|
|
464
|
+
if (close < 0) continue;
|
|
465
|
+
if (/^[{:=]/.test(body.slice(close + 1, close + 30).trimStart())) continue;
|
|
466
|
+
}
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
return false;
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
for (const name of BUILTINS) {
|
|
473
|
+
if (!bound.has(name) && usedAs(name, true)) addBuiltin.push(name);
|
|
474
|
+
}
|
|
475
|
+
for (const name of api.exports) {
|
|
476
|
+
if (!bound.has(name) && usedAs(name, false)) addFlet.push(name);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const insertAt = (() => {
|
|
480
|
+
const imports = [...code.matchAll(/^import\s[^\n]*\n/gm)];
|
|
481
|
+
if (!imports.length) return /^#!.*\n/.test(code) ? code.split("\n")[0].length + 1 : 0;
|
|
482
|
+
const last = imports[imports.length - 1];
|
|
483
|
+
return last.index + last[0].length;
|
|
484
|
+
})();
|
|
485
|
+
|
|
486
|
+
if (addFlet.length) {
|
|
487
|
+
const fletMatch = /import\s*\{[^}]*\}\s*from\s*['"]flet-box['"]/.exec(withStrings);
|
|
488
|
+
if (fletMatch) {
|
|
489
|
+
const open = fletMatch.index + fletMatch[0].indexOf("{") + 1;
|
|
490
|
+
edits.push({
|
|
491
|
+
start: open,
|
|
492
|
+
end: open,
|
|
493
|
+
text: `${addFlet.join(", ")}, `,
|
|
494
|
+
note: `added ${c("brightGreen", addFlet.join(", "))} ${c("gray", "to the flet-box import")}`,
|
|
495
|
+
kind: "missing",
|
|
496
|
+
});
|
|
497
|
+
} else {
|
|
498
|
+
edits.push({
|
|
499
|
+
start: insertAt,
|
|
500
|
+
end: insertAt,
|
|
501
|
+
text: `import { ${addFlet.join(", ")} } from 'flet-box';\n`,
|
|
502
|
+
note: `added ${c("brightGreen", `import { ${addFlet.join(", ")} } from 'flet-box'`)}`,
|
|
503
|
+
kind: "missing",
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
for (const name of addBuiltin) {
|
|
508
|
+
edits.push({
|
|
509
|
+
start: insertAt,
|
|
510
|
+
end: insertAt,
|
|
511
|
+
text: `import ${name} from "node:${name}";\n`,
|
|
512
|
+
note: `added ${c("brightGreen", `import ${name} from "node:${name}"`)}`,
|
|
513
|
+
kind: "missing",
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return { edits, skipped };
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Plan the prop-key rewrites for one file.
|
|
522
|
+
*
|
|
523
|
+
* @param {string} file
|
|
524
|
+
* @param {string} code
|
|
525
|
+
* @param {object} api
|
|
526
|
+
* @returns {{ edits: Edit[], skipped: string[] }}
|
|
527
|
+
*/
|
|
528
|
+
const planProps = (file, code, api) => {
|
|
529
|
+
const edits = [];
|
|
530
|
+
const skipped = [];
|
|
531
|
+
const rel = path.relative(api.root, file);
|
|
532
|
+
const withStrings = neutralise(code, { keepStrings: true });
|
|
533
|
+
const body = neutralise(code);
|
|
534
|
+
|
|
535
|
+
const localToWidget = new Map();
|
|
536
|
+
for (const match of withStrings.matchAll(IMPORT_RE)) {
|
|
537
|
+
if (match[2] !== "flet-box") continue;
|
|
538
|
+
const braces = /\{([^}]*)\}/.exec(match[1]);
|
|
539
|
+
if (!braces) continue;
|
|
540
|
+
for (const part of braces[1].split(",")) {
|
|
541
|
+
const token = part.trim();
|
|
542
|
+
if (!token) continue;
|
|
543
|
+
const aliased = /^(\S+)\s+as\s+(\S+)$/.exec(token);
|
|
544
|
+
const exported = aliased ? aliased[1] : token;
|
|
545
|
+
const local = aliased ? aliased[2] : token;
|
|
546
|
+
if (/^[A-Z]/.test(exported)) localToWidget.set(local, exported);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
for (const [local, exported] of localToWidget) {
|
|
551
|
+
const iface = `${exported}Props`;
|
|
552
|
+
if (!api.interfaces.has(iface)) continue;
|
|
553
|
+
const declared = resolveInterfaceProps(api.interfaces, iface);
|
|
554
|
+
const known = new Set([...declared.keys(), ...Object.keys(api.PROP_MAP)]);
|
|
555
|
+
const callRe = new RegExp(`\\b${escapeRe(local)}\\s*\\(\\s*\\{`, "g");
|
|
556
|
+
let call;
|
|
557
|
+
while ((call = callRe.exec(body))) {
|
|
558
|
+
const open = call.index + call[0].length - 1;
|
|
559
|
+
const { keys, opaque } = literalKeySpans(body, open);
|
|
560
|
+
if (opaque) continue;
|
|
561
|
+
for (const key of keys) {
|
|
562
|
+
if (known.has(key.name) || STRUCTURAL_PROPS.has(key.name)) continue;
|
|
563
|
+
const real = strictMatch(key.name, known);
|
|
564
|
+
if (!real) {
|
|
565
|
+
skipped.push(`${rel}: ${exported}({ ${key.name} }) — nothing close enough to guess`);
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
edits.push({
|
|
569
|
+
start: key.start,
|
|
570
|
+
end: key.end,
|
|
571
|
+
text: real,
|
|
572
|
+
note: `${exported}({ ${c("brightRed", key.name)} }) → ${c("brightGreen", real)}`,
|
|
573
|
+
kind: "prop",
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return { edits, skipped };
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Apply planned edits to one file.
|
|
584
|
+
*
|
|
585
|
+
* Edits run back to front so earlier offsets stay valid, and anything that
|
|
586
|
+
* would land inside an already-applied edit is dropped rather than merged.
|
|
587
|
+
*
|
|
588
|
+
* @param {string} code
|
|
589
|
+
* @param {Edit[]} edits
|
|
590
|
+
* @returns {{ text: string, notes: string[] }}
|
|
591
|
+
*/
|
|
592
|
+
const applyEdits = (code, edits) => {
|
|
593
|
+
const sorted = edits.sort((a, b) => b.start - a.start);
|
|
594
|
+
let out = code;
|
|
595
|
+
let cursor = Infinity;
|
|
596
|
+
const notes = [];
|
|
597
|
+
for (const edit of sorted) {
|
|
598
|
+
if (edit.end > cursor) continue;
|
|
599
|
+
out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
|
|
600
|
+
cursor = edit.start;
|
|
601
|
+
if (edit.note) notes.push(edit.note);
|
|
602
|
+
}
|
|
603
|
+
return { text: out, notes: notes.reverse() };
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* @param {string[]} argv
|
|
608
|
+
* @param {{ print?: Function }} [io]
|
|
609
|
+
* @returns {Promise<{ exitCode: number, changed: string[], untouched: string[] }>}
|
|
610
|
+
*/
|
|
611
|
+
export const auditFix = async (argv = [], io = {}) => {
|
|
612
|
+
const print = io.print || ((line) => console.log(line));
|
|
613
|
+
const root = (() => {
|
|
614
|
+
const i = argv.indexOf("--root");
|
|
615
|
+
return i >= 0 && argv[i + 1] ? path.resolve(argv[i + 1]) : process.cwd();
|
|
616
|
+
})();
|
|
617
|
+
const onlyFlag = argv.includes("--only") ? argv[argv.indexOf("--only") + 1] : null;
|
|
618
|
+
if (onlyFlag && !["imports", "props"].includes(onlyFlag)) {
|
|
619
|
+
print(c("red", `--only expects "imports" or "props", got "${onlyFlag}"`));
|
|
620
|
+
return { exitCode: 1, changed: [], untouched: [] };
|
|
621
|
+
}
|
|
622
|
+
const only = {
|
|
623
|
+
imports: !onlyFlag || onlyFlag === "imports",
|
|
624
|
+
props: !onlyFlag || onlyFlag === "props",
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
let api;
|
|
628
|
+
try {
|
|
629
|
+
api = await loadApi(root);
|
|
630
|
+
} catch (error) {
|
|
631
|
+
print(c("red", `✗ ${error.message}`));
|
|
632
|
+
return { exitCode: 1, changed: [], untouched: [] };
|
|
633
|
+
}
|
|
634
|
+
api.root = root;
|
|
635
|
+
api.moduleCache = new Map();
|
|
636
|
+
|
|
637
|
+
const files = collectSources(root);
|
|
638
|
+
const changed = [];
|
|
639
|
+
const untouched = [];
|
|
640
|
+
const passes = [only.imports && "imports", only.props && "props"].filter(Boolean);
|
|
641
|
+
|
|
642
|
+
print(banner("🔧 FletBox audit fix", `${files.length} file(s) · ${passes.join(" + ")}`));
|
|
643
|
+
|
|
644
|
+
for (const file of files) {
|
|
645
|
+
const code = fs.readFileSync(file, "utf8");
|
|
646
|
+
let text = code;
|
|
647
|
+
const notes = [];
|
|
648
|
+
|
|
649
|
+
// Imports first, then props on the repaired text: a widget whose import
|
|
650
|
+
// line was missing is only provably a flet-box widget after the fix.
|
|
651
|
+
const runs = [];
|
|
652
|
+
if (only.imports) runs.push(() => planImports(file, text, api, files));
|
|
653
|
+
if (only.props) runs.push(() => planProps(file, text, api));
|
|
654
|
+
for (const run of runs) {
|
|
655
|
+
const plan = run();
|
|
656
|
+
untouched.push(...plan.skipped);
|
|
657
|
+
const applied = applyEdits(text, plan.edits);
|
|
658
|
+
text = applied.text;
|
|
659
|
+
notes.push(...applied.notes);
|
|
660
|
+
}
|
|
661
|
+
if (text === code) continue;
|
|
662
|
+
|
|
663
|
+
fs.writeFileSync(file, text, "utf8");
|
|
664
|
+
changed.push(path.relative(root, file));
|
|
665
|
+
print(section("📄", path.relative(root, file)));
|
|
666
|
+
for (const note of notes) print(` ${c("cyan", "·")} ${note}`);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (untouched.length) {
|
|
670
|
+
print(section("🔍", "Left for you to decide", "#f59e0b"));
|
|
671
|
+
for (const line of untouched) print(` ${c("yellow", "!")} ${line}`);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
print(
|
|
675
|
+
changed.length
|
|
676
|
+
? `\n${c("brightGreen", `${changed.length} file(s) rewritten`)} ${c("gray", "— run npm run check to confirm, and git diff to review.")}`
|
|
677
|
+
: c("gray", "\nNothing to rewrite — every import resolves and every prop is known."),
|
|
678
|
+
);
|
|
679
|
+
return { exitCode: 0, changed, untouched };
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
export default auditFix;
|