flet-box 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +28 -1
  2. package/bin/cli.js +90 -11
  3. package/bin/commands/auditFix.js +682 -0
  4. package/bin/commands/checkApi.js +20 -6
  5. package/bin/commands/checkUi.js +1369 -0
  6. package/bin/commands/create.js +86 -12
  7. package/bin/commands/createBundle.js +75 -1
  8. package/bin/commands/images.js +349 -0
  9. package/bin/commands/pruneAssets.js +170 -0
  10. package/bin/commands/release.js +34 -1
  11. package/bin/commands/runCloudflare.js +47 -9
  12. package/bin/commands/runServer.js +91 -25
  13. package/bin/commands/toPng.js +346 -0
  14. package/bin/commands/unpack.js +1173 -0
  15. package/bin/ota/loader.js +90 -0
  16. package/bin/utils/appSources.js +101 -0
  17. package/bin/utils/bundleConfig.js +166 -0
  18. package/bin/utils/dtsProps.js +147 -0
  19. package/bin/utils/imageTools.js +271 -0
  20. package/bin/utils/images/bmp.js +99 -0
  21. package/bin/utils/images/gif.js +244 -0
  22. package/bin/utils/images/index.js +163 -0
  23. package/bin/utils/images/jpeg.js +643 -0
  24. package/bin/utils/images/png.js +486 -0
  25. package/bin/utils/images/unsupported.js +29 -0
  26. package/bin/utils/neutralise.js +109 -6
  27. package/bin/utils/pwaShell.js +137 -0
  28. package/bin/utils/templates.js +1653 -471
  29. package/docs/README.md +2 -0
  30. package/docs/cli/README.md +144 -1
  31. package/docs/guides/README.md +2 -0
  32. package/docs/guides/clean-ui.md +211 -0
  33. package/docs/guides/minification-and-protection.md +1 -1
  34. package/docs/guides/online-updates.md +105 -9
  35. package/docs/tools/README.md +1 -1
  36. package/docs/tools/animation.md +1 -1
  37. package/docs/tools/markdown.md +3 -3
  38. package/docs/tools/theme.md +41 -1
  39. package/docs/widget/Accordion.md +4 -0
  40. package/docs/widget/AdMob.md +5 -1
  41. package/docs/widget/AdSense.md +4 -0
  42. package/docs/widget/AnimatedBox.md +7 -3
  43. package/docs/widget/AnimatedText.md +4 -0
  44. package/docs/widget/Audio.md +5 -1
  45. package/docs/widget/Avatar.md +15 -3
  46. package/docs/widget/Badge.md +4 -0
  47. package/docs/widget/Button.md +4 -0
  48. package/docs/widget/COMMON_PROPS.md +52 -0
  49. package/docs/widget/Card.md +4 -0
  50. package/docs/widget/Carousel.md +4 -0
  51. package/docs/widget/Chart.md +4 -0
  52. package/docs/widget/Checkbox.md +4 -0
  53. package/docs/widget/Chip.md +4 -0
  54. package/docs/widget/CircularBar.md +4 -0
  55. package/docs/widget/CircularChart.md +4 -0
  56. package/docs/widget/CodeViewer.md +4 -0
  57. package/docs/widget/Column.md +4 -0
  58. package/docs/widget/Container.md +4 -0
  59. package/docs/widget/DataTable.md +4 -0
  60. package/docs/widget/Divider.md +4 -0
  61. package/docs/widget/DraggBox.md +6 -2
  62. package/docs/widget/Dropdown.md +6 -0
  63. package/docs/widget/DroppBox.md +5 -1
  64. package/docs/widget/FloatingActionButton.md +4 -0
  65. package/docs/widget/GridView.md +4 -0
  66. package/docs/widget/Icon.md +4 -0
  67. package/docs/widget/Image.md +14 -3
  68. package/docs/widget/Input.md +4 -0
  69. package/docs/widget/Inspector.md +2 -2
  70. package/docs/widget/InstallButton.md +4 -0
  71. package/docs/widget/ListTile.md +4 -0
  72. package/docs/widget/ListView.md +4 -0
  73. package/docs/widget/Maps.md +191 -0
  74. package/docs/widget/Markdown.md +4 -0
  75. package/docs/widget/MatrixRain.md +4 -0
  76. package/docs/widget/Pagination.md +4 -0
  77. package/docs/widget/ParallaxBox.md +4 -0
  78. package/docs/widget/ProgressBar.md +4 -0
  79. package/docs/widget/QRCode.md +4 -0
  80. package/docs/widget/README.md +12 -10
  81. package/docs/widget/Radio.md +5 -1
  82. package/docs/widget/Rating.md +4 -0
  83. package/docs/widget/Row.md +4 -0
  84. package/docs/widget/Skeleton.md +4 -0
  85. package/docs/widget/Slider.md +4 -0
  86. package/docs/widget/Stack.md +4 -0
  87. package/docs/widget/Stepper.md +4 -0
  88. package/docs/widget/Switch.md +4 -0
  89. package/docs/widget/Tabs.md +5 -5
  90. package/docs/widget/Text.md +4 -0
  91. package/docs/widget/Tooltip.md +14 -13
  92. package/docs/widget/TreeView.md +4 -0
  93. package/docs/widget/Video.md +6 -2
  94. package/package.json +2 -2
  95. package/src/animations/AnimatedBox.js +4 -1
  96. package/src/animations/MatrixRain.js +3 -3
  97. package/src/core/hmr-client.js +129 -34
  98. package/src/index.d.ts +195 -7
  99. package/src/index.js +2 -0
  100. package/src/navigations/AppBar.js +22 -16
  101. package/src/navigations/BottomNavigation.js +8 -13
  102. package/src/navigations/CollapsibleSideBar.js +30 -8
  103. package/src/navigations/Drawer.js +25 -15
  104. package/src/navigations/DrawerItem.js +3 -1
  105. package/src/navigations/Scaffold.js +26 -12
  106. package/src/navigations/Tabs.js +104 -59
  107. package/src/tools/color.js +54 -0
  108. package/src/tools/index.js +9 -1
  109. package/src/tools/scale.js +41 -0
  110. package/src/tools/shadow.js +20 -0
  111. package/src/utils/index.js +1 -0
  112. package/src/utils/readableInk.js +19 -0
  113. package/src/utils/syntaxHighlight.js +78 -10
  114. package/src/utils/themes.js +217 -54
  115. package/src/utils/units.js +33 -11
  116. package/src/widget-factory/effects.js +56 -5
  117. package/src/widget-factory/processProps.js +10 -1
  118. package/src/widget-factory/remTool.js +15 -29
  119. package/src/widget-factory/stackPosition.js +18 -3
  120. package/src/widget-factory/translateProps.js +60 -1
  121. package/src/widgets/Accordion.js +20 -23
  122. package/src/widgets/AdMob.js +7 -2
  123. package/src/widgets/Avatar.js +236 -133
  124. package/src/widgets/Badge.js +3 -2
  125. package/src/widgets/BottomSheet.js +14 -13
  126. package/src/widgets/Button.js +69 -30
  127. package/src/widgets/Card.js +6 -2
  128. package/src/widgets/Carousel.js +12 -10
  129. package/src/widgets/Chart.js +28 -5
  130. package/src/widgets/Checkbox.js +76 -18
  131. package/src/widgets/Chip.js +31 -10
  132. package/src/widgets/CircularBar.js +7 -7
  133. package/src/widgets/CircularChart.js +26 -8
  134. package/src/widgets/CodeViewer.js +93 -20
  135. package/src/widgets/Container.js +13 -0
  136. package/src/widgets/DataTable.js +17 -11
  137. package/src/widgets/DraggBox.js +2 -1
  138. package/src/widgets/Dropdown.js +59 -14
  139. package/src/widgets/DroppBox.js +4 -2
  140. package/src/widgets/FloatingActionButton.js +13 -7
  141. package/src/widgets/Icon.js +3 -1
  142. package/src/widgets/Image.js +102 -3
  143. package/src/widgets/Input.js +30 -23
  144. package/src/widgets/Inspector.js +54 -1
  145. package/src/widgets/InstallButton.js +6 -4
  146. package/src/widgets/ListTile.js +4 -3
  147. package/src/widgets/ListView.js +30 -12
  148. package/src/widgets/Maps.js +1495 -0
  149. package/src/widgets/Markdown.js +37 -26
  150. package/src/widgets/Modal.js +14 -11
  151. package/src/widgets/Pagination.js +16 -11
  152. package/src/widgets/ProgressBar.js +6 -4
  153. package/src/widgets/Radio.js +73 -15
  154. package/src/widgets/Rating.js +24 -6
  155. package/src/widgets/Skeleton.js +31 -31
  156. package/src/widgets/Slider.js +29 -18
  157. package/src/widgets/SnackBar.js +35 -39
  158. package/src/widgets/Stepper.js +13 -9
  159. package/src/widgets/Switch.js +59 -18
  160. package/src/widgets/Tooltip.js +24 -12
  161. package/src/widgets/TreeView.js +27 -15
  162. package/src/widgets/index.js +1 -0
@@ -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;