flet-box 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +28 -1
  2. package/bin/cli.js +85 -18
  3. package/bin/commands/auditFix.js +682 -0
  4. package/bin/commands/checkApi.js +7 -5
  5. package/bin/commands/create.js +40 -10
  6. package/bin/commands/createBundle.js +54 -1
  7. package/bin/commands/images.js +349 -0
  8. package/bin/commands/pruneAssets.js +170 -0
  9. package/bin/commands/release.js +34 -1
  10. package/bin/commands/runCloudflare.js +47 -9
  11. package/bin/commands/runServer.js +81 -24
  12. package/bin/commands/toPng.js +346 -0
  13. package/bin/commands/unpack.js +1173 -0
  14. package/bin/ota/loader.js +90 -0
  15. package/bin/utils/appSources.js +101 -0
  16. package/bin/utils/bundleConfig.js +166 -0
  17. package/bin/utils/dtsProps.js +147 -0
  18. package/bin/utils/imageTools.js +271 -0
  19. package/bin/utils/images/bmp.js +99 -0
  20. package/bin/utils/images/gif.js +244 -0
  21. package/bin/utils/images/index.js +163 -0
  22. package/bin/utils/images/jpeg.js +643 -0
  23. package/bin/utils/images/png.js +486 -0
  24. package/bin/utils/images/unsupported.js +29 -0
  25. package/bin/utils/neutralise.js +109 -6
  26. package/bin/utils/templates.js +307 -64
  27. package/docs/cli/README.md +82 -0
  28. package/docs/guides/online-updates.md +57 -9
  29. package/docs/widget/Accordion.md +1 -0
  30. package/docs/widget/AdMob.md +1 -0
  31. package/docs/widget/AdSense.md +1 -0
  32. package/docs/widget/AnimatedBox.md +1 -0
  33. package/docs/widget/AnimatedText.md +1 -0
  34. package/docs/widget/Audio.md +2 -1
  35. package/docs/widget/Avatar.md +12 -3
  36. package/docs/widget/Badge.md +1 -0
  37. package/docs/widget/Button.md +1 -0
  38. package/docs/widget/Card.md +1 -0
  39. package/docs/widget/Carousel.md +1 -0
  40. package/docs/widget/Chart.md +1 -0
  41. package/docs/widget/Checkbox.md +1 -0
  42. package/docs/widget/Chip.md +1 -0
  43. package/docs/widget/CircularBar.md +1 -0
  44. package/docs/widget/CircularChart.md +1 -0
  45. package/docs/widget/CodeViewer.md +1 -0
  46. package/docs/widget/Column.md +1 -0
  47. package/docs/widget/Container.md +1 -0
  48. package/docs/widget/DataTable.md +1 -0
  49. package/docs/widget/Divider.md +1 -0
  50. package/docs/widget/DraggBox.md +3 -2
  51. package/docs/widget/Dropdown.md +3 -0
  52. package/docs/widget/DroppBox.md +2 -1
  53. package/docs/widget/FloatingActionButton.md +1 -0
  54. package/docs/widget/GridView.md +1 -0
  55. package/docs/widget/Icon.md +1 -0
  56. package/docs/widget/Image.md +11 -3
  57. package/docs/widget/Input.md +1 -0
  58. package/docs/widget/InstallButton.md +1 -0
  59. package/docs/widget/ListTile.md +1 -0
  60. package/docs/widget/ListView.md +1 -0
  61. package/docs/widget/Maps.md +188 -0
  62. package/docs/widget/Markdown.md +1 -0
  63. package/docs/widget/MatrixRain.md +1 -0
  64. package/docs/widget/Pagination.md +1 -0
  65. package/docs/widget/ParallaxBox.md +1 -0
  66. package/docs/widget/ProgressBar.md +1 -0
  67. package/docs/widget/QRCode.md +1 -0
  68. package/docs/widget/README.md +12 -10
  69. package/docs/widget/Radio.md +1 -0
  70. package/docs/widget/Rating.md +1 -0
  71. package/docs/widget/Row.md +1 -0
  72. package/docs/widget/Skeleton.md +1 -0
  73. package/docs/widget/Slider.md +1 -0
  74. package/docs/widget/Stack.md +1 -0
  75. package/docs/widget/Stepper.md +1 -0
  76. package/docs/widget/Switch.md +1 -0
  77. package/docs/widget/Text.md +1 -0
  78. package/docs/widget/Tooltip.md +11 -13
  79. package/docs/widget/TreeView.md +1 -0
  80. package/docs/widget/Video.md +3 -2
  81. package/package.json +2 -2
  82. package/src/core/hmr-client.js +129 -34
  83. package/src/index.d.ts +132 -0
  84. package/src/index.js +1 -0
  85. package/src/navigations/AppBar.js +15 -4
  86. package/src/navigations/Drawer.js +9 -1
  87. package/src/widget-factory/translateProps.js +32 -1
  88. package/src/widgets/Avatar.js +228 -131
  89. package/src/widgets/Container.js +13 -0
  90. package/src/widgets/Dropdown.js +33 -1
  91. package/src/widgets/Image.js +102 -3
  92. package/src/widgets/Maps.js +1495 -0
  93. package/src/widgets/Tooltip.js +12 -2
  94. package/src/widgets/index.js +1 -0
package/bin/ota/loader.js CHANGED
@@ -24,6 +24,10 @@
24
24
  * SECURITY: the downloaded bundle is only run if its sha256 matches the hash in
25
25
  * the remote config.json. If Web Crypto is unavailable (non-secure context) OTA
26
26
  * is skipped rather than running unverified code.
27
+ *
28
+ * Extra release files (`config.json` → `assets`) are served to the app through
29
+ * `globalThis.__FLETBOX_OTA_ASSET__(path)`: cached copy, then the published one
30
+ * (verified against its manifest hash), then the copy baked into this build.
27
31
  */
28
32
 
29
33
  const SHIPPED_BUNDLE = "./src/app.js";
@@ -235,6 +239,9 @@ async function checkAndUpdate(shippedConfig, activeHash) {
235
239
  return false;
236
240
  }
237
241
  if (!remote || typeof remote.hash !== "string") return false;
242
+ // Even when this release turns out to be the one already running, its asset
243
+ // list is the newest truth about where the app's files live.
244
+ rememberAssets(remote, update.url);
238
245
  if (remote.hash === activeHash) {
239
246
  log("up to date (" + (remote.version || activeHash.slice(0, 8)) + ")");
240
247
  return false;
@@ -328,6 +335,88 @@ async function checkAndUpdate(shippedConfig, activeHash) {
328
335
  return true;
329
336
  }
330
337
 
338
+ // ---- Release assets -----------------------------------------------------
339
+ // config.json may name files a release serves beside the bundle (markdown docs,
340
+ // JSON data, images). OTA replaces the bundle and nothing else, so an app
341
+ // packaged two builds ago still has the old copy of those files on disk — the
342
+ // symptom is an updated catalog pointing at pages the device does not have.
343
+ // The app calls globalThis.__FLETBOX_OTA_ASSET__(path) and gets a Response, or
344
+ // null when the file exists in neither place.
345
+ const SHIPPED_BASE = new URL("./", import.meta.url).href;
346
+
347
+ let assetSource = null; // base URL of the newest release the loader has seen
348
+ let assetHashes = null; // Map "<path>" -> expected sha256
349
+
350
+ const rememberAssets = (config, base) => {
351
+ if (!config || !Array.isArray(config.assets) || !config.assets.length) return;
352
+ assetSource = base;
353
+ assetHashes = new Map(
354
+ config.assets
355
+ .filter((a) => a && typeof a.path === "string" && typeof a.hash === "string")
356
+ .map((a) => [a.path, a.hash]),
357
+ );
358
+ };
359
+
360
+ const openAssetCache = async () =>
361
+ "caches" in globalThis ? await caches.open(OTA_CACHE) : null;
362
+
363
+ globalThis.__FLETBOX_OTA_ASSET__ = async (relPath) => {
364
+ const clean = String(relPath || "").replace(/^\.?\//, "");
365
+ if (!clean) return null;
366
+ const shippedUrl = new URL(clean, SHIPPED_BASE).href;
367
+ const url = assetSource ? new URL(clean, assetSource).href : shippedUrl;
368
+
369
+ try {
370
+ const cache = await openAssetCache();
371
+ const hit = cache && (await cache.match(url));
372
+ if (hit) return hit;
373
+ } catch (e) {
374
+ log("asset cache unreadable:", e.message);
375
+ }
376
+
377
+ if (url !== shippedUrl) {
378
+ try {
379
+ const ctrl = new AbortController();
380
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
381
+ const res = await fetch(url, { cache: "no-store", signal: ctrl.signal });
382
+ clearTimeout(timer);
383
+ if (res.ok) {
384
+ const text = await res.text();
385
+ const type = res.headers.get("Content-Type") || "application/octet-stream";
386
+ const expected = assetHashes && assetHashes.get(clean);
387
+ const digest = expected ? await sha256(text) : null;
388
+ if (digest && digest !== expected) {
389
+ warn("asset hash mismatch for " + clean + " — using this build's copy");
390
+ } else {
391
+ try {
392
+ const cache = await openAssetCache();
393
+ if (cache) {
394
+ await cache.put(
395
+ url,
396
+ new Response(text, { headers: { "Content-Type": type } }),
397
+ );
398
+ }
399
+ } catch (e) {
400
+ log("asset not cached:", e.message);
401
+ }
402
+ return new Response(text, { headers: { "Content-Type": type } });
403
+ }
404
+ }
405
+ } catch (e) {
406
+ log("asset fetch failed for " + clean + ":", e.message);
407
+ }
408
+ }
409
+
410
+ // Offline, unpublished, or it failed verification: ship what this build holds.
411
+ try {
412
+ const res = await fetch(shippedUrl);
413
+ return res.ok ? res : null;
414
+ } catch (e) {
415
+ log("asset missing from this build (" + clean + "):", e.message);
416
+ return null;
417
+ }
418
+ };
419
+
331
420
  (async function main() {
332
421
  let shippedConfig = null;
333
422
  try {
@@ -340,6 +429,7 @@ async function checkAndUpdate(shippedConfig, activeHash) {
340
429
  } catch (e) {
341
430
  warn("no shipped config.json:", e.message);
342
431
  }
432
+ rememberAssets(shippedConfig, SHIPPED_BASE);
343
433
 
344
434
  const installed = readInstalled();
345
435
  const activeHash = installed
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @file bin/utils/appSources.js
3
+ * @description What a generated app's own sources actually name.
4
+ *
5
+ * Two CLI commands need this question and must not answer it two ways:
6
+ * `prune-assets` deletes the image files no source references, and `images
7
+ * --referenced` refuses to build a WebP the app never asks for (an assets folder
8
+ * routinely holds the declared `favicon.png` and a social-preview JPG, and
9
+ * converting them would add files nothing loads).
10
+ *
11
+ * `www/` is not a source: it is generated output, so a name that appears only
12
+ * there is a stale bundle, not a consumer.
13
+ */
14
+
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+
18
+ const SOURCE_FILE = /\.(js|mjs|css|html|json|md)$/i;
19
+ const SKIPPED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".cache", "www", "vendor", "ios", "android"]);
20
+
21
+ /**
22
+ * Roots a reference may legitimately live in.
23
+ *
24
+ * `scripts/` counts because the native icon steps sit there, and a template that
25
+ * moves a filename into one of them must not have its master deleted.
26
+ */
27
+ export const SOURCE_ROOTS = ["src", "index.html", "scripts", "fletbox.config.json", "package.json", "README.md", "AGENTS.md"];
28
+
29
+ /**
30
+ * Reads the app's sources into one string, with comments taken out.
31
+ *
32
+ * A note like `mojito.jpg is the frappé` is documentation, not a load, and
33
+ * keeping a dead file alive because of it defeats the command.
34
+ *
35
+ * @param {string} root - Absolute project root.
36
+ * @returns {string}
37
+ */
38
+ export const readAppSources = (root) => {
39
+ const parts = [];
40
+ const walk = (target) => {
41
+ if (!fs.existsSync(target)) return;
42
+ const stack = [target];
43
+ while (stack.length) {
44
+ const p = stack.pop();
45
+ const stat = fs.statSync(p);
46
+ if (stat.isDirectory()) {
47
+ for (const entry of fs.readdirSync(p)) {
48
+ if (SKIPPED_DIRS.has(entry)) continue;
49
+ stack.push(path.join(p, entry));
50
+ }
51
+ continue;
52
+ }
53
+ if (SOURCE_FILE.test(p)) {
54
+ try {
55
+ parts.push(fs.readFileSync(p, "utf8"));
56
+ } catch {
57
+ /* unreadable text is not evidence of anything */
58
+ }
59
+ }
60
+ }
61
+ };
62
+ for (const rel of SOURCE_ROOTS) walk(path.resolve(root, rel));
63
+ return parts
64
+ .join("\n")
65
+ .replace(/\/\*[\s\S]*?\*\//g, "")
66
+ .replace(/<!--[\s\S]*?-->/g, "")
67
+ .split("\n")
68
+ .filter((line) => !/^\s*(\/\/|#|\*)/.test(line))
69
+ .join("\n");
70
+ };
71
+
72
+ /**
73
+ * Files a cleanup or a conversion must not touch, whatever the sources say.
74
+ *
75
+ * `assets/logo.png` is the app icon: the native build scripts read it and resize
76
+ * it into launcher icons and splash screens, so it has to stay the lossless
77
+ * master the sensor produced. A text scan alone cannot be trusted to notice it —
78
+ * those scripts build the path with `path.join(root, "assets", "logo.png")`, so
79
+ * the string `assets/logo.png` never appears in them — and a lossy WebP at a
80
+ * 40 KB budget would silently become the icon on every phone.
81
+ *
82
+ * Matched by project-relative path or by exact filename.
83
+ */
84
+ export const PROTECTED_MASTERS = [
85
+ { rel: "assets/logo.png", why: "the app icon master: native icons and splash screens are resized from it" },
86
+ ];
87
+
88
+ /**
89
+ * @param {string} text - The corpus from `readAppSources`.
90
+ * @param {string} rel - Path relative to the project root, e.g. `assets/x.webp`.
91
+ * @returns {boolean} Whether the app names it — by path, or by exact filename.
92
+ */
93
+ export const references = (text, rel) => text.includes(rel) || text.includes(path.basename(rel));
94
+
95
+ /**
96
+ * @param {string} rel - Project-relative path with `/` separators.
97
+ * @returns {{rel: string, why: string}|null} The protection that covers it, if any.
98
+ */
99
+ export const protectedMaster = (rel) =>
100
+ PROTECTED_MASTERS.find((entry) => entry.rel === rel || entry.rel.split("/").pop() === path.basename(rel)) || null;
101
+
@@ -107,6 +107,40 @@ export const resolveUpdateUrl = (projectRoot) => {
107
107
  return null;
108
108
  };
109
109
 
110
+ /**
111
+ * The `<meta>` tags a social scraper resolves against its own machine.
112
+ *
113
+ * Facebook, WhatsApp and X do not run the page and do not resolve `./assets/…`
114
+ * the way a browser does, so a relative `og:image` produces a share card with no
115
+ * picture — the silent kind of breakage, because the site itself looks perfect
116
+ * when somebody opens it.
117
+ */
118
+ export const SHARE_TAGS = ["og:image", "og:url", "twitter:image", "twitter:url"];
119
+
120
+ /**
121
+ * Rewrite the share-card tags in a built `index.html` to absolute URLs.
122
+ *
123
+ * Only the bundle copy is touched: the project's own `index.html` stays
124
+ * host-agnostic, which is what lets one folder run on `:8000`, through a tunnel
125
+ * and on Pages. Values that already carry a scheme are left alone, so building
126
+ * twice over the same output cannot produce `https://…/https://…`.
127
+ *
128
+ * @param {string} html - The built index.html.
129
+ * @param {string|null} base - Site origin ending in "/", or null to leave as is.
130
+ * @returns {string}
131
+ */
132
+ export const absoluteShareTags = (html, base) => {
133
+ if (!base || !base.endsWith("/")) return html;
134
+ return html.replace(/<meta\b[^>]*>/gi, (tag) => {
135
+ const key = tag.match(/\b(?:property|name)\s*=\s*(["'])([^"']+)\1/i)?.[2];
136
+ if (!key || !SHARE_TAGS.includes(key)) return tag;
137
+ const found = tag.match(/\bcontent\s*=\s*(["'])([^"']*)\1/i);
138
+ if (!found || !found[2] || /^(https?:)?\/\//i.test(found[2])) return tag;
139
+ const absolute = base + found[2].replace(/^\.?\//, "");
140
+ return tag.slice(0, found.index) + `content="${absolute}"` + tag.slice(found.index + found[0].length);
141
+ });
142
+ };
143
+
110
144
  /**
111
145
  * Returns the `origin` remote URL of the git repo at `projectRoot`, or null.
112
146
  *
@@ -127,6 +161,126 @@ export const getGitRemoteUrl = (projectRoot) => {
127
161
  }
128
162
  };
129
163
 
164
+ /**
165
+ * Directories never worth walking when resolving release assets: they are either
166
+ * tooling output or would turn a small build into a filesystem scan.
167
+ */
168
+ const PRUNED_DIRS = new Set(["node_modules", "dist", "www", "build", "coverage"]);
169
+
170
+ /**
171
+ * Translates an asset glob into a regular expression over bundle-relative paths
172
+ * that always use forward slashes. Supports `**` (any depth, and zero dirs when
173
+ * written as `a/** /b`), `*` (one segment) and `?`.
174
+ *
175
+ * @param {string} pattern
176
+ * @returns {RegExp}
177
+ */
178
+ const globToRegExp = (pattern) => {
179
+ let source = "";
180
+ for (let i = 0; i < pattern.length; i += 1) {
181
+ const char = pattern[i];
182
+ if (char === "*") {
183
+ if (pattern[i + 1] === "*") {
184
+ i += 1;
185
+ const trailingSlash = pattern[i + 1] === "/";
186
+ if (trailingSlash) i += 1;
187
+ source += trailingSlash ? "(?:.*/)?" : ".*";
188
+ } else {
189
+ source += "[^/]*";
190
+ }
191
+ } else if (char === "?") {
192
+ source += "[^/]";
193
+ } else {
194
+ source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
195
+ }
196
+ }
197
+ return new RegExp(`^${source}$`);
198
+ };
199
+
200
+ /**
201
+ * Lists every file under `dir` (relative to `projectRoot`) as a forward-slash
202
+ * path, skipping tooling folders and dot-directories.
203
+ *
204
+ * @param {string} projectRoot
205
+ * @param {string} dir - "" for the project root itself.
206
+ * @param {string[]} found - Accumulator, mutated.
207
+ * @returns {string[]}
208
+ */
209
+ const listFiles = (projectRoot, dir, found) => {
210
+ let entries;
211
+ try {
212
+ entries = fs.readdirSync(path.join(projectRoot, dir), { withFileTypes: true });
213
+ } catch {
214
+ return found;
215
+ }
216
+ for (const entry of entries) {
217
+ const rel = dir ? `${dir}/${entry.name}` : entry.name;
218
+ if (entry.isDirectory()) {
219
+ if (PRUNED_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
220
+ listFiles(projectRoot, rel, found);
221
+ } else if (entry.isFile()) {
222
+ found.push(rel);
223
+ }
224
+ }
225
+ return found;
226
+ };
227
+
228
+ /**
229
+ * Reads the asset globs a project declares for its OTA releases
230
+ * (`update.assets` in `fletbox.config.json`).
231
+ *
232
+ * @param {string} projectRoot - Absolute path to the project root.
233
+ * @returns {string[]} Absolute-free glob list, empty when none is configured.
234
+ */
235
+ export const resolveAssetPatterns = (projectRoot) => {
236
+ const fletboxConfig = readJsonSafe(
237
+ path.join(projectRoot, "fletbox.config.json"),
238
+ {},
239
+ );
240
+ const patterns = fletboxConfig?.update?.assets;
241
+ if (!Array.isArray(patterns)) return [];
242
+ return patterns
243
+ .filter((p) => typeof p === "string" && p.trim())
244
+ .map((p) => p.trim().replace(/^\.\//, "").replace(/^\/+/, ""));
245
+ };
246
+
247
+ /**
248
+ * Resolves asset globs into the manifest entries a release ships next to the
249
+ * bundle. Each file is hashed so a running app can verify what it downloads.
250
+ *
251
+ * @param {string} projectRoot - Absolute path to the project root.
252
+ * @param {string[]} patterns - Globs relative to the project root.
253
+ * @returns {Array<{path: string, hash: string, size: number}>} Sorted by path.
254
+ */
255
+ export const collectAssets = (projectRoot, patterns = []) => {
256
+ const globs = patterns.map((p) => globToRegExp(p));
257
+ if (!globs.length) return [];
258
+
259
+ // Start the walk at each pattern's literal first segment when there is one,
260
+ // so `docs/**` never reads the rest of the project.
261
+ const starts = new Set(
262
+ patterns.map((pattern) => {
263
+ const first = pattern.split("/")[0];
264
+ if (/[*?]/.test(first)) return "";
265
+ const absolute = path.join(projectRoot, first);
266
+ return fs.existsSync(absolute) && fs.statSync(absolute).isDirectory()
267
+ ? first
268
+ : "";
269
+ }),
270
+ );
271
+
272
+ const files = [];
273
+ for (const start of starts) listFiles(projectRoot, start, files);
274
+
275
+ return [...new Set(files)]
276
+ .filter((file) => globs.some((re) => re.test(file)))
277
+ .sort()
278
+ .map((file) => {
279
+ const content = fs.readFileSync(path.join(projectRoot, file));
280
+ return { path: file, hash: sha256(content), size: content.length };
281
+ });
282
+ };
283
+
130
284
  /**
131
285
  * Builds the OTA config manifest object for a freshly written bundle.
132
286
  *
@@ -136,12 +290,16 @@ export const getGitRemoteUrl = (projectRoot) => {
136
290
  * `app.js` (after the version header is stamped), used for the hash + size.
137
291
  * @param {string} [opts.bundlePath="src/app.js"] - Bundle path, relative to
138
292
  * the bundle root, as the loader should request it.
293
+ * @param {Array<{path: string, hash: string, size: number}>} [opts.assets=[]]
294
+ * Extra files the release serves at `update.url`, as returned by
295
+ * {@link collectAssets}. `hash` still describes only the bundle.
139
296
  * @returns {object} The config.json manifest.
140
297
  */
141
298
  export const buildBundleConfig = ({
142
299
  projectRoot,
143
300
  bundleContent,
144
301
  bundlePath = "src/app.js",
302
+ assets = [],
145
303
  }) => {
146
304
  const pkg = readJsonSafe(path.join(projectRoot, "package.json"), {});
147
305
  const fletboxConfig = readJsonSafe(
@@ -159,6 +317,14 @@ export const buildBundleConfig = ({
159
317
  builtAt: new Date().toISOString(),
160
318
  bundle: bundlePath,
161
319
  size: content.length,
320
+ // Files the release serves beside the bundle (markdown, JSON, images). An
321
+ // installed app reads them through the loader's asset helper; without this
322
+ // list they are frozen at whatever version the APK/IPA was built with.
323
+ assets: Array.isArray(assets)
324
+ ? assets
325
+ .filter((a) => a && typeof a.path === "string" && typeof a.hash === "string")
326
+ .map((a) => ({ path: a.path, hash: a.hash, size: Number(a.size) || 0 }))
327
+ : [],
162
328
  // Minimum native (APK/IPA) version this bundle is compatible with. When the
163
329
  // installed native shell is older, the loader must fall back to a store
164
330
  // update instead of applying the OTA. Defaults to "0.0.0" (always compatible).
@@ -172,6 +172,153 @@ export const resolveInterfaceProps = (interfaces, name, seen = new Set()) => {
172
172
  return resolved;
173
173
  };
174
174
 
175
+ /**
176
+ * Index of the character closing the pair opened at `open`, or -1.
177
+ *
178
+ * @param {string} text
179
+ * @param {number} open
180
+ * @param {string} left
181
+ * @param {string} right
182
+ * @returns {number}
183
+ */
184
+ const matching = (text, open, left, right) => {
185
+ if (text[open] !== left) return -1;
186
+ let depth = 0;
187
+ for (let i = open; i < text.length; i++) {
188
+ if (text[i] === left) depth++;
189
+ else if (text[i] === right) {
190
+ depth--;
191
+ if (depth === 0) return i;
192
+ }
193
+ }
194
+ return -1;
195
+ };
196
+
197
+ /**
198
+ * Read one type expression starting at `from`: balanced brackets keep nested
199
+ * object types whole, and the type ends at the first `;` or newline outside them.
200
+ *
201
+ * @param {string} code
202
+ * @param {number} from
203
+ * @returns {string}
204
+ */
205
+ const readType = (code, from) => {
206
+ let depth = 0;
207
+ let text = "";
208
+ for (let i = from; i < code.length; i++) {
209
+ const ch = code[i];
210
+ if (ch === "=" && code[i + 1] === ">") {
211
+ text += "=>";
212
+ i++;
213
+ continue;
214
+ }
215
+ if (ch === "{" || ch === "[" || ch === "(" || ch === "<") depth++;
216
+ else if (ch === "}" || ch === "]" || ch === ")" || ch === ">") {
217
+ if (depth === 0) break;
218
+ depth--;
219
+ } else if (depth === 0 && (ch === ";" || ch === "\n")) break;
220
+ text += ch;
221
+ }
222
+ return text.trim().replace(/\s+/g, " ");
223
+ };
224
+
225
+ /**
226
+ * Split a declared parameter list into `{ name, type, optional }` fields.
227
+ *
228
+ * Commas only separate parameters at depth 0, so
229
+ * `(name: string, options?: { a: number, b: number })` stays two parameters.
230
+ *
231
+ * @param {string} params Text between the declaration's parentheses.
232
+ * @returns {Array<{ name: string, type: string, optional: boolean }>}
233
+ */
234
+ const parseParams = (params) => {
235
+ const parts = [];
236
+ let depth = 0;
237
+ let current = "";
238
+ for (const ch of params) {
239
+ if (ch === "{" || ch === "[" || ch === "(" || ch === "<") depth++;
240
+ else if (ch === "}" || ch === "]" || ch === ")" || ch === ">") depth--;
241
+ if (ch === "," && depth === 0) {
242
+ parts.push(current);
243
+ current = "";
244
+ continue;
245
+ }
246
+ current += ch;
247
+ }
248
+ parts.push(current);
249
+ return parts
250
+ .map((part) =>
251
+ /^\s*([A-Za-z_$][\w$]*)\s*(\??)\s*:\s*([\s\S]+)$/.exec(part.trim()),
252
+ )
253
+ .filter(Boolean)
254
+ .map((m) => ({ name: m[1], optional: m[2] === "?", type: m[3].trim() }));
255
+ };
256
+
257
+ /**
258
+ * Read how each exported function is called and what it produces.
259
+ *
260
+ * Props say what a widget receives; the signature says what you hand it and what
261
+ * you get back, and that is the difference between `App({ … })`, `Modal({ … }).open()`
262
+ * and `Inspector(element, 2)`. Both declaration shapes in `index.d.ts` work:
263
+ *
264
+ * export function Modal(props: ModalProps): { open(): void };
265
+ * export const ThemeProvider: (props: { … }) => Widget;
266
+ *
267
+ * @param {string} source Full `.d.ts` text.
268
+ * @returns {Map<string, { params: Array<{ name: string, type: string, optional: boolean }>, returns: string }>}
269
+ */
270
+ export const parseSignatures = (source) => {
271
+ const signatures = new Map();
272
+ const code = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
273
+ for (const m of code.matchAll(/export\s+function\s+([A-Za-z_$][\w$]*)\s*\(/g)) {
274
+ const open = m.index + m[0].length - 1;
275
+ const close = matching(code, open, "(", ")");
276
+ if (close < 0) continue;
277
+ let after = close + 1;
278
+ while (after < code.length && /\s/.test(code[after])) after++;
279
+ if (code[after] !== ":") continue;
280
+ signatures.set(m[1], {
281
+ params: parseParams(code.slice(open + 1, close)),
282
+ returns: readType(code, after + 1),
283
+ });
284
+ }
285
+ for (const m of code.matchAll(/export\s+(?:declare\s+)?const\s+([A-Za-z_$][\w$]*)\s*:\s*/g)) {
286
+ const type = readType(code, m.index + m[0].length);
287
+ if (!type.startsWith("(")) continue; // a constant, not a factory
288
+ const close = matching(type, 0, "(", ")");
289
+ if (close < 0) continue;
290
+ const rest = type.slice(close + 1);
291
+ const arrow = rest.indexOf("=>");
292
+ if (arrow < 0) continue;
293
+ signatures.set(m[1], {
294
+ params: parseParams(type.slice(1, close)),
295
+ returns: rest.slice(arrow + 2).trim(),
296
+ });
297
+ }
298
+ return signatures;
299
+ };
300
+
301
+ /**
302
+ * List the members of a returned object type: `open()` means a method,
303
+ * `isOpen: boolean` a property. Empty for plain types like `Widget`.
304
+ *
305
+ * @param {string} type A return type produced by `parseSignatures`.
306
+ * @returns {string[]}
307
+ */
308
+ export const membersOf = (type) => {
309
+ const open = type?.indexOf("{");
310
+ if (open === undefined || open < 0 || !type.trim().endsWith("}")) return [];
311
+ const body = type.slice(open + 1, type.lastIndexOf("}"));
312
+ return splitMembers(body)
313
+ .map((member) => {
314
+ const method = /^([A-Za-z_$][\w$]*)\s*(\??)\s*\(/.exec(member);
315
+ if (method) return `${method[1]}()`;
316
+ const field = /^([A-Za-z_$][\w$]*)\s*(\??)\s*:/.exec(member);
317
+ return field ? field[1] : null;
318
+ })
319
+ .filter(Boolean);
320
+ };
321
+
175
322
  /**
176
323
  * Find the declaration that types a widget's props object.
177
324
  *