@triflare/fx 1.0.2 → 2.0.0

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 (54) hide show
  1. package/README.md +32 -0
  2. package/bin/fx.js +4 -1160
  3. package/package.json +9 -3
  4. package/product.yml +1 -1
  5. package/src/brand.js +9 -3
  6. package/src/build.js +2 -1
  7. package/src/catalog.js +117 -140
  8. package/src/cli/commands/build.js +98 -0
  9. package/src/cli/commands/catalog.js +42 -0
  10. package/src/cli/commands/check.js +44 -0
  11. package/src/cli/commands/completion.js +54 -0
  12. package/src/cli/commands/decompile.js +266 -0
  13. package/src/cli/commands/dev.js +179 -0
  14. package/src/cli/commands/extension.js +110 -0
  15. package/src/cli/commands/index.js +39 -0
  16. package/src/cli/commands/init.js +169 -0
  17. package/src/cli/commands/package.js +137 -0
  18. package/src/cli/commands/sprite/add.js +85 -0
  19. package/src/cli/commands/sprite/build.js +62 -0
  20. package/src/cli/commands/sprite/helpers.js +66 -0
  21. package/src/cli/commands/sprite/index.js +29 -0
  22. package/src/cli/commands/sprite/list.js +31 -0
  23. package/src/cli/commands/sprite/remove.js +51 -0
  24. package/src/cli/commands/vendor/add.js +98 -0
  25. package/src/cli/commands/vendor/helpers.js +50 -0
  26. package/src/cli/commands/vendor/index.js +39 -0
  27. package/src/cli/commands/vendor/install.js +64 -0
  28. package/src/cli/commands/vendor/list.js +45 -0
  29. package/src/cli/commands/vendor/migrate.js +47 -0
  30. package/src/cli/commands/vendor/outdated.js +51 -0
  31. package/src/cli/commands/vendor/remove.js +34 -0
  32. package/src/cli/commands/vendor/update.js +49 -0
  33. package/src/cli/commands/vendor/verify.js +44 -0
  34. package/src/cli/completion/bash.js +10 -0
  35. package/src/cli/completion/fish.js +9 -0
  36. package/src/cli/completion/index.js +97 -0
  37. package/src/cli/completion/zsh.js +11 -0
  38. package/src/cli/context.js +257 -0
  39. package/src/cli/define-command.js +140 -0
  40. package/src/cli/errors.js +111 -0
  41. package/src/cli/format.js +31 -0
  42. package/src/cli/prompts.js +183 -0
  43. package/src/cli/reporter.js +194 -0
  44. package/src/cli/run.js +248 -0
  45. package/src/cli/templates/index.js +112 -0
  46. package/src/cli/theme.js +84 -0
  47. package/src/compiler.js +32 -3
  48. package/src/config-edit.js +280 -1
  49. package/src/config.js +137 -3
  50. package/src/decompiler.js +12 -2
  51. package/src/extensions.js +311 -48
  52. package/src/index.js +21 -0
  53. package/src/vendor.js +1323 -0
  54. package/vendor/pm-packager-dist/.fx-build.json +1 -1
