@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.
- package/README.md +32 -0
- package/bin/fx.js +4 -1160
- package/package.json +9 -3
- package/product.yml +1 -1
- package/src/brand.js +9 -3
- package/src/build.js +2 -1
- package/src/catalog.js +117 -140
- package/src/cli/commands/build.js +98 -0
- package/src/cli/commands/catalog.js +42 -0
- package/src/cli/commands/check.js +44 -0
- package/src/cli/commands/completion.js +54 -0
- package/src/cli/commands/decompile.js +266 -0
- package/src/cli/commands/dev.js +179 -0
- package/src/cli/commands/extension.js +110 -0
- package/src/cli/commands/index.js +39 -0
- package/src/cli/commands/init.js +169 -0
- package/src/cli/commands/package.js +137 -0
- package/src/cli/commands/sprite/add.js +85 -0
- package/src/cli/commands/sprite/build.js +62 -0
- package/src/cli/commands/sprite/helpers.js +66 -0
- package/src/cli/commands/sprite/index.js +29 -0
- package/src/cli/commands/sprite/list.js +31 -0
- package/src/cli/commands/sprite/remove.js +51 -0
- package/src/cli/commands/vendor/add.js +98 -0
- package/src/cli/commands/vendor/helpers.js +50 -0
- package/src/cli/commands/vendor/index.js +39 -0
- package/src/cli/commands/vendor/install.js +64 -0
- package/src/cli/commands/vendor/list.js +45 -0
- package/src/cli/commands/vendor/migrate.js +47 -0
- package/src/cli/commands/vendor/outdated.js +51 -0
- package/src/cli/commands/vendor/remove.js +34 -0
- package/src/cli/commands/vendor/update.js +49 -0
- package/src/cli/commands/vendor/verify.js +44 -0
- package/src/cli/completion/bash.js +10 -0
- package/src/cli/completion/fish.js +9 -0
- package/src/cli/completion/index.js +97 -0
- package/src/cli/completion/zsh.js +11 -0
- package/src/cli/context.js +257 -0
- package/src/cli/define-command.js +140 -0
- package/src/cli/errors.js +111 -0
- package/src/cli/format.js +31 -0
- package/src/cli/prompts.js +183 -0
- package/src/cli/reporter.js +194 -0
- package/src/cli/run.js +248 -0
- package/src/cli/templates/index.js +112 -0
- package/src/cli/theme.js +84 -0
- package/src/compiler.js +32 -3
- package/src/config-edit.js +280 -1
- package/src/config.js +137 -3
- package/src/decompiler.js +12 -2
- package/src/extensions.js +311 -48
- package/src/index.js +21 -0
- package/src/vendor.js +1323 -0
- 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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
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"
|
|
179
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
return { type: "number" };
|
|
367
|
+
spec = { type: "boolean" };
|
|
368
|
+
break;
|
|
196
369
|
case ArgumentType.ANGLE:
|
|
197
|
-
|
|
370
|
+
spec = { type: "angle" };
|
|
371
|
+
break;
|
|
198
372
|
case ArgumentType.COLOR:
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
//
|
|
341
|
-
//
|
|
342
|
-
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
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
/**
|
|
576
|
-
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
{
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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 {
|
|
1001
|
+
return {
|
|
1002
|
+
catalog,
|
|
1003
|
+
extensionUrls,
|
|
1004
|
+
warnings,
|
|
1005
|
+
ids,
|
|
1006
|
+
unsandboxed,
|
|
1007
|
+
matchedUnsandboxed: [...declaredSeen],
|
|
1008
|
+
unnecessaryUnsandboxed: [...declaredNotNeeded],
|
|
1009
|
+
};
|
|
747
1010
|
}
|