@triflare/fx 1.0.1 → 1.0.3
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/bin/fx.js +2 -0
- package/package.json +5 -1
- package/product.yml +1 -1
- package/src/build.js +2 -1
- package/src/catalog.js +117 -140
- package/src/compiler.js +32 -3
- package/src/decompiler.js +12 -2
- package/src/dev.js +249 -3
- package/src/extensions.js +239 -29
- package/vendor/pm-packager-dist/.fx-build.json +1 -1
package/bin/fx.js
CHANGED
|
@@ -217,6 +217,8 @@ async function cmdBuild(args) {
|
|
|
217
217
|
extensionUrls,
|
|
218
218
|
useConfigFile: !args.flags["no-config"],
|
|
219
219
|
});
|
|
220
|
+
for (const warning of result.warnings)
|
|
221
|
+
process.stderr.write(`warning: ${warning}\n`);
|
|
220
222
|
|
|
221
223
|
const explicit = args.flags.o || args.flags.output;
|
|
222
224
|
const output =
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@triflare/fx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Code PenguinMod projects with text.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
"bin": {
|
|
9
9
|
"fx": "bin/fx.js"
|
|
10
10
|
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/triflare/fx.git"
|
|
14
|
+
},
|
|
11
15
|
"exports": {
|
|
12
16
|
".": "./src/index.js",
|
|
13
17
|
"./catalog": "./src/catalog.js",
|
package/product.yml
CHANGED
package/src/build.js
CHANGED
|
@@ -98,7 +98,7 @@ function resolveSources(options) {
|
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
100
|
* @param {BuildOptions} [options]
|
|
101
|
-
* @returns {{project: object, assets: Map<string, Buffer>, sb3: Buffer, config: object, fx: object, ast: object, files: string[]}}
|
|
101
|
+
* @returns {{project: object, assets: Map<string, Buffer>, sb3: Buffer, config: object, fx: object, ast: object, files: string[], warnings: string[]}}
|
|
102
102
|
*/
|
|
103
103
|
export function build(options = {}) {
|
|
104
104
|
const { dir, entries } = resolveSources(options);
|
|
@@ -155,6 +155,7 @@ export function build(options = {}) {
|
|
|
155
155
|
fx: fxConfig,
|
|
156
156
|
ast,
|
|
157
157
|
files: entries.map((e) => e.file),
|
|
158
|
+
warnings: compiler.warnings,
|
|
158
159
|
};
|
|
159
160
|
}
|
|
160
161
|
|
package/src/catalog.js
CHANGED
|
@@ -1,21 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Triflare Fx block catalog.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* mutation it requires. The compiler, the decompiler and the editor tooling all
|
|
8
|
-
* read from here instead of hard-coding opcode tables.
|
|
4
|
+
* The single source of truth for how a `category.function(...)` call maps onto
|
|
5
|
+
* a Scratch/PenguinMod opcode: shape, inputs, dropdown fields and mutations.
|
|
6
|
+
* The compiler, the decompiler and the editor tooling all read from here.
|
|
9
7
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* Entry conventions:
|
|
9
|
+
* - `kind` is a command unless the entry says otherwise, so only reporters,
|
|
10
|
+
* booleans and hats spell it out (see the default applied below).
|
|
11
|
+
* - `inputs` maps an input name to one of the shared descriptors below.
|
|
12
|
+
* - `fields` maps a dropdown field name to its default value.
|
|
13
|
+
* - `args` describes a hat's parameters (`field` or `input`).
|
|
14
|
+
* - `extension` names the providing extension for non-core opcodes.
|
|
15
|
+
* - `dynamicNext` / `stopBlock` flag blocks with special stack behaviour.
|
|
14
16
|
*
|
|
15
|
-
*
|
|
17
|
+
* Input descriptors:
|
|
18
|
+
* NUMBER, STRING, BOOLEAN, ANGLE, COLOR, INTEGER, POSITIVE, WHOLE, SUBSTACK
|
|
19
|
+
* and BROADCAST are shared `{type}` descriptors;
|
|
20
|
+
* `{type: 'menu', menu, field, default}` wraps a dropdown shadow block;
|
|
21
|
+
* `{type: 'variable'|'list'}` takes a variable/list reference.
|
|
16
22
|
*/
|
|
17
23
|
|
|
18
|
-
//
|
|
24
|
+
// Reusable input descriptors -----------------------------------------------
|
|
25
|
+
const NUMBER = { type: "number" };
|
|
26
|
+
const STRING = { type: "string" };
|
|
27
|
+
const BOOLEAN = { type: "boolean" };
|
|
28
|
+
const ANGLE = { type: "angle" };
|
|
29
|
+
const COLOR = { type: "color" };
|
|
30
|
+
const INTEGER = { type: "integer" };
|
|
31
|
+
const POSITIVE = { type: "positive" };
|
|
32
|
+
const WHOLE = { type: "whole" };
|
|
33
|
+
const SUBSTACK = { type: "substack" };
|
|
34
|
+
const BROADCAST = { type: "broadcast" };
|
|
35
|
+
|
|
36
|
+
// Reusable dropdown (shadow menu) descriptors -------------------------------
|
|
19
37
|
const GOTO_MENU = {
|
|
20
38
|
type: "menu",
|
|
21
39
|
menu: "motion_goto_menu",
|
|
@@ -85,82 +103,67 @@ const OF_MENU = {
|
|
|
85
103
|
default: "_stage_",
|
|
86
104
|
};
|
|
87
105
|
|
|
88
|
-
|
|
89
|
-
export const CATALOG = {
|
|
106
|
+
const CORE_CATALOG = {
|
|
90
107
|
// ---------------------------------------------------------------- motion
|
|
91
108
|
"motion.moveSteps": {
|
|
92
109
|
opcode: "motion_movesteps",
|
|
93
|
-
|
|
94
|
-
inputs: { STEPS: { type: "number" } },
|
|
110
|
+
inputs: { STEPS: NUMBER },
|
|
95
111
|
},
|
|
96
112
|
"motion.turnRight": {
|
|
97
113
|
opcode: "motion_turnright",
|
|
98
|
-
|
|
99
|
-
inputs: { DEGREES: { type: "number" } },
|
|
114
|
+
inputs: { DEGREES: NUMBER },
|
|
100
115
|
},
|
|
101
116
|
"motion.turnLeft": {
|
|
102
117
|
opcode: "motion_turnleft",
|
|
103
|
-
|
|
104
|
-
inputs: { DEGREES: { type: "number" } },
|
|
118
|
+
inputs: { DEGREES: NUMBER },
|
|
105
119
|
},
|
|
106
120
|
"motion.goToXY": {
|
|
107
121
|
opcode: "motion_gotoxy",
|
|
108
|
-
|
|
109
|
-
inputs: { X: { type: "number" }, Y: { type: "number" } },
|
|
122
|
+
inputs: { X: NUMBER, Y: NUMBER },
|
|
110
123
|
},
|
|
111
124
|
"motion.goTo": {
|
|
112
125
|
opcode: "motion_goto",
|
|
113
|
-
kind: "command",
|
|
114
126
|
inputs: { TO: GOTO_MENU },
|
|
115
127
|
},
|
|
116
128
|
"motion.glideToXY": {
|
|
117
129
|
opcode: "motion_glidesecstoxy",
|
|
118
|
-
kind: "command",
|
|
119
130
|
inputs: {
|
|
120
|
-
SECS:
|
|
121
|
-
X:
|
|
122
|
-
Y:
|
|
131
|
+
SECS: NUMBER,
|
|
132
|
+
X: NUMBER,
|
|
133
|
+
Y: NUMBER,
|
|
123
134
|
},
|
|
124
135
|
},
|
|
125
136
|
"motion.glideTo": {
|
|
126
137
|
opcode: "motion_glideto",
|
|
127
|
-
|
|
128
|
-
inputs: { SECS: { type: "number" }, TO: GLIDE_MENU },
|
|
138
|
+
inputs: { SECS: NUMBER, TO: GLIDE_MENU },
|
|
129
139
|
},
|
|
130
140
|
"motion.pointInDirection": {
|
|
131
141
|
opcode: "motion_pointindirection",
|
|
132
|
-
|
|
133
|
-
inputs: { DIRECTION: { type: "angle" } },
|
|
142
|
+
inputs: { DIRECTION: ANGLE },
|
|
134
143
|
},
|
|
135
144
|
"motion.pointTowards": {
|
|
136
145
|
opcode: "motion_pointtowards",
|
|
137
|
-
kind: "command",
|
|
138
146
|
inputs: { TOWARDS: POINT_MENU },
|
|
139
147
|
},
|
|
140
148
|
"motion.changeX": {
|
|
141
149
|
opcode: "motion_changexby",
|
|
142
|
-
|
|
143
|
-
inputs: { DX: { type: "number" } },
|
|
150
|
+
inputs: { DX: NUMBER },
|
|
144
151
|
},
|
|
145
152
|
"motion.setX": {
|
|
146
153
|
opcode: "motion_setx",
|
|
147
|
-
|
|
148
|
-
inputs: { X: { type: "number" } },
|
|
154
|
+
inputs: { X: NUMBER },
|
|
149
155
|
},
|
|
150
156
|
"motion.changeY": {
|
|
151
157
|
opcode: "motion_changeyby",
|
|
152
|
-
|
|
153
|
-
inputs: { DY: { type: "number" } },
|
|
158
|
+
inputs: { DY: NUMBER },
|
|
154
159
|
},
|
|
155
160
|
"motion.setY": {
|
|
156
161
|
opcode: "motion_sety",
|
|
157
|
-
|
|
158
|
-
inputs: { Y: { type: "number" } },
|
|
162
|
+
inputs: { Y: NUMBER },
|
|
159
163
|
},
|
|
160
|
-
"motion.ifOnEdgeBounce": { opcode: "motion_ifonedgebounce"
|
|
164
|
+
"motion.ifOnEdgeBounce": { opcode: "motion_ifonedgebounce" },
|
|
161
165
|
"motion.setRotationStyle": {
|
|
162
166
|
opcode: "motion_setrotationstyle",
|
|
163
|
-
kind: "command",
|
|
164
167
|
fields: { STYLE: "all around" },
|
|
165
168
|
},
|
|
166
169
|
"motion.xPosition": { opcode: "motion_xposition", kind: "reporter" },
|
|
@@ -170,79 +173,65 @@ export const CATALOG = {
|
|
|
170
173
|
// ----------------------------------------------------------------- looks
|
|
171
174
|
"looks.say": {
|
|
172
175
|
opcode: "looks_say",
|
|
173
|
-
|
|
174
|
-
inputs: { MESSAGE: { type: "string" } },
|
|
176
|
+
inputs: { MESSAGE: STRING },
|
|
175
177
|
},
|
|
176
178
|
"looks.sayForSecs": {
|
|
177
179
|
opcode: "looks_sayforsecs",
|
|
178
|
-
|
|
179
|
-
inputs: { MESSAGE: { type: "string" }, SECS: { type: "number" } },
|
|
180
|
+
inputs: { MESSAGE: STRING, SECS: NUMBER },
|
|
180
181
|
},
|
|
181
182
|
"looks.think": {
|
|
182
183
|
opcode: "looks_think",
|
|
183
|
-
|
|
184
|
-
inputs: { MESSAGE: { type: "string" } },
|
|
184
|
+
inputs: { MESSAGE: STRING },
|
|
185
185
|
},
|
|
186
186
|
"looks.thinkForSecs": {
|
|
187
187
|
opcode: "looks_thinkforsecs",
|
|
188
|
-
|
|
189
|
-
inputs: { MESSAGE: { type: "string" }, SECS: { type: "number" } },
|
|
188
|
+
inputs: { MESSAGE: STRING, SECS: NUMBER },
|
|
190
189
|
},
|
|
191
190
|
"looks.switchCostume": {
|
|
192
191
|
opcode: "looks_switchcostumeto",
|
|
193
|
-
kind: "command",
|
|
194
192
|
inputs: { COSTUME: COSTUME_MENU },
|
|
195
193
|
},
|
|
196
|
-
"looks.nextCostume": { opcode: "looks_nextcostume"
|
|
194
|
+
"looks.nextCostume": { opcode: "looks_nextcostume" },
|
|
197
195
|
"looks.switchBackdrop": {
|
|
198
196
|
opcode: "looks_switchbackdropto",
|
|
199
|
-
kind: "command",
|
|
200
197
|
inputs: { BACKDROP: BACKDROP_MENU },
|
|
201
198
|
},
|
|
202
199
|
"looks.switchBackdropAndWait": {
|
|
203
200
|
opcode: "looks_switchbackdroptoandwait",
|
|
204
|
-
kind: "command",
|
|
205
201
|
inputs: { BACKDROP: BACKDROP_MENU },
|
|
206
202
|
},
|
|
207
|
-
"looks.nextBackdrop": { opcode: "looks_nextbackdrop"
|
|
203
|
+
"looks.nextBackdrop": { opcode: "looks_nextbackdrop" },
|
|
208
204
|
"looks.changeSize": {
|
|
209
205
|
opcode: "looks_changesizeby",
|
|
210
|
-
|
|
211
|
-
inputs: { CHANGE: { type: "number" } },
|
|
206
|
+
inputs: { CHANGE: NUMBER },
|
|
212
207
|
},
|
|
213
208
|
"looks.setSize": {
|
|
214
209
|
opcode: "looks_setsizeto",
|
|
215
|
-
|
|
216
|
-
inputs: { SIZE: { type: "number" } },
|
|
210
|
+
inputs: { SIZE: NUMBER },
|
|
217
211
|
},
|
|
218
212
|
"looks.changeEffect": {
|
|
219
213
|
opcode: "looks_changeeffectby",
|
|
220
|
-
kind: "command",
|
|
221
214
|
fields: { EFFECT: "color" },
|
|
222
|
-
inputs: { CHANGE:
|
|
215
|
+
inputs: { CHANGE: NUMBER },
|
|
223
216
|
},
|
|
224
217
|
"looks.setEffect": {
|
|
225
218
|
opcode: "looks_seteffectto",
|
|
226
|
-
kind: "command",
|
|
227
219
|
fields: { EFFECT: "color" },
|
|
228
|
-
inputs: { VALUE:
|
|
220
|
+
inputs: { VALUE: NUMBER },
|
|
229
221
|
},
|
|
230
222
|
"looks.clearEffects": {
|
|
231
223
|
opcode: "looks_cleargraphiceffects",
|
|
232
|
-
kind: "command",
|
|
233
224
|
},
|
|
234
|
-
"looks.show": { opcode: "looks_show"
|
|
235
|
-
"looks.hide": { opcode: "looks_hide"
|
|
225
|
+
"looks.show": { opcode: "looks_show" },
|
|
226
|
+
"looks.hide": { opcode: "looks_hide" },
|
|
236
227
|
"looks.goToFrontLayer": {
|
|
237
228
|
opcode: "looks_gotofrontback",
|
|
238
|
-
kind: "command",
|
|
239
229
|
fields: { FRONT_BACK: "front" },
|
|
240
230
|
},
|
|
241
231
|
"looks.goLayers": {
|
|
242
232
|
opcode: "looks_goforwardbackwardlayers",
|
|
243
|
-
kind: "command",
|
|
244
233
|
fields: { FORWARD_BACKWARD: "forward" },
|
|
245
|
-
inputs: { NUM:
|
|
234
|
+
inputs: { NUM: INTEGER },
|
|
246
235
|
},
|
|
247
236
|
"looks.costumeNumber": {
|
|
248
237
|
opcode: "looks_costumenumbername",
|
|
@@ -259,37 +248,31 @@ export const CATALOG = {
|
|
|
259
248
|
// ----------------------------------------------------------------- sound
|
|
260
249
|
"sound.play": {
|
|
261
250
|
opcode: "sound_play",
|
|
262
|
-
kind: "command",
|
|
263
251
|
inputs: { SOUND_MENU: SOUND_MENU },
|
|
264
252
|
},
|
|
265
253
|
"sound.playUntilDone": {
|
|
266
254
|
opcode: "sound_playuntildone",
|
|
267
|
-
kind: "command",
|
|
268
255
|
inputs: { SOUND_MENU: SOUND_MENU },
|
|
269
256
|
},
|
|
270
|
-
"sound.stopAllSounds": { opcode: "sound_stopallsounds"
|
|
257
|
+
"sound.stopAllSounds": { opcode: "sound_stopallsounds" },
|
|
271
258
|
"sound.changeEffect": {
|
|
272
259
|
opcode: "sound_changeeffectby",
|
|
273
|
-
kind: "command",
|
|
274
260
|
fields: { EFFECT: "pitch" },
|
|
275
|
-
inputs: { VALUE:
|
|
261
|
+
inputs: { VALUE: NUMBER },
|
|
276
262
|
},
|
|
277
263
|
"sound.setEffect": {
|
|
278
264
|
opcode: "sound_seteffectto",
|
|
279
|
-
kind: "command",
|
|
280
265
|
fields: { EFFECT: "pitch" },
|
|
281
|
-
inputs: { VALUE:
|
|
266
|
+
inputs: { VALUE: NUMBER },
|
|
282
267
|
},
|
|
283
|
-
"sound.clearEffects": { opcode: "sound_cleareffects"
|
|
268
|
+
"sound.clearEffects": { opcode: "sound_cleareffects" },
|
|
284
269
|
"sound.changeVolume": {
|
|
285
270
|
opcode: "sound_changevolumeby",
|
|
286
|
-
|
|
287
|
-
inputs: { VOLUME: { type: "number" } },
|
|
271
|
+
inputs: { VOLUME: NUMBER },
|
|
288
272
|
},
|
|
289
273
|
"sound.setVolume": {
|
|
290
274
|
opcode: "sound_setvolumeto",
|
|
291
|
-
|
|
292
|
-
inputs: { VOLUME: { type: "number" } },
|
|
275
|
+
inputs: { VOLUME: NUMBER },
|
|
293
276
|
},
|
|
294
277
|
"sound.volume": { opcode: "sound_volume", kind: "reporter" },
|
|
295
278
|
|
|
@@ -302,12 +285,12 @@ export const CATALOG = {
|
|
|
302
285
|
"sensing.touchingColor": {
|
|
303
286
|
opcode: "sensing_touchingcolor",
|
|
304
287
|
kind: "boolean",
|
|
305
|
-
inputs: { COLOR:
|
|
288
|
+
inputs: { COLOR: COLOR },
|
|
306
289
|
},
|
|
307
290
|
"sensing.colorIsTouchingColor": {
|
|
308
291
|
opcode: "sensing_coloristouchingcolor",
|
|
309
292
|
kind: "boolean",
|
|
310
|
-
inputs: { COLOR:
|
|
293
|
+
inputs: { COLOR: COLOR, COLOR2: COLOR },
|
|
311
294
|
},
|
|
312
295
|
"sensing.distanceTo": {
|
|
313
296
|
opcode: "sensing_distanceto",
|
|
@@ -316,8 +299,7 @@ export const CATALOG = {
|
|
|
316
299
|
},
|
|
317
300
|
"sensing.askAndWait": {
|
|
318
301
|
opcode: "sensing_askandwait",
|
|
319
|
-
|
|
320
|
-
inputs: { QUESTION: { type: "string" } },
|
|
302
|
+
inputs: { QUESTION: STRING },
|
|
321
303
|
},
|
|
322
304
|
"sensing.answer": { opcode: "sensing_answer", kind: "reporter" },
|
|
323
305
|
"sensing.keyPressed": {
|
|
@@ -330,7 +312,7 @@ export const CATALOG = {
|
|
|
330
312
|
"sensing.mouseY": { opcode: "sensing_mousey", kind: "reporter" },
|
|
331
313
|
"sensing.loudness": { opcode: "sensing_loudness", kind: "reporter" },
|
|
332
314
|
"sensing.timer": { opcode: "sensing_timer", kind: "reporter" },
|
|
333
|
-
"sensing.resetTimer": { opcode: "sensing_resettimer"
|
|
315
|
+
"sensing.resetTimer": { opcode: "sensing_resettimer" },
|
|
334
316
|
"sensing.propertyOf": {
|
|
335
317
|
opcode: "sensing_of",
|
|
336
318
|
kind: "reporter",
|
|
@@ -352,110 +334,107 @@ export const CATALOG = {
|
|
|
352
334
|
"operators.add": {
|
|
353
335
|
opcode: "operator_add",
|
|
354
336
|
kind: "reporter",
|
|
355
|
-
inputs: { NUM1:
|
|
337
|
+
inputs: { NUM1: NUMBER, NUM2: NUMBER },
|
|
356
338
|
},
|
|
357
339
|
"operators.subtract": {
|
|
358
340
|
opcode: "operator_subtract",
|
|
359
341
|
kind: "reporter",
|
|
360
|
-
inputs: { NUM1:
|
|
342
|
+
inputs: { NUM1: NUMBER, NUM2: NUMBER },
|
|
361
343
|
},
|
|
362
344
|
"operators.multiply": {
|
|
363
345
|
opcode: "operator_multiply",
|
|
364
346
|
kind: "reporter",
|
|
365
|
-
inputs: { NUM1:
|
|
347
|
+
inputs: { NUM1: NUMBER, NUM2: NUMBER },
|
|
366
348
|
},
|
|
367
349
|
"operators.divide": {
|
|
368
350
|
opcode: "operator_divide",
|
|
369
351
|
kind: "reporter",
|
|
370
|
-
inputs: { NUM1:
|
|
352
|
+
inputs: { NUM1: NUMBER, NUM2: NUMBER },
|
|
371
353
|
},
|
|
372
354
|
"operators.random": {
|
|
373
355
|
opcode: "operator_random",
|
|
374
356
|
kind: "reporter",
|
|
375
|
-
inputs: { FROM:
|
|
357
|
+
inputs: { FROM: NUMBER, TO: NUMBER },
|
|
376
358
|
},
|
|
377
359
|
"operators.gt": {
|
|
378
360
|
opcode: "operator_gt",
|
|
379
361
|
kind: "boolean",
|
|
380
|
-
inputs: { OPERAND1:
|
|
362
|
+
inputs: { OPERAND1: STRING, OPERAND2: STRING },
|
|
381
363
|
},
|
|
382
364
|
"operators.lt": {
|
|
383
365
|
opcode: "operator_lt",
|
|
384
366
|
kind: "boolean",
|
|
385
|
-
inputs: { OPERAND1:
|
|
367
|
+
inputs: { OPERAND1: STRING, OPERAND2: STRING },
|
|
386
368
|
},
|
|
387
369
|
"operators.equals": {
|
|
388
370
|
opcode: "operator_equals",
|
|
389
371
|
kind: "boolean",
|
|
390
|
-
inputs: { OPERAND1:
|
|
372
|
+
inputs: { OPERAND1: STRING, OPERAND2: STRING },
|
|
391
373
|
},
|
|
392
374
|
"operators.and": {
|
|
393
375
|
opcode: "operator_and",
|
|
394
376
|
kind: "boolean",
|
|
395
|
-
inputs: { OPERAND1:
|
|
377
|
+
inputs: { OPERAND1: BOOLEAN, OPERAND2: BOOLEAN },
|
|
396
378
|
},
|
|
397
379
|
"operators.or": {
|
|
398
380
|
opcode: "operator_or",
|
|
399
381
|
kind: "boolean",
|
|
400
|
-
inputs: { OPERAND1:
|
|
382
|
+
inputs: { OPERAND1: BOOLEAN, OPERAND2: BOOLEAN },
|
|
401
383
|
},
|
|
402
384
|
"operators.not": {
|
|
403
385
|
opcode: "operator_not",
|
|
404
386
|
kind: "boolean",
|
|
405
|
-
inputs: { OPERAND:
|
|
387
|
+
inputs: { OPERAND: BOOLEAN },
|
|
406
388
|
},
|
|
407
389
|
"operators.join": {
|
|
408
390
|
opcode: "operator_join",
|
|
409
391
|
kind: "reporter",
|
|
410
|
-
inputs: { STRING1:
|
|
392
|
+
inputs: { STRING1: STRING, STRING2: STRING },
|
|
411
393
|
},
|
|
412
394
|
"operators.letterOf": {
|
|
413
395
|
opcode: "operator_letter_of",
|
|
414
396
|
kind: "reporter",
|
|
415
|
-
inputs: { LETTER:
|
|
397
|
+
inputs: { LETTER: NUMBER, STRING: STRING },
|
|
416
398
|
},
|
|
417
399
|
"operators.length": {
|
|
418
400
|
opcode: "operator_length",
|
|
419
401
|
kind: "reporter",
|
|
420
|
-
inputs: { STRING:
|
|
402
|
+
inputs: { STRING: STRING },
|
|
421
403
|
},
|
|
422
404
|
"operators.contains": {
|
|
423
405
|
opcode: "operator_contains",
|
|
424
406
|
kind: "boolean",
|
|
425
|
-
inputs: { STRING1:
|
|
407
|
+
inputs: { STRING1: STRING, STRING2: STRING },
|
|
426
408
|
},
|
|
427
409
|
"operators.mod": {
|
|
428
410
|
opcode: "operator_mod",
|
|
429
411
|
kind: "reporter",
|
|
430
|
-
inputs: { NUM1:
|
|
412
|
+
inputs: { NUM1: NUMBER, NUM2: NUMBER },
|
|
431
413
|
},
|
|
432
414
|
"operators.round": {
|
|
433
415
|
opcode: "operator_round",
|
|
434
416
|
kind: "reporter",
|
|
435
|
-
inputs: { NUM:
|
|
417
|
+
inputs: { NUM: NUMBER },
|
|
436
418
|
},
|
|
437
419
|
"operators.math": {
|
|
438
420
|
opcode: "operator_mathop",
|
|
439
421
|
kind: "reporter",
|
|
440
422
|
fields: { OPERATOR: "abs" },
|
|
441
|
-
inputs: { NUM:
|
|
423
|
+
inputs: { NUM: NUMBER },
|
|
442
424
|
},
|
|
443
425
|
|
|
444
426
|
// --------------------------------------------------------------- control
|
|
445
427
|
"control.wait": {
|
|
446
428
|
opcode: "control_wait",
|
|
447
|
-
|
|
448
|
-
inputs: { DURATION: { type: "positive" } },
|
|
429
|
+
inputs: { DURATION: POSITIVE },
|
|
449
430
|
},
|
|
450
431
|
"control.repeat": {
|
|
451
432
|
opcode: "control_repeat",
|
|
452
|
-
|
|
453
|
-
inputs: { TIMES: { type: "whole" }, SUBSTACK: { type: "substack" } },
|
|
433
|
+
inputs: { TIMES: WHOLE, SUBSTACK: SUBSTACK },
|
|
454
434
|
},
|
|
455
435
|
"control.forever": {
|
|
456
436
|
opcode: "control_forever",
|
|
457
|
-
|
|
458
|
-
inputs: { SUBSTACK: { type: "substack" } },
|
|
437
|
+
inputs: { SUBSTACK: SUBSTACK },
|
|
459
438
|
// PenguinMod builds this loop without a next-statement connection and only
|
|
460
439
|
// creates one from the block's mutation (`hasbreak` — the nub the editor
|
|
461
440
|
// adds by itself once a block sits below the loop). Blocks stacked under a
|
|
@@ -466,54 +445,45 @@ export const CATALOG = {
|
|
|
466
445
|
},
|
|
467
446
|
"control.if": {
|
|
468
447
|
opcode: "control_if",
|
|
469
|
-
|
|
470
|
-
inputs: { CONDITION: { type: "boolean" }, SUBSTACK: { type: "substack" } },
|
|
448
|
+
inputs: { CONDITION: BOOLEAN, SUBSTACK: SUBSTACK },
|
|
471
449
|
},
|
|
472
450
|
"control.ifElse": {
|
|
473
451
|
opcode: "control_if_else",
|
|
474
|
-
kind: "command",
|
|
475
452
|
inputs: {
|
|
476
|
-
CONDITION:
|
|
477
|
-
SUBSTACK:
|
|
478
|
-
SUBSTACK2:
|
|
453
|
+
CONDITION: BOOLEAN,
|
|
454
|
+
SUBSTACK: SUBSTACK,
|
|
455
|
+
SUBSTACK2: SUBSTACK,
|
|
479
456
|
},
|
|
480
457
|
},
|
|
481
458
|
"control.waitUntil": {
|
|
482
459
|
opcode: "control_wait_until",
|
|
483
|
-
|
|
484
|
-
inputs: { CONDITION: { type: "boolean" } },
|
|
460
|
+
inputs: { CONDITION: BOOLEAN },
|
|
485
461
|
},
|
|
486
462
|
"control.repeatUntil": {
|
|
487
463
|
opcode: "control_repeat_until",
|
|
488
|
-
|
|
489
|
-
inputs: { CONDITION: { type: "boolean" }, SUBSTACK: { type: "substack" } },
|
|
464
|
+
inputs: { CONDITION: BOOLEAN, SUBSTACK: SUBSTACK },
|
|
490
465
|
},
|
|
491
466
|
"control.stop": {
|
|
492
467
|
opcode: "control_stop",
|
|
493
|
-
kind: "command",
|
|
494
468
|
fields: { STOP_OPTION: "all" },
|
|
495
469
|
stopBlock: true,
|
|
496
470
|
},
|
|
497
471
|
"control.clone": {
|
|
498
472
|
opcode: "control_create_clone_of",
|
|
499
|
-
kind: "command",
|
|
500
473
|
inputs: { CLONE_OPTION: CLONE_MENU },
|
|
501
474
|
},
|
|
502
475
|
"control.deleteThisClone": {
|
|
503
476
|
opcode: "control_delete_this_clone",
|
|
504
|
-
kind: "command",
|
|
505
477
|
},
|
|
506
478
|
|
|
507
479
|
// ---------------------------------------------------------------- events
|
|
508
480
|
"events.broadcast": {
|
|
509
481
|
opcode: "event_broadcast",
|
|
510
|
-
|
|
511
|
-
inputs: { BROADCAST_INPUT: { type: "broadcast" } },
|
|
482
|
+
inputs: { BROADCAST_INPUT: BROADCAST },
|
|
512
483
|
},
|
|
513
484
|
"events.broadcastAndWait": {
|
|
514
485
|
opcode: "event_broadcastandwait",
|
|
515
|
-
|
|
516
|
-
inputs: { BROADCAST_INPUT: { type: "broadcast" } },
|
|
486
|
+
inputs: { BROADCAST_INPUT: BROADCAST },
|
|
517
487
|
},
|
|
518
488
|
|
|
519
489
|
// --------------------------------------------------------------- hats
|
|
@@ -574,26 +544,23 @@ export const CATALOG = {
|
|
|
574
544
|
},
|
|
575
545
|
|
|
576
546
|
// ------------------------------------------------------------------ pen
|
|
577
|
-
"pen.clear": { opcode: "pen_clear",
|
|
578
|
-
"pen.stamp": { opcode: "pen_stamp",
|
|
579
|
-
"pen.penDown": { opcode: "pen_penDown",
|
|
580
|
-
"pen.penUp": { opcode: "pen_penUp",
|
|
547
|
+
"pen.clear": { opcode: "pen_clear", extension: "pen" },
|
|
548
|
+
"pen.stamp": { opcode: "pen_stamp", extension: "pen" },
|
|
549
|
+
"pen.penDown": { opcode: "pen_penDown", extension: "pen" },
|
|
550
|
+
"pen.penUp": { opcode: "pen_penUp", extension: "pen" },
|
|
581
551
|
"pen.setColor": {
|
|
582
552
|
opcode: "pen_setPenColorToColor",
|
|
583
|
-
|
|
584
|
-
inputs: { COLOR: { type: "color" } },
|
|
553
|
+
inputs: { COLOR: COLOR },
|
|
585
554
|
extension: "pen",
|
|
586
555
|
},
|
|
587
556
|
"pen.changeSize": {
|
|
588
557
|
opcode: "pen_changePenSizeBy",
|
|
589
|
-
|
|
590
|
-
inputs: { SIZE: { type: "number" } },
|
|
558
|
+
inputs: { SIZE: NUMBER },
|
|
591
559
|
extension: "pen",
|
|
592
560
|
},
|
|
593
561
|
"pen.setSize": {
|
|
594
562
|
opcode: "pen_setPenSizeTo",
|
|
595
|
-
|
|
596
|
-
inputs: { SIZE: { type: "number" } },
|
|
563
|
+
inputs: { SIZE: NUMBER },
|
|
597
564
|
extension: "pen",
|
|
598
565
|
},
|
|
599
566
|
// COLOR_PARAM is an input whose shadow is `pen_menu_colorParam`, not a
|
|
@@ -602,7 +569,6 @@ export const CATALOG = {
|
|
|
602
569
|
// wrapping a shadow menu block (field name `colorParam`).
|
|
603
570
|
"pen.changeColorParam": {
|
|
604
571
|
opcode: "pen_changePenColorParamBy",
|
|
605
|
-
kind: "command",
|
|
606
572
|
inputs: {
|
|
607
573
|
COLOR_PARAM: {
|
|
608
574
|
type: "menu",
|
|
@@ -610,13 +576,12 @@ export const CATALOG = {
|
|
|
610
576
|
field: "colorParam",
|
|
611
577
|
default: "color",
|
|
612
578
|
},
|
|
613
|
-
VALUE:
|
|
579
|
+
VALUE: NUMBER,
|
|
614
580
|
},
|
|
615
581
|
extension: "pen",
|
|
616
582
|
},
|
|
617
583
|
"pen.setColorParam": {
|
|
618
584
|
opcode: "pen_setPenColorParamTo",
|
|
619
|
-
kind: "command",
|
|
620
585
|
inputs: {
|
|
621
586
|
COLOR_PARAM: {
|
|
622
587
|
type: "menu",
|
|
@@ -624,7 +589,7 @@ export const CATALOG = {
|
|
|
624
589
|
field: "colorParam",
|
|
625
590
|
default: "color",
|
|
626
591
|
},
|
|
627
|
-
VALUE:
|
|
592
|
+
VALUE: NUMBER,
|
|
628
593
|
},
|
|
629
594
|
extension: "pen",
|
|
630
595
|
},
|
|
@@ -647,6 +612,18 @@ export const CATALOG = {
|
|
|
647
612
|
},
|
|
648
613
|
};
|
|
649
614
|
|
|
615
|
+
/**
|
|
616
|
+
* Catalog entries with the default `kind` filled in: everything is a command
|
|
617
|
+
* unless the table above said otherwise.
|
|
618
|
+
* @type {Record<string, object>}
|
|
619
|
+
*/
|
|
620
|
+
export const CATALOG = Object.fromEntries(
|
|
621
|
+
Object.entries(CORE_CATALOG).map(([name, entry]) => [
|
|
622
|
+
name,
|
|
623
|
+
{ kind: "command", ...entry },
|
|
624
|
+
]),
|
|
625
|
+
);
|
|
626
|
+
|
|
650
627
|
/**
|
|
651
628
|
* Native Scratch variable/list mutation opcodes used when a list method is
|
|
652
629
|
* called (`items.push(x)` etc.).
|
package/src/compiler.js
CHANGED
|
@@ -25,6 +25,11 @@ const HAT_ALIASES = {
|
|
|
25
25
|
timerGreaterThan: "events.timerGreaterThan",
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
// `dataFormat` values Scratch 3 can load (see the `VM.Costume` / `VM.Sound`
|
|
29
|
+
// types). Anything else is still written through, but may not load.
|
|
30
|
+
const COSTUME_FORMATS = new Set(["svg", "png", "jpg"]);
|
|
31
|
+
const SOUND_FORMATS = new Set(["wav", "mp3"]);
|
|
32
|
+
|
|
28
33
|
const NUMBER_TYPES = {
|
|
29
34
|
number: 4,
|
|
30
35
|
positive: 5,
|
|
@@ -133,6 +138,8 @@ export class Compiler {
|
|
|
133
138
|
this.catalog = catalog ? { ...CATALOG, ...catalog } : CATALOG;
|
|
134
139
|
this.extensionUrls = extensionUrls;
|
|
135
140
|
this.file = file;
|
|
141
|
+
/** @type {string[]} Non-fatal problems worth surfacing to the user. */
|
|
142
|
+
this.warnings = [];
|
|
136
143
|
|
|
137
144
|
// opcode -> extension id, derived from the (merged) catalog. This lets
|
|
138
145
|
// synthesized extension blocks declare an id that isn't inferable from
|
|
@@ -463,12 +470,20 @@ export class Compiler {
|
|
|
463
470
|
applyCostumes(ctx, property) {
|
|
464
471
|
for (const entry of property.value) {
|
|
465
472
|
const format = detectFormat(entry.value);
|
|
473
|
+
if (!COSTUME_FORMATS.has(format))
|
|
474
|
+
this.warnings.push(
|
|
475
|
+
`costume ${JSON.stringify(entry.key)} uses dataFormat ` +
|
|
476
|
+
`"${format}"; Scratch 3 costumes are svg, png or jpg`,
|
|
477
|
+
);
|
|
466
478
|
const buffer = this.readAsset(entry.value, entry.loc);
|
|
467
479
|
const { width, height } = imageSize(buffer, format);
|
|
468
480
|
const asset = this.registerAsset(buffer, format);
|
|
469
481
|
ctx.target.costumes.push({
|
|
470
482
|
name: entry.key,
|
|
471
|
-
|
|
483
|
+
// Scratch 3 saves bitmap costumes at 2x; the VM converts a 1 to 2 on
|
|
484
|
+
// load anyway (`import/load-costume.js`), and the renderer's
|
|
485
|
+
// BitmapSkin defaults to 2. SVGs are always 1.
|
|
486
|
+
bitmapResolution: format === "svg" ? 1 : 2,
|
|
472
487
|
dataFormat: format,
|
|
473
488
|
assetId: asset.assetId,
|
|
474
489
|
md5ext: asset.md5ext,
|
|
@@ -481,6 +496,11 @@ export class Compiler {
|
|
|
481
496
|
applySounds(ctx, property) {
|
|
482
497
|
for (const entry of property.value) {
|
|
483
498
|
const format = detectFormat(entry.value);
|
|
499
|
+
if (!SOUND_FORMATS.has(format))
|
|
500
|
+
this.warnings.push(
|
|
501
|
+
`sound ${JSON.stringify(entry.key)} uses dataFormat ` +
|
|
502
|
+
`"${format}"; Scratch 3 sounds are wav or mp3`,
|
|
503
|
+
);
|
|
484
504
|
const buffer = this.readAsset(entry.value, entry.loc);
|
|
485
505
|
const info =
|
|
486
506
|
format === "wav" ? wavInfo(buffer) : { rate: 48000, sampleCount: 0 };
|
|
@@ -577,7 +597,7 @@ export class Compiler {
|
|
|
577
597
|
}
|
|
578
598
|
} else if (spec.input) {
|
|
579
599
|
inputs[spec.input] = this.buildInputForSpec(
|
|
580
|
-
{ type: spec.type || "any" },
|
|
600
|
+
{ type: spec.type || "any", default: spec.default },
|
|
581
601
|
argExpr,
|
|
582
602
|
null,
|
|
583
603
|
);
|
|
@@ -1110,7 +1130,16 @@ export class Compiler {
|
|
|
1110
1130
|
}
|
|
1111
1131
|
|
|
1112
1132
|
defaultPrim(spec) {
|
|
1113
|
-
|
|
1133
|
+
const prim = this.defaultPrimForType(spec.type);
|
|
1134
|
+
// Extensions declare the value a call falls back to when it omits an
|
|
1135
|
+
// argument; built-in catalog entries never set `default`.
|
|
1136
|
+
if (spec.default !== undefined && spec.default !== null)
|
|
1137
|
+
prim[1] = String(spec.default);
|
|
1138
|
+
return prim;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
defaultPrimForType(type) {
|
|
1142
|
+
switch (type) {
|
|
1114
1143
|
case "number":
|
|
1115
1144
|
return [4, "0"];
|
|
1116
1145
|
case "positive":
|
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/dev.js
CHANGED
|
@@ -55,6 +55,239 @@ function escapeHtml(text) {
|
|
|
55
55
|
.replaceAll('"', """);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Elements whose content the HTML tokenizer treats as text rather than markup.
|
|
60
|
+
* `noscript` is included because this page always runs scripts, which is
|
|
61
|
+
* exactly when the parser stops tokenizing its content.
|
|
62
|
+
*/
|
|
63
|
+
const RAW_TEXT_TAGS = new Set([
|
|
64
|
+
"script",
|
|
65
|
+
"style",
|
|
66
|
+
"title",
|
|
67
|
+
"textarea",
|
|
68
|
+
"iframe",
|
|
69
|
+
"noembed",
|
|
70
|
+
"noframes",
|
|
71
|
+
"xmp",
|
|
72
|
+
"noscript",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Elements whose contents are parsed into a separate fragment: a `</body>`
|
|
77
|
+
* inside one is ignored by the document tokenizer, so it cannot be the
|
|
78
|
+
* document's body end either.
|
|
79
|
+
*/
|
|
80
|
+
const FRAGMENT_TAGS = new Set(["template"]);
|
|
81
|
+
|
|
82
|
+
const TAG_NAME_AT = /<([a-zA-Z][^\s/>]*)/y;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Index just past the start tag beginning at `start`, skipping quoted attribute
|
|
86
|
+
* values so a `>` inside `srcdoc="…"` cannot end the tag early. Returns -1 when
|
|
87
|
+
* the tag never closes.
|
|
88
|
+
* @param {string} html
|
|
89
|
+
* @param {number} start
|
|
90
|
+
* @returns {number}
|
|
91
|
+
*/
|
|
92
|
+
function startTagEnd(html, start) {
|
|
93
|
+
let quote = "";
|
|
94
|
+
for (let i = start + 1; i < html.length; i += 1) {
|
|
95
|
+
const char = html[i];
|
|
96
|
+
if (quote) {
|
|
97
|
+
if (char === quote) quote = "";
|
|
98
|
+
} else if (char === '"' || char === "'") {
|
|
99
|
+
quote = char;
|
|
100
|
+
} else if (char === ">") {
|
|
101
|
+
return i + 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return -1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Index just past the first `</name>` at or after `from`, or -1.
|
|
109
|
+
* @param {string} html
|
|
110
|
+
* @param {string} name
|
|
111
|
+
* @param {number} from
|
|
112
|
+
* @returns {number}
|
|
113
|
+
*/
|
|
114
|
+
function endTagEnd(html, name, from) {
|
|
115
|
+
const pattern = new RegExp(`</${name}\\s*>`, "gi");
|
|
116
|
+
pattern.lastIndex = from;
|
|
117
|
+
const match = pattern.exec(html);
|
|
118
|
+
return match ? match.index + match[0].length : -1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Index just past the `</name>` that closes the element whose start tag ends at
|
|
123
|
+
* `from`, honouring nested `name` elements and skipping comments and raw-text
|
|
124
|
+
* content. Returns -1 when the element never closes.
|
|
125
|
+
* @param {string} html
|
|
126
|
+
* @param {string} name
|
|
127
|
+
* @param {number} from
|
|
128
|
+
* @returns {number}
|
|
129
|
+
*/
|
|
130
|
+
function matchingClose(html, name, from) {
|
|
131
|
+
let depth = 1;
|
|
132
|
+
let i = from;
|
|
133
|
+
|
|
134
|
+
while (i < html.length) {
|
|
135
|
+
const tag = html.indexOf("<", i);
|
|
136
|
+
if (tag === -1) return -1;
|
|
137
|
+
|
|
138
|
+
if (html.startsWith("<!--", tag)) {
|
|
139
|
+
const commentEnd = html.indexOf("-->", tag + 4);
|
|
140
|
+
if (commentEnd === -1) return -1;
|
|
141
|
+
i = commentEnd + 3;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const end = startTagEnd(html, tag);
|
|
146
|
+
if (end === -1) return -1;
|
|
147
|
+
|
|
148
|
+
if (html[tag + 1] === "/") {
|
|
149
|
+
const nameAt = tag + 2;
|
|
150
|
+
if (
|
|
151
|
+
html.slice(nameAt, nameAt + name.length).toLowerCase() === name &&
|
|
152
|
+
" \t\n\f\r/>".includes(html[nameAt + name.length] ?? ">")
|
|
153
|
+
) {
|
|
154
|
+
depth -= 1;
|
|
155
|
+
if (depth === 0) return end;
|
|
156
|
+
}
|
|
157
|
+
i = end;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
TAG_NAME_AT.lastIndex = tag;
|
|
162
|
+
const match = TAG_NAME_AT.exec(html);
|
|
163
|
+
const inner = match ? match[1].toLowerCase() : "";
|
|
164
|
+
|
|
165
|
+
if (inner === name) {
|
|
166
|
+
depth += 1;
|
|
167
|
+
i = end;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// `<plaintext>` never closes, so nothing after it can close the fragment.
|
|
172
|
+
if (inner === "plaintext") return -1;
|
|
173
|
+
|
|
174
|
+
// Raw-text content is text, so a `</template>`-looking string in a script
|
|
175
|
+
// inside the fragment cannot close it early.
|
|
176
|
+
if (RAW_TEXT_TAGS.has(inner)) {
|
|
177
|
+
const raw = endTagEnd(html, inner, end);
|
|
178
|
+
if (raw === -1) return -1;
|
|
179
|
+
i = raw;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
i = end;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return -1;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Find the ranges the HTML tokenizer treats as text rather than markup, so tag
|
|
191
|
+
* searches cannot match text that merely looks like markup (a `</body>` inside
|
|
192
|
+
* a script string, a commented-out tag, an attribute value, ...).
|
|
193
|
+
* @param {string} html
|
|
194
|
+
* @returns {{ranges: Array<[number, number]>, textFrom: number}} `ranges` are
|
|
195
|
+
* sorted, non-overlapping `[start, end)` spans; `textFrom` is the offset
|
|
196
|
+
* where a `<plaintext>` or unterminated tag/text element starts, so nothing
|
|
197
|
+
* from there to end of input is markup. -1 when the whole input is markup.
|
|
198
|
+
*/
|
|
199
|
+
function nonMarkupRanges(html) {
|
|
200
|
+
/** @type {Array<[number, number]>} */
|
|
201
|
+
const ranges = [];
|
|
202
|
+
let textFrom = -1;
|
|
203
|
+
let i = 0;
|
|
204
|
+
|
|
205
|
+
while (i < html.length) {
|
|
206
|
+
const tag = html.indexOf("<", i);
|
|
207
|
+
if (tag === -1) break;
|
|
208
|
+
|
|
209
|
+
if (html.startsWith("<!--", tag)) {
|
|
210
|
+
const close = html.indexOf("-->", tag + 4);
|
|
211
|
+
const end = close === -1 ? html.length : close + 3;
|
|
212
|
+
ranges.push([tag, end]);
|
|
213
|
+
i = end;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
TAG_NAME_AT.lastIndex = tag;
|
|
218
|
+
const match = TAG_NAME_AT.exec(html);
|
|
219
|
+
const name = match ? match[1].toLowerCase() : "";
|
|
220
|
+
const next = html[tag + 1] ?? "";
|
|
221
|
+
|
|
222
|
+
// A bare `<` (`1 < 2`) opens no tag, so carry on just after it: scanning
|
|
223
|
+
// from here could otherwise let a stray quote in text open an attribute
|
|
224
|
+
// quote and swallow real markup that follows.
|
|
225
|
+
if (!match && next !== "/" && next !== "!" && next !== "?") {
|
|
226
|
+
i = tag + 1;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const end = startTagEnd(html, tag);
|
|
231
|
+
|
|
232
|
+
// An unterminated tag, and `<plaintext>`, make the rest of the input text.
|
|
233
|
+
if (end === -1 || name === "plaintext") {
|
|
234
|
+
ranges.push([tag, html.length]);
|
|
235
|
+
textFrom = tag;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// End tags and declarations have no attributes that could hide a tag.
|
|
240
|
+
if (name === "") {
|
|
241
|
+
i = end;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!RAW_TEXT_TAGS.has(name) && !FRAGMENT_TAGS.has(name)) {
|
|
246
|
+
// A start tag's quoted attributes are text too, so a `</body>` inside
|
|
247
|
+
// `title="…"` cannot capture the injection.
|
|
248
|
+
ranges.push([tag, end]);
|
|
249
|
+
i = end;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Fragment contents (templates) are opaque to the document tokenizer, so a
|
|
254
|
+
// `</body>` inside them — nested templates included — is not the body end.
|
|
255
|
+
const close = FRAGMENT_TAGS.has(name)
|
|
256
|
+
? matchingClose(html, name, end)
|
|
257
|
+
: endTagEnd(html, name, end);
|
|
258
|
+
|
|
259
|
+
// An element without its end tag also runs to end of input.
|
|
260
|
+
if (close === -1) {
|
|
261
|
+
ranges.push([tag, html.length]);
|
|
262
|
+
textFrom = tag;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
ranges.push([tag, close]);
|
|
267
|
+
i = close;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { ranges, textFrom };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Offset of the last `</body>` outside the given text ranges, or -1.
|
|
275
|
+
* @param {string} html
|
|
276
|
+
* @param {Array<[number, number]>} ranges
|
|
277
|
+
* @returns {number}
|
|
278
|
+
*/
|
|
279
|
+
function lastBodyEnd(html, ranges) {
|
|
280
|
+
let bodyEnd = -1;
|
|
281
|
+
let next = 0;
|
|
282
|
+
for (const match of html.matchAll(/<\/body\s*>/gi)) {
|
|
283
|
+
while (next < ranges.length && ranges[next][1] <= match.index) next += 1;
|
|
284
|
+
const range = ranges[next];
|
|
285
|
+
if (range && range[0] <= match.index) continue; // text, not a tag
|
|
286
|
+
bodyEnd = match.index;
|
|
287
|
+
}
|
|
288
|
+
return bodyEnd;
|
|
289
|
+
}
|
|
290
|
+
|
|
58
291
|
/**
|
|
59
292
|
* @param {string} html
|
|
60
293
|
* @returns {string}
|
|
@@ -84,9 +317,22 @@ export function injectDevClient(html) {
|
|
|
84
317
|
});
|
|
85
318
|
})();
|
|
86
319
|
</script>`;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
320
|
+
// Insert before the *last* real closing body tag: packaged pages inline a
|
|
321
|
+
// bundle that can itself contain the literal text `</body>` inside a string,
|
|
322
|
+
// and injecting there would corrupt the script instead of ending the
|
|
323
|
+
// document. Comments, text-element content and attribute values are masked
|
|
324
|
+
// first so a quoted or commented-out `</body>` cannot capture the injection.
|
|
325
|
+
const { ranges, textFrom } = nonMarkupRanges(html);
|
|
326
|
+
const bodyEnd = lastBodyEnd(html, ranges);
|
|
327
|
+
|
|
328
|
+
// Without a usable body end tag, go before the point where the document
|
|
329
|
+
// turns into text (keeping the client a script rather than literal text),
|
|
330
|
+
// or append when the whole input was markup.
|
|
331
|
+
const at =
|
|
332
|
+
bodyEnd === -1 ? (textFrom === -1 ? html.length : textFrom) : bodyEnd;
|
|
333
|
+
const before = html.slice(0, at);
|
|
334
|
+
const separator = before === "" || before.endsWith("\n") ? "" : "\n";
|
|
335
|
+
return `${before}${separator}${script}${html.slice(at)}`;
|
|
90
336
|
}
|
|
91
337
|
|
|
92
338
|
/**
|
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: () =>
|