package/src/config.js CHANGED
@@ -31,8 +31,112 @@ export const DEFAULT_PROJECT = {
31
31
  */
32
32
  export const DEFAULT_FX = {
33
33
  partialSprites: false,
34
+ vendorDir: "extensions",
34
35
  };
35
36
 
37
+ /** Default directory that receives vendored extension sources. */
38
+ export const DEFAULT_VENDOR_DIR = "extensions";
39
+
40
+ /** Source schemes that identify a remote extension. */
41
+ const REMOTE_SOURCE = /^https?:\/\//i;
42
+ const DATA_SOURCE = /^data:/i;
43
+
44
+ /** True for http(s) extension sources. */
45
+ function isRemoteSource(source) {
46
+ return REMOTE_SOURCE.test(String(source));
47
+ }
48
+
49
+ /** True for embedded data-URI extension sources. */
50
+ function isDataSource(source) {
51
+ return DATA_SOURCE.test(String(source));
52
+ }
53
+
54
+ /** Localhost/loopback URLs can point at a development server that changes. */
55
+ export function isLocalhostSource(source) {
56
+ if (!isRemoteSource(source)) return false;
57
+ try {
58
+ const url = new URL(String(source));
59
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
60
+ return (
61
+ host === "localhost" ||
62
+ host.endsWith(".localhost") ||
63
+ host === "0.0.0.0" ||
64
+ host === "::1" ||
65
+ /^127\./.test(host)
66
+ );
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Normalize one `vendor:` entry. A string is shorthand for `{ url }`; a
74
+ * mapping may add `unsandboxed`, `volatile` and any future fields.
75
+ *
76
+ * @param {string} name
77
+ * @param {unknown} raw
78
+ * @returns {object}
79
+ */
80
+ export function normalizeVendorEntry(name, raw) {
81
+ const key = String(name);
82
+ let source;
83
+ let options = {};
84
+ if (typeof raw === "string" || typeof raw === "number") {
85
+ source = String(raw);
86
+ } else if (isPlainObject(raw)) {
87
+ source = raw.url ?? raw.source ?? raw.path ?? raw.file;
88
+ options = { ...raw };
89
+ } else {
90
+ throw new Error(
91
+ `${configFilename()}: vendor.${key} must be a URL, path, or mapping`,
92
+ );
93
+ }
94
+ if (typeof source !== "string" || source.trim() === "")
95
+ throw new Error(
96
+ `${configFilename()}: vendor.${key} needs a "url" (or "source"/"path")`,
97
+ );
98
+ source = source.trim();
99
+
100
+ const remote = isRemoteSource(source);
101
+ const local = !remote && !isDataSource(source);
102
+ const unsandboxed =
103
+ options.unsandboxed === true ||
104
+ (typeof options.unsandboxed === "string" &&
105
+ options.unsandboxed.trim() !== "");
106
+ const volatile =
107
+ options.volatile == null
108
+ ? remote && isLocalhostSource(source)
109
+ : Boolean(options.volatile);
110
+
111
+ return {
112
+ ...options,
113
+ name: key,
114
+ source,
115
+ url: source,
116
+ remote,
117
+ local,
118
+ data: isDataSource(source),
119
+ volatile,
120
+ unsandboxed,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Normalize the top-level `vendor:` mapping. Order is load order, so this is an
126
+ * array rather than a plain object.
127
+ *
128
+ * @param {unknown} raw
129
+ * @returns {object[]}
130
+ */
131
+ export function normalizeVendor(raw = {}) {
132
+ if (raw == null) return [];
133
+ if (!isPlainObject(raw))
134
+ throw new Error(`${configFilename()}: "vendor" must be a mapping`);
135
+ return Object.entries(raw).map(([name, entry]) =>
136
+ normalizeVendorEntry(name, entry),
137
+ );
138
+ }
139
+
36
140
  /**
37
141
  * Packaging defaults. These never reach PenguinMod itself; they are handed to
38
142
  * the PenguinMod Packager when `fx package` or `fx dev` runs.
@@ -281,6 +385,11 @@ export function normalizeFx(raw = {}) {
281
385
  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
282
386
  if (raw.partialSprites != null)
283
387
  fx.partialSprites = Boolean(raw.partialSprites);
388
+ if (raw.vendorDir != null) {
389
+ if (typeof raw.vendorDir !== "string" || raw.vendorDir.trim() === "")
390
+ throw new Error(`${configFilename()}: fx.vendorDir must be a path`);
391
+ fx.vendorDir = raw.vendorDir.trim();
392
+ }
284
393
  }
285
394
  return fx;
286
395
  }
@@ -288,7 +397,7 @@ export function normalizeFx(raw = {}) {
288
397
  /**
289
398
  * Parse a YAML config string.
290
399
  * @param {string} text
291
- * @returns {{project: typeof DEFAULT_PROJECT, fx: typeof DEFAULT_FX, raw: object}}
400
+ * @returns {{project: typeof DEFAULT_PROJECT, fx: typeof DEFAULT_FX, package: typeof DEFAULT_PACKAGE, vendor: object[], vendorDir: string, raw: object}}
292
401
  */
293
402
  export function parseConfig(text) {
294
403
  const raw = yaml.load(text) || {};
@@ -298,10 +407,20 @@ export function parseConfig(text) {
298
407
  );
299
408
  if (raw.fx != null && (typeof raw.fx !== "object" || Array.isArray(raw.fx)))
300
409
  throw new Error(`${configFilename()} must contain an "fx" mapping`);
410
+ const fx = normalizeFx(raw.fx || {});
411
+ // `vendorDir` is documented at the top level, but accepting it under `fx:`
412
+ // keeps older drafts and long configs working.
413
+ if (raw.vendorDir != null) {
414
+ if (typeof raw.vendorDir !== "string" || raw.vendorDir.trim() === "")
415
+ throw new Error(`${configFilename()}: vendorDir must be a path`);
416
+ fx.vendorDir = raw.vendorDir.trim();
417
+ }
301
418
  return {
302
419
  project: normalizeProject(raw.project || {}),
303
- fx: normalizeFx(raw.fx || {}),
420
+ fx,
304
421
  package: normalizePackage(raw.package || {}),
422
+ vendor: normalizeVendor(raw.vendor || {}),
423
+ vendorDir: fx.vendorDir,
305
424
  raw,
306
425
  };
307
426
  }
@@ -339,14 +458,26 @@ export function loadConfigFrom(dir) {
339
458
  project,
340
459
  fx,
341
460
  package: pkg,
461
+ vendor,
462
+ vendorDir,
342
463
  raw,
343
464
  } = parseConfig(fs.readFileSync(found, "utf8"));
344
- return { project, fx, package: pkg, raw, path: found };
465
+ return {
466
+ project,
467
+ fx,
468
+ package: pkg,
469
+ vendor,
470
+ vendorDir,
471
+ raw,
472
+ path: found,
473
+ };
345
474
  }
346
475
  return {
347
476
  project: { ...DEFAULT_PROJECT },
348
477
  fx: { ...DEFAULT_FX },
349
478
  package: normalizePackage({}),
479
+ vendor: [],
480
+ vendorDir: DEFAULT_VENDOR_DIR,
350
481
  raw: {},
351
482
  path: null,
352
483
  };
@@ -394,7 +525,10 @@ export function configComment(project) {
394
525
 
395
526
  export {
396
527
  addProjectExtension,
528
+ addVendorEntry,
397
529
  extensionKey,
398
530
  readProjectExtensions,
399
531
  removeProjectExtension,
532
+ removeProjectExtensions,
533
+ removeVendorEntry,
400
534
  } from "./config-edit.js";
package/src/decompiler.js CHANGED
@@ -376,13 +376,23 @@ class Decompiler {
376
376
  const proto = this.target.blocks[protoId];
377
377
  const mutation = (proto && proto.mutation) || {};
378
378
  const proccode = mutation.proccode || "procedure";
379
+ const ids = mutation.argumentids ? JSON.parse(mutation.argumentids) : [];
379
380
  const names = mutation.argumentnames
380
381
  ? JSON.parse(mutation.argumentnames)
381
382
  : [];
383
+ // Parameter types live on the prototype's argument reporter shadows, not
384
+ // in the mutation, so read them back or `loud: boolean` decompiles to a
385
+ // string parameter.
386
+ const params = names.map((name, index) => {
387
+ const reporter =
388
+ proto && this.target.blocks[firstOf(proto.inputs[ids[index]])];
389
+ return reporter && reporter.opcode === "argument_reporter_boolean"
390
+ ? `${name}: boolean`
391
+ : name;
392
+ });
382
393
  const baseName = proccode.split(" ")[0];
383
394
  const returning = this.returningProccodes.has(proccode) ? "returning " : "";
384
- const params = names.join(", ");
385
- return `${returning}function ${baseName}(${params}) {\n${this.renderStack(defBlock.next, 1)}\n}`;
395
+ return `${returning}function ${baseName}(${params.join(", ")}) {\n${this.renderStack(defBlock.next, 1)}\n}`;
386
396
  }
387
397
 
388
398
  decompileTarget(target, indent = 0) {
package/src/extensions.js CHANGED
@@ -165,18 +165,191 @@ export const Color = {
165
165
  hexToDecimal(hex) {
166
166
  return Color.rgbToDecimal(Color.hexToRgb(hex));
167
167
  },
168
+ hsvToRgb(hsv) {
169
+ let h = hsv.h % 360;
170
+ if (h < 0) h += 360;
171
+ const s = Math.max(0, Math.min(hsv.s, 1));
172
+ const v = Math.max(0, Math.min(hsv.v, 1));
173
+
174
+ const i = Math.floor(h / 60);
175
+ const f = h / 60 - i;
176
+ const p = v * (1 - s);
177
+ const q = v * (1 - s * f);
178
+ const t = v * (1 - s * (1 - f));
179
+
180
+ let r;
181
+ let g;
182
+ let b;
183
+ switch (i) {
184
+ default:
185
+ case 0:
186
+ r = v;
187
+ g = t;
188
+ b = p;
189
+ break;
190
+ case 1:
191
+ r = q;
192
+ g = v;
193
+ b = p;
194
+ break;
195
+ case 2:
196
+ r = p;
197
+ g = v;
198
+ b = t;
199
+ break;
200
+ case 3:
201
+ r = p;
202
+ g = q;
203
+ b = v;
204
+ break;
205
+ case 4:
206
+ r = t;
207
+ g = p;
208
+ b = v;
209
+ break;
210
+ case 5:
211
+ r = v;
212
+ g = p;
213
+ b = q;
214
+ break;
215
+ }
216
+
217
+ return {
218
+ r: Math.floor(r * 255),
219
+ g: Math.floor(g * 255),
220
+ b: Math.floor(b * 255),
221
+ };
222
+ },
223
+ rgbToHsv(rgb) {
224
+ const r = rgb.r / 255;
225
+ const g = rgb.g / 255;
226
+ const b = rgb.b / 255;
227
+ const x = Math.min(Math.min(r, g), b);
228
+ const v = Math.max(Math.max(r, g), b);
229
+
230
+ // For greys hue is arbitrarily reported as zero.
231
+ let h = 0;
232
+ let s = 0;
233
+ if (x !== v) {
234
+ const f = r === x ? g - b : g === x ? b - r : r - g;
235
+ const i = r === x ? 3 : g === x ? 5 : 1;
236
+ h = ((i - f / (v - x)) * 60) % 360;
237
+ s = (v - x) / v;
238
+ }
239
+
240
+ return { h: h, s: s, v: v };
241
+ },
242
+ mixRgb(rgb0, rgb1, fraction1) {
243
+ if (fraction1 <= 0) return rgb0;
244
+ if (fraction1 >= 1) return rgb1;
245
+ const fraction0 = 1 - fraction1;
246
+ return {
247
+ r: fraction0 * rgb0.r + fraction1 * rgb1.r,
248
+ g: fraction0 * rgb0.g + fraction1 * rgb1.g,
249
+ b: fraction0 * rgb0.b + fraction1 * rgb1.b,
250
+ };
251
+ },
168
252
  };
169
253
 
170
- /** `Scratch.Cast`. */
254
+ /**
255
+ * `Scratch.Cast` (scratch-vm's `util/cast`, as exposed to extensions).
256
+ *
257
+ * Each member mirrors the VM's implementation, including Scratch's own
258
+ * coercion rules — `Cast.toBoolean("0")` is false, `Cast.compare` is
259
+ * case-insensitive for text and numeric otherwise.
260
+ */
171
261
  export const Cast = {
172
262
  toString: (value) => String(value),
173
263
  toNumber: (value) => {
264
+ if (typeof value === "number") return Number.isNaN(value) ? 0 : value;
174
265
  const n = Number(value);
175
266
  return Number.isNaN(n) ? 0 : n;
176
267
  },
177
- toBoolean: (value) =>
178
- typeof value === "boolean" ? value : String(value) === "true",
179
- toList: (value) => (Array.isArray(value) ? value : [value]),
268
+ toBoolean: (value) => {
269
+ if (typeof value === "boolean") return value;
270
+ if (typeof value === "string") {
271
+ // These exact strings are false in Scratch; everything else is true.
272
+ if (value === "" || value === "0" || value.toLowerCase() === "false")
273
+ return false;
274
+ return true;
275
+ }
276
+ return Boolean(value);
277
+ },
278
+ /** 0 when equal, negative when `a` sorts first, positive otherwise. */
279
+ compare: (a, b) => {
280
+ // Scratch does not treat "0", "\t" or strings containing them as zero.
281
+ const isNotActuallyZero = (value) => {
282
+ if (typeof value !== "string") return false;
283
+ for (let i = 0; i < value.length; i++) {
284
+ const code = value.charCodeAt(i);
285
+ if (code === 48 || code === 9) return false;
286
+ }
287
+ return true;
288
+ };
289
+ let n1 = Number(a);
290
+ let n2 = Number(b);
291
+ if (n1 === 0 && isNotActuallyZero(a)) n1 = NaN;
292
+ else if (n2 === 0 && isNotActuallyZero(b)) n2 = NaN;
293
+ if (Number.isNaN(n1) || Number.isNaN(n2)) {
294
+ const s1 = String(a).toLowerCase();
295
+ const s2 = String(b).toLowerCase();
296
+ if (s1 < s2) return -1;
297
+ if (s1 > s2) return 1;
298
+ return 0;
299
+ }
300
+ if (
301
+ (n1 === Infinity && n2 === Infinity) ||
302
+ (n1 === -Infinity && n2 === -Infinity)
303
+ )
304
+ return 0;
305
+ return n1 - n2;
306
+ },
307
+ toRgbColorList: (value) => {
308
+ const color = Cast.toRgbColorObject(value);
309
+ return [color.r, color.g, color.b];
310
+ },
311
+ toRgbColorObject: (value) => {
312
+ let color;
313
+ if (typeof value === "object") value += "";
314
+ if (typeof value === "string" && value.substring(0, 1) === "#") {
315
+ color = Color.hexToRgb(value);
316
+ // A string that only looks like a hex colour casts to black.
317
+ if (!color) color = { r: 0, g: 0, b: 0, a: 255 };
318
+ } else {
319
+ color = Color.decimalToRgb(Cast.toNumber(value));
320
+ }
321
+ return color;
322
+ },
323
+ isWhiteSpace: (value) =>
324
+ value === null || (typeof value === "string" && value.trim().length === 0),
325
+ isInt: (value) => {
326
+ if (typeof value === "number") {
327
+ if (Number.isNaN(value)) return true;
328
+ return value === Math.floor(value);
329
+ }
330
+ if (typeof value === "boolean") return true;
331
+ if (typeof value === "string") return value.indexOf(".") < 0;
332
+ return false;
333
+ },
334
+ get LIST_INVALID() {
335
+ return "INVALID";
336
+ },
337
+ get LIST_ALL() {
338
+ return "ALL";
339
+ },
340
+ toListIndex: (index, length, acceptAll) => {
341
+ if (typeof index !== "number") {
342
+ if (index === "all") return acceptAll ? Cast.LIST_ALL : Cast.LIST_INVALID;
343
+ if (index === "last") return length > 0 ? length : Cast.LIST_INVALID;
344
+ if (index === "random" || index === "any")
345
+ return length > 0
346
+ ? 1 + Math.floor(Math.random() * length)
347
+ : Cast.LIST_INVALID;
348
+ }
349
+ index = Math.floor(Cast.toNumber(index));
350
+ if (index < 1 || index > length) return Cast.LIST_INVALID;
351
+ return index;
352
+ },
180
353
  };
181
354
 
182
355
  // -------------------------------------------------------------- translation
@@ -184,23 +357,35 @@ export const Cast = {
184
357
  /**
185
358
  * Map a `Scratch.ArgumentType` onto a Triflare Fx input descriptor.
186
359
  * @param {object} arg
187
- * @returns {{type: string}}
360
+ * @returns {{type: string, default?: string|number}}
188
361
  */
189
362
  function argumentSpec(arg) {
190
363
  const type = arg && arg.type;
364
+ let spec;
191
365
  switch (type) {
192
366
  case ArgumentType.BOOLEAN:
193
- return { type: "boolean" };
194
- case ArgumentType.NUMBER:
195
- return { type: "number" };
367
+ spec = { type: "boolean" };
368
+ break;
196
369
  case ArgumentType.ANGLE:
197
- return { type: "angle" };
370
+ spec = { type: "angle" };
371
+ break;
198
372
  case ArgumentType.COLOR:
199
- return { type: "color" };
200
- // Matrix, note, costume, backdrop, sound and string all travel as text.
373
+ spec = { type: "color" };
374
+ break;
375
+ // A note is a MIDI number, so it takes numeric input like `NUMBER`.
376
+ case ArgumentType.NOTE:
377
+ case ArgumentType.NUMBER:
378
+ spec = { type: "number" };
379
+ break;
380
+ // Matrix, costume, sound, PM menus and plain strings all travel as text.
201
381
  default:
202
- return { type: "string" };
382
+ spec = { type: "string" };
203
383
  }
384
+ // Extensions declare the value a call falls back to when it omits the arg.
385
+ if (arg && arg.defaultValue !== undefined && arg.defaultValue !== null) {
386
+ spec.default = arg.defaultValue;
387
+ }
388
+ return spec;
204
389
  }
205
390
 
206
391
  /**
@@ -270,10 +455,14 @@ function registerInfo(catalog, info, sourceName) {
270
455
  extension: id,
271
456
  };
272
457
  if (kind === "hat") {
273
- entry.args = Object.entries(block.arguments || {}).map(([name, arg]) => ({
274
- input: name,
275
- type: argumentSpec(arg).type,
276
- }));
458
+ entry.args = Object.entries(block.arguments || {}).map(([name, arg]) => {
459
+ const spec = argumentSpec(arg);
460
+ return {
461
+ input: name,
462
+ type: spec.type,
463
+ ...(spec.default !== undefined ? { default: spec.default } : {}),
464
+ };
465
+ });
277
466
  } else if (
278
467
  block.blockType === BlockType.CONDITIONAL ||
279
468
  block.blockType === BlockType.LOOP
@@ -331,15 +520,20 @@ function permissive(label) {
331
520
  return proxy;
332
521
  }
333
522
 
334
- function makeSandbox(registered, { unsandboxed = false } = {}) {
523
+ export function makeSandbox(registered, { unsandboxed = false } = {}) {
524
+ /** A function that rejects: the sandbox has no browser or network. */
525
+ const unavailable = (name) => () =>
526
+ Promise.reject(new Error(`${name} is not available while compiling`));
335
527
  const Scratch = {
336
528
  extensions: {
337
529
  register(extension) {
338
530
  registered.push(extension);
339
531
  },
340
- // PenguinMod sets `unsandboxed: true` for unsandboxed loads and exposes
341
- // `isPenguinMod`, so extensions can adapt to whichever loader runs them.
342
- unsandboxed: Boolean(unsandboxed),
532
+ // TurboWarp documents `unsandboxed` as true when unsandboxed and
533
+ // undefined otherwise — never false.
534
+ unsandboxed: unsandboxed || undefined,
535
+ // PenguinMod's unsandboxed loader sets this; exposing it in both modes
536
+ // keeps PenguinMod-only extensions loadable here.
343
537
  isPenguinMod: true,
344
538
  },
345
539
  BlockType,
@@ -351,15 +545,31 @@ function makeSandbox(registered, { unsandboxed = false } = {}) {
351
545
  Clone,
352
546
  Color,
353
547
  Cast,
354
- translate: (value) => {
355
- if (typeof value === "string") return value;
356
- if (
357
- value &&
358
- typeof value === "object" &&
359
- typeof value.default === "string"
360
- )
361
- return value.default;
362
- return value == null ? "" : String(value);
548
+ // tw-l10n exposes `translate(message, args)`, `translate.setup({…})` and a
549
+ // read-only `translate.language` on the same function. Generated l10n
550
+ // bundles call `setup({…})` at module scope before registering.
551
+ translate: Object.defineProperty(
552
+ Object.assign(
553
+ (message) => {
554
+ if (typeof message === "string") return message;
555
+ if (message && typeof message === "object") {
556
+ return typeof message.default === "string" ? message.default : "";
557
+ }
558
+ throw new Error("unsupported data type in translate()");
559
+ },
560
+ { setup() {} },
561
+ ),
562
+ "language",
563
+ { get: () => "en", enumerable: true },
564
+ ),
565
+ // `Scratch.external` loads sibling resources; the sandbox has no browser,
566
+ // so its entries reject instead of resolving like a module.
567
+ external: {
568
+ importModule: unavailable("Scratch.external"),
569
+ fetch: unavailable("Scratch.external"),
570
+ dataURL: unavailable("Scratch.external"),
571
+ blob: unavailable("Scratch.external"),
572
+ evalAndReturn: unavailable("Scratch.external"),
363
573
  },
364
574
  canFetch: () => Promise.resolve(false),
365
575
  fetch: () =>
@@ -547,6 +757,7 @@ const BASE64_PAYLOAD = /^[A-Za-z0-9+/=\s]*$/;
547
757
  * @param {string} source
548
758
  * @returns {string}
549
759
  */
760
+ /** Encode extension source as a self-contained JavaScript data URI. */
550
761
  export function extensionDataUri(source) {
551
762
  const base64 = Buffer.from(String(source), "utf8").toString("base64");
552
763
  return `data:text/javascript;base64,${base64}`;
@@ -572,8 +783,13 @@ export function readExtensionDataUri(uri) {
572
783
  }
573
784
  }
574
785
 
575
- /** Resolve a config entry to source text. */
576
- async function resolveSource(entry, baseDir, fetchImpl) {
786
+ /**
787
+ * Resolve a config entry to source text and the original bytes.
788
+ *
789
+ * The raw buffer is retained so vendoring can lock exactly what the server
790
+ * returned, even when the text decoder strips a byte-order mark.
791
+ */
792
+ export async function resolveSource(entry, baseDir, fetchImpl) {
577
793
  const value = String(entry);
578
794
  if (/^data:/i.test(value)) {
579
795
  const source = readExtensionDataUri(value);
@@ -581,7 +797,13 @@ async function resolveSource(entry, baseDir, fetchImpl) {
581
797
  throw new Error(
582
798
  "unsupported data URI; expected base64 or percent-encoded JavaScript",
583
799
  );
584
- return { source, remote: false, dataUri: true, url: value };
800
+ return {
801
+ source,
802
+ buffer: Buffer.from(source, "utf8"),
803
+ remote: false,
804
+ dataUri: true,
805
+ url: value,
806
+ };
585
807
  }
586
808
  if (/^https?:\/\//i.test(value)) {
587
809
  const fetchFn = fetchImpl || globalThis.fetch;
@@ -590,13 +812,24 @@ async function resolveSource(entry, baseDir, fetchImpl) {
590
812
  const response = await fetchFn(value);
591
813
  if (!response.ok)
592
814
  throw new Error(`HTTP ${response.status} ${response.statusText}`.trim());
593
- return { source: await response.text(), remote: true, url: value };
815
+ const buffer =
816
+ typeof response.arrayBuffer === "function"
817
+ ? Buffer.from(await response.arrayBuffer())
818
+ : Buffer.from(await response.text(), "utf8");
819
+ return {
820
+ source: buffer.toString("utf8").replace(/^\uFEFF/, ""),
821
+ buffer,
822
+ remote: true,
823
+ url: value,
824
+ };
594
825
  }
595
826
  const resolved = path.isAbsolute(value)
596
827
  ? value
597
828
  : path.resolve(baseDir, value);
829
+ const buffer = fs.readFileSync(resolved);
598
830
  return {
599
- source: fs.readFileSync(resolved, "utf8"),
831
+ source: buffer.toString("utf8"),
832
+ buffer,
600
833
  remote: false,
601
834
  url: value,
602
835
  file: resolved,
@@ -640,19 +873,39 @@ async function evaluateExtensionAny(source, filename) {
640
873
  }
641
874
  }
642
875
 
876
+ /**
877
+ * Inspect extension source without running it in the Node.js process.
878
+ * Useful to propose an extension id while vendoring or migrating.
879
+ * @param {string} source
880
+ * @param {string} [filename]
881
+ * @returns {Promise<{infos: object[], unsandboxed: boolean}>}
882
+ */
883
+ export async function inspectExtension(source, filename = "extension") {
884
+ return evaluateExtensionAny(source, filename);
885
+ }
886
+
643
887
  /**
644
888
  * Load a list of extension references.
645
889
  *
646
890
  * @param {Array<string>} list Paths, `http(s)://` URLs or `data:` URIs.
647
- * @param {{baseDir?: string, fetchImpl?: typeof fetch, expectUnsandboxed?: string|string[]}} [options]
891
+ * @param {{baseDir?: string, fetchImpl?: typeof fetch, expectUnsandboxed?: string|string[], reportUnmatchedUnsandboxed?: boolean}} [options]
648
892
  * `expectUnsandboxed` acknowledges extensions that are known to require
649
- * unsandboxed mode, silencing the warning for them.
650
- * @returns {Promise<{catalog: Record<string, object>, extensionUrls: Record<string, string>, warnings: string[], ids: string[], unsandboxed: string[]}>}
893
+ * unsandboxed mode, silencing the warning for them. Set
894
+ * `reportUnmatchedUnsandboxed` to false when a caller combines declarations
895
+ * from several loaders and reports unmatched names itself.
896
+ * @returns {Promise<{catalog: Record<string, object>, extensionUrls: Record<string, string>, warnings: string[], ids: string[], unsandboxed: string[], matchedUnsandboxed: string[], unnecessaryUnsandboxed: string[]}>}
651
897
  * `unsandboxed` holds the ids of extensions that only load unsandboxed.
898
+ * `matchedUnsandboxed` and `unnecessaryUnsandboxed` let callers combine
899
+ * declarations across multiple loaders without double-reporting them.
652
900
  */
653
901
  export async function loadExtensions(
654
902
  list,
655
- { baseDir = process.cwd(), fetchImpl, expectUnsandboxed = [] } = {},
903
+ {
904
+ baseDir = process.cwd(),
905
+ fetchImpl,
906
+ expectUnsandboxed = [],
907
+ reportUnmatchedUnsandboxed = true,
908
+ } = {},
656
909
  ) {
657
910
  const catalog = {};
658
911
  const extensionUrls = {};
@@ -731,17 +984,27 @@ export async function loadExtensions(
731
984
  "project.unsandboxed in fx.yml once you trust it)",
732
985
  );
733
986
  }
734
- for (const name of declared) {
735
- if (declaredNotNeeded.has(name)) {
736
- warnings.push(
737
- `project.unsandboxed entry "${name}" loads in the sandbox; remove it from fx.yml`,
738
- );
739
- } else if (!declaredSeen.has(name)) {
740
- warnings.push(
741
- `project.unsandboxed entry "${name}" does not match any loaded extension`,
742
- );
987
+ if (reportUnmatchedUnsandboxed) {
988
+ for (const name of declared) {
989
+ if (declaredNotNeeded.has(name)) {
990
+ warnings.push(
991
+ `project.unsandboxed entry "${name}" loads in the sandbox; remove it from fx.yml`,
992
+ );
993
+ } else if (!declaredSeen.has(name)) {
994
+ warnings.push(
995
+ `project.unsandboxed entry "${name}" does not match any loaded extension`,
996
+ );
997
+ }
743
998
  }
744
999
  }
745
1000
 
746
- return { catalog, extensionUrls, warnings, ids, unsandboxed };
1001
+ return {
1002
+ catalog,
1003
+ extensionUrls,
1004
+ warnings,
1005
+ ids,
1006
+ unsandboxed,
1007
+ matchedUnsandboxed: [...declaredSeen],
1008
+ unnecessaryUnsandboxed: [...declaredNotNeeded],
1009
+ };
747
1010
  }