@turbowarp/sb3fix 0.3.7 → 0.4.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 (4) hide show
  1. package/LICENSE +373 -373
  2. package/README.md +106 -100
  3. package/package.json +31 -31
  4. package/src/sb3fix.js +681 -606
package/src/sb3fix.js CHANGED
@@ -1,606 +1,681 @@
1
- /*!
2
- sb3fix - https://github.com/TurboWarp/sb3fix
3
-
4
- Copyright (C) 2023-2025 Thomas Weber
5
-
6
- This Source Code Form is subject to the terms of the Mozilla Public
7
- License, v. 2.0. If a copy of the MPL was not distributed with this
8
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
9
- */
10
-
11
- /**
12
- * @typedef Options
13
- * @property {(message: string) => void} [logCallback]
14
- */
15
-
16
- /**
17
- * @param {unknown} obj
18
- * @returns {obj is object}
19
- */
20
- const isObject = (obj) => !!obj && typeof obj === 'object';
21
-
22
- const BUILTIN_EXTENSIONS = [
23
- 'control',
24
- 'data',
25
- 'event',
26
- 'looks',
27
- 'motion',
28
- 'operators',
29
- 'procedures',
30
- 'argument', // "argument_reporter_boolean" is technically not an extension but we should list here anyways
31
- 'sensing',
32
- 'sound',
33
- 'pen',
34
- 'wedo2',
35
- 'music',
36
- 'microbit',
37
- 'text2speech',
38
- 'translate',
39
- 'videoSensing',
40
- 'ev3',
41
- 'makeymakey',
42
- 'boost',
43
- 'gdxfor'
44
- // intentionally not listing TurboWarp's 'tw' extension here.
45
- ];
46
-
47
- /**
48
- * @param {object} project Parsed project.json.
49
- * @returns {Set<string>} Set of valid extensions, including the primitive ones, that the project loads.
50
- */
51
- const getKnownExtensions = (project) => {
52
- const extensions = project.extensions;
53
- if (!Array.isArray(extensions)) {
54
- throw new Error('extensions is not an array');
55
- }
56
- for (let i = 0; i < extensions.length; i++) {
57
- if (typeof extensions[i] !== 'string') {
58
- throw new Error(`extension ${i} is not a string`);
59
- }
60
- }
61
- return new Set([
62
- ...BUILTIN_EXTENSIONS,
63
- ...extensions
64
- ]);
65
- };
66
-
67
- /**
68
- * @param {string|object} data project.json as a string or as a parsed object already. If object provided, it will be modified in-place.
69
- * @param {Options} [options]
70
- * @returns {object} Fixed project.json object. If the `data` argument was an object, this will point to the same object.
71
- */
72
- const fixJSON = (data, options = {}) => {
73
- /**
74
- * @param {string} message
75
- */
76
- const log = (message) => {
77
- if (options.logCallback) {
78
- options.logCallback(message);
79
- }
80
- };
81
-
82
- /**
83
- * @param {string} id
84
- * @param {unknown} variable
85
- */
86
- const fixVariableInPlace = (id, variable) => {
87
- if (!Array.isArray(variable)) {
88
- throw new Error(`variable object ${id} is not an array`);
89
- }
90
-
91
- const name = variable[0];
92
- if (typeof name !== 'string') {
93
- log(`variable or list ${id} name was not a string`);
94
- variable[0] = String(variable[0]);
95
- }
96
-
97
- const value = variable[1];
98
- if (typeof value !== 'number' && typeof value !== 'string' && typeof value !== 'boolean') {
99
- log(`variable ${id} value was not a Scratch-compatible value`);
100
- variable[1] = String(variable[1]);
101
- }
102
- };
103
-
104
- /**
105
- * @param {string} id
106
- * @param {unknown} list
107
- */
108
- const fixListInPlace = (id, list) => {
109
- if (!Array.isArray(list)) {
110
- throw new Error(`list object ${id} is not an array`);
111
- }
112
-
113
- const name = list[0];
114
- if (typeof name !== 'string') {
115
- log(`list ${id} name was not a string`);
116
- list[0] = String(list[0]);
117
- }
118
-
119
- if (!Array.isArray(list[1])) {
120
- log(`list ${id} value was not an array`);
121
- list[1] = [];
122
- }
123
-
124
- const listValue = list[1];
125
- for (let i = 0; i < listValue.length; i++) {
126
- const value = listValue[i];
127
- if (typeof value !== 'number' && typeof value !== 'string' && typeof value !== 'boolean') {
128
- log(`list ${id} index ${i} was not a Scratch-compatible value`);
129
- listValue[i] = String(value);
130
- }
131
- }
132
- };
133
-
134
- /**
135
- * @param {unknown[]} native
136
- */
137
- const fixCompressedNativeInPlace = (native) => {
138
- if (!Array.isArray(native)) {
139
- throw new Error('native is not an array');
140
- }
141
-
142
- const type = native[0];
143
- if (typeof type !== 'number') {
144
- throw new Error('native type is not a number');
145
- }
146
-
147
- switch (type) {
148
- // Number primitive: [4, string|number]
149
- // Positive number primitive: [5, string|number]
150
- // Whole number primitive: [6, string|number]
151
- // Integer primitive: [7, string|number]
152
- // Angle primitive: [8, string|number]
153
- case 4:
154
- case 5:
155
- case 6:
156
- case 7:
157
- case 8: {
158
- if (native.length !== 2) {
159
- throw new Error(`Number native is of unexpected length: ${native.length}`);
160
- }
161
- const value = native[1];
162
- if (typeof value !== 'string' && typeof value !== 'number') {
163
- log('number native had invalid value');
164
- native[1] = String(value);
165
- }
166
- break;
167
- }
168
-
169
- // Color: [9, hex color]
170
- case 9: {
171
- if (native.length !== 2) {
172
- throw new Error(`Color native is of unexpected length: ${native.length}`);
173
- }
174
- const color = native[1];
175
- if (typeof color !== 'string' || !/^#[a-f0-9]{6}$/i.test(color)) {
176
- log('color native had invalid value');
177
- native[1] = '#000000';
178
- }
179
- break;
180
- }
181
-
182
- // Text: [10, string|number]
183
- case 10: {
184
- if (native.length !== 2) {
185
- throw new Error(`Text native is of unexpected length: ${native.length}`);
186
- }
187
- const value = native[1];
188
- if (typeof value !== 'string' && typeof value !== 'number') {
189
- log('text native had invalid value');
190
- native[1] = String(value);
191
- }
192
- break;
193
- }
194
-
195
- // Variable: [12, variable name, variable id, x?, y?]
196
- // List: [13, list name, list id, x?, y?]
197
- // x and y only present if the native is a top-level block
198
- case 12:
199
- case 13: {
200
- if (native.length !== 3 && native.length !== 5) {
201
- throw new Error(`Variable or list native is of unexpected length: ${native.length}`);
202
- }
203
- const name = native[1];
204
- if (typeof name !== 'string') {
205
- log(`variable or list native name was not a string`);
206
- native[1] = String(native[1]);
207
- }
208
- break;
209
- }
210
- }
211
- };
212
-
213
- /**
214
- * @param {string} id
215
- * @param {unknown} block
216
- */
217
- const fixBlockInPlace = (id, block) => {
218
- if (Array.isArray(block)) {
219
- fixCompressedNativeInPlace(block);
220
- } else if (isObject(block)) {
221
- const inputs = block.inputs;
222
- if (!isObject(inputs)) {
223
- throw new Error('inputs is not an object');
224
- }
225
- for (const [inputName, input] of Object.entries(inputs)) {
226
- if (!Array.isArray(input)) {
227
- throw new Error(`block ${id} input ${inputName} is not an array`);
228
- }
229
- for (let i = 1; i < input.length; i++) {
230
- if (Array.isArray(input[i])) {
231
- fixCompressedNativeInPlace(input[i]);
232
- }
233
- }
234
- }
235
-
236
- const fields = block.fields;
237
- if (!isObject(fields)) {
238
- throw new Error('fields is not an object');
239
- }
240
- for (const [fieldName, field] of Object.entries(fields)) {
241
- if (!Array.isArray(field)) {
242
- throw new Error(`block ${id} field ${fieldName} is not an array`);
243
- }
244
- }
245
- } else {
246
- throw new Error(`block ${id} is not an object`);
247
- }
248
- };
249
-
250
- /**
251
- * @param {string} id
252
- * @param {unknown} comment
253
- */
254
- const fixCommentInPlace = (id, comment) => {
255
- if (!isObject(comment)) {
256
- throw new Error('comment is not an object');
257
- }
258
-
259
- if (typeof comment.text !== 'string') {
260
- throw new Error('comment text is not a string');
261
- }
262
-
263
- // Scratch requires comments to not exceed 8000 characters.
264
- // We'll store the excess in .extraText so the text won't be truncated if opened in TurboWarp.
265
- const MAX_LENGTH = 8000;
266
- if (comment.text.length > MAX_LENGTH) {
267
- log(`comment ${id} had length ${comment.text.length}`);
268
- comment.extraText = comment.text.substring(MAX_LENGTH);
269
- comment.text = comment.text.substring(0, MAX_LENGTH);
270
- }
271
- };
272
-
273
- /**
274
- * @param {unknown} target
275
- */
276
- const fixTargetInPlace = (target) => {
277
- const costumes = target.costumes;
278
- if (!Array.isArray(costumes)) {
279
- throw new Error('costumes is not an array');
280
- }
281
- for (let i = costumes.length - 1; i >= 0; i--) {
282
- const costume = costumes[i];
283
- if (!isObject(costume)) {
284
- throw new Error(`costume ${i} is not an object`);
285
- }
286
-
287
- if (typeof costume.name !== 'string') {
288
- log(`costume ${i} name was not a string`);
289
- costume.name = String(costume.name);
290
- }
291
-
292
- // https://github.com/scratchfoundation/scratch-parser/blob/665f05d739a202d565a4af70a201909393d456b2/lib/sb3_definitions.json#L51
293
- const knownCostumeFormats = ['png', 'svg', 'jpeg', 'jpg', 'bmp', 'gif'];
294
- if (!knownCostumeFormats.includes(costume.dataFormat)) {
295
- if (typeof costume.md5ext === 'string' && costume.md5ext.endsWith('.svg')) {
296
- log(`costume ${i} is vector, had invalid dataFormat ${costume.dataFormat}`);
297
- costume.dataFormat = 'svg';
298
- } else {
299
- log(`costume ${i} is bitmap, had invalid dataFormat ${costume.dataFormat}`);
300
- // dataFormat is only really used to detect vector or bitmap, so we don't
301
- // need to set this to the real format
302
- costume.dataFormat = 'png';
303
- }
304
- }
305
-
306
- if (!('assetId' in costume)) {
307
- log(`costume ${i} was missing assetId, deleted`);
308
- costumes.splice(i, 1);
309
- }
310
- }
311
- if (costumes.length === 0) {
312
- log(`costumes was empty, adding empty costume`);
313
- costumes.push({
314
- // Empty SVG costume
315
- name: 'costume1',
316
- bitmapResolution: 1,
317
- dataFormat: 'svg',
318
- assetId: 'cd21514d0531fdffb22204e0ec5ed84a',
319
- md5ext: 'cd21514d0531fdffb22204e0ec5ed84a.svg',
320
- rotationCenterX: 0,
321
- rotationCenterY: 0
322
- });
323
- }
324
-
325
- const sounds = target.sounds;
326
- if (!Array.isArray(sounds)) {
327
- throw new Error('sounds is not an array');
328
- }
329
- for (let i = sounds.length - 1; i >= 0; i--) {
330
- const sound = sounds[i];
331
- if (!isObject(sound)) {
332
- throw new Error(`sound ${i} is not an object`);
333
- }
334
-
335
- // https://github.com/scratchfoundation/scratch-parser/blob/665f05d739a202d565a4af70a201909393d456b2/lib/sb3_definitions.json#L81
336
- const knownSoundFormats = ['wav', 'wave', 'mp3'];
337
- if (!knownSoundFormats.includes(sound.dataFormat)) {
338
- log(`sound ${i} had invalid dataFormat ${sound.dataFormat}`);
339
- sound.dataFormat = 'mp3';
340
- }
341
-
342
- if (typeof sound.name !== 'string') {
343
- log(`sound ${i} name was not a string`);
344
- sound.name = String(sound.name);
345
- }
346
-
347
- if (!('assetId' in sound)) {
348
- log(`sound ${i} was missing assetId, deleted`);
349
- sounds.splice(i, 1);
350
- }
351
- }
352
-
353
- const blocks = target.blocks;
354
- if (!isObject(blocks)) {
355
- throw new Error('blocks is not an object');
356
- }
357
- for (const [blockId, block] of Object.entries(blocks)) {
358
- fixBlockInPlace(blockId, block);
359
- }
360
-
361
- // Comments are not required
362
- const comments = target.comments;
363
- if (comments) {
364
- for (const [commentId, comment] of Object.entries(comments)) {
365
- fixCommentInPlace(commentId, comment);
366
- }
367
- }
368
-
369
- const variables = target.variables;
370
- if (!isObject(variables)) {
371
- throw new Error('variables is not an object');
372
- }
373
- for (const [variableId, variable] of Object.entries(variables)) {
374
- fixVariableInPlace(variableId, variable);
375
- }
376
-
377
- const lists = target.lists;
378
- if (!isObject(lists)) {
379
- throw new Error('lists is not an object');
380
- }
381
- for (const [listId, list] of Object.entries(lists)) {
382
- fixListInPlace(listId, list);
383
- }
384
-
385
- if (target.isStage) {
386
- if (target.layerOrder !== 0) {
387
- log('stage had invalid layerOrder');
388
- target.layerOrder = 0;
389
- }
390
- } else {
391
- if (target.layerOrder < 1) {
392
- log('sprite had invalid layerOrder');
393
- target.layerOrder = 1;
394
- }
395
- }
396
-
397
- const ROTATION_STYLES = [
398
- 'all around',
399
- 'don\'t rotate',
400
- 'left-right'
401
- ];
402
- if (!target.isStage && !ROTATION_STYLES.includes(target.rotationStyle)) {
403
- log(`sprite had invalid rotation style ${target.rotationStyle}`);
404
- target.rotationStyle = 'all around';
405
- }
406
-
407
- if (!target.isStage) {
408
- const x = target.x;
409
- if (typeof x !== 'number') {
410
- log(`target x was ${typeof x}: ${x}`);
411
- target.x = +x || 0;
412
- }
413
-
414
- const y = target.y;
415
- if (typeof y !== 'number') {
416
- log(`target y was ${typeof y}: ${y}`);
417
- target.y = +y || 0;
418
- }
419
- }
420
- };
421
-
422
- /**
423
- * @param {unknown} stage
424
- */
425
- const fixStageInPlace = (stage) => {
426
- // stage's name must match exactly
427
- if (stage.name !== 'Stage') {
428
- log(`stage had wrong name: ${stage.name}`);
429
- stage.name = 'Stage';
430
- }
431
-
432
- // In vanilla Scratch, "turn video < ... >" with anything that isn't the dropdown allows videoState
433
- // to be set to something that isn't one of the expected strings. We'll play it safe and default to
434
- // off.
435
- const VIDEO_STATES = [
436
- 'on',
437
- 'off',
438
- 'on-flipped'
439
- ];
440
- if (Object.prototype.hasOwnProperty.call(stage, 'videoState') && !VIDEO_STATES.includes(stage.videoState)) {
441
- log(`stage had invalid videoState: ${stage.videoState}`);
442
- stage.videoState = 'off';
443
- }
444
- };
445
-
446
- /**
447
- * @param {unknown} project
448
- */
449
- const fixProjectInPlace = (project) => {
450
- if ('objName' in project) {
451
- throw new Error('Scratch 2 (sb2) projects not supported');
452
- }
453
-
454
- if (!isObject(project)) {
455
- throw new Error('Root JSON is not an object');
456
- }
457
-
458
- if ('name' in project) {
459
- // Not a project. Just a sprite.
460
- log('project is a sprite');
461
- fixTargetInPlace(project);
462
- return;
463
- }
464
-
465
- const targets = project.targets;
466
- if (!Array.isArray(targets)) {
467
- throw new Error('targets is not an array');
468
- }
469
- if (targets.length < 1) {
470
- throw new Error('targets is empty');
471
- }
472
- for (let i = 0; i < targets.length; i++) {
473
- log(`checking target ${i}`);
474
- const target = targets[i];
475
- if (!isObject(target)) {
476
- throw new Error('target is not an object');
477
- }
478
- fixTargetInPlace(target);
479
- }
480
-
481
- const allStages = targets.filter((target) => target.isStage);
482
- if (allStages.length === 0) {
483
- log('stage is missing; adding an empty one');
484
- targets.unshift({
485
- isStage: true,
486
- name: 'Stage',
487
- variables: {},
488
- lists: {},
489
- broadcasts: {},
490
- blocks: {},
491
- currentCostume: 0,
492
- costumes: [
493
- {
494
- name: 'backdrop1',
495
- dataFormat: 'svg',
496
- assetId: 'cd21514d0531fdffb22204e0ec5ed84a',
497
- md5ext: 'cd21514d0531fdffb22204e0ec5ed84a.svg',
498
- rotationCenterX: 240,
499
- rotationCenterY: 180
500
- }
501
- ],
502
- sounds: [],
503
- volume: 100,
504
- layerOrder: 0,
505
- tempo: 60,
506
- videoTransparency: 50,
507
- videoState: "on",
508
- textToSpeechLanguage: null
509
- });
510
- } else {
511
- // We will accept the first stage in targets as the real stage
512
- const firstStageIndex = targets.findIndex((target) => target.isStage);
513
-
514
- // Stage must be the first target
515
- if (firstStageIndex !== 0) {
516
- log(`stage was at wrong index: ${firstStageIndex}`);
517
- const stage = targets[firstStageIndex];
518
- targets.splice(firstStageIndex, 1);
519
- targets.unshift(stage);
520
- }
521
-
522
- // Remove all the other stages
523
- for (let i = targets.length - 1; i > 0; i--) {
524
- if (targets[i].isStage) {
525
- log(`removing extra stage at index ${i}`);
526
- targets.splice(i, 1);
527
- }
528
- }
529
- }
530
-
531
- // Above checks ensure this invariant holds
532
- const stage = targets[0];
533
- fixStageInPlace(stage);
534
-
535
- const knownExtensions = getKnownExtensions(project);
536
- const monitors = project.monitors;
537
- if (!Array.isArray(monitors)) {
538
- throw new Error('monitors is not an array');
539
- }
540
- project.monitors = project.monitors.filter((monitor, i) => {
541
- const opcode = monitor.opcode;
542
- if (typeof opcode !== 'string') {
543
- throw new Error(`monitor ${i} opcode is not a string`);
544
- }
545
- const extension = opcode.split('_')[0];
546
- if (!knownExtensions.has(extension)) {
547
- log(`removed monitor ${i} from unknown extension ${extension}`);
548
- return false;
549
- }
550
- return true;
551
- });
552
- };
553
-
554
- if (typeof data === 'object' && data !== null) {
555
- // Already parsed.
556
- fixProjectInPlace(data);
557
- return data;
558
- } else if (typeof data === 'string') {
559
- // Need to parse.
560
- const parsed = JSON.parse(data);
561
- fixProjectInPlace(parsed);
562
- return parsed;
563
- } else {
564
- throw new Error('Unable to tell how to interpret input as JSON');
565
- }
566
- };
567
-
568
- /**
569
- * @param {ArrayBuffer|Uint8Array|Blob} data A compressed .sb3 file.
570
- * @param {Options} [options]
571
- * @returns {Promise<ArrayBuffer>} A promise that resolves to a fixed compressed .sb3 file.
572
- */
573
- const fixZip = async (data, options = {}) => {
574
- // JSZip is not a small library, so we'll load it somewhat lazily.
575
- const JSZip = require('@turbowarp/jszip');
576
-
577
- const zip = await JSZip.loadAsync(data);
578
-
579
- // json is not guaranteed to be stored in the root.
580
- const jsonFile = zip.file(/(?:project|sprite)\.json/)[0];
581
- if (!jsonFile) {
582
- throw new Error('Could not find project.json or sprite.json.');
583
- }
584
-
585
- const jsonText = await jsonFile.async('text');
586
- const fixedJSON = fixJSON(jsonText, options);
587
- const newProjectJSONText = JSON.stringify(fixedJSON);
588
- zip.file(jsonFile.name, newProjectJSONText);
589
-
590
- // By default, JSZip will use the current date as the modified timestamp, which would generated zips non-deterministic.
591
- const date = new Date('Thu, 14 Mar 2024 00:00:00 GMT');
592
- for (const file of Object.values(zip.files)) {
593
- file.date = date;
594
- }
595
-
596
- const compressed = await zip.generateAsync({
597
- type: 'uint8array',
598
- compression: 'DEFLATE'
599
- });
600
- return compressed;
601
- };
602
-
603
- module.exports = {
604
- fixJSON,
605
- fixZip
606
- };
1
+ /*!
2
+ sb3fix - https://github.com/TurboWarp/sb3fix
3
+
4
+ Copyright (C) 2023-2025 Thomas Weber
5
+
6
+ This Source Code Form is subject to the terms of the Mozilla Public
7
+ License, v. 2.0. If a copy of the MPL was not distributed with this
8
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
9
+ */
10
+
11
+ /**
12
+ * @typedef {'scratch'|'turbowarp'} Platform
13
+ */
14
+
15
+ /**
16
+ * @typedef Options
17
+ * @property {Platform} [platform] Defaults to 'scratch'.
18
+ * @property {(message: string) => void} [logCallback]
19
+ */
20
+
21
+ /**
22
+ * @param {unknown} obj
23
+ * @returns {obj is object}
24
+ */
25
+ const isObject = (obj) => !!obj && typeof obj === 'object';
26
+
27
+ /**
28
+ * @typedef PlatformInfo
29
+ * @property {boolean} [allowsNonScalarVariables]
30
+ */
31
+
32
+ /**
33
+ * @type {Record<Platform, PlatformInfo>}
34
+ */
35
+ const platforms = {
36
+ scratch: {},
37
+ turbowarp: {
38
+ allowsNonScalarVariables: true
39
+ }
40
+ };
41
+
42
+ /**
43
+ * @param {Options} options
44
+ * @returns {PlatformInfo}
45
+ */
46
+ const getPlatform = (options) => {
47
+ if (options && Object.prototype.hasOwnProperty.call(options, 'platform')) {
48
+ if (Object.prototype.hasOwnProperty.call(platforms, options.platform)) {
49
+ return platforms[options.platform];
50
+ }
51
+ throw new Error(`Unknown platform: ${options.platform}`);
52
+ }
53
+ return platforms.scratch;
54
+ };
55
+
56
+ const BUILTIN_EXTENSIONS = [
57
+ 'control',
58
+ 'data',
59
+ 'event',
60
+ 'looks',
61
+ 'motion',
62
+ 'operators',
63
+ 'procedures',
64
+ 'argument', // "argument_reporter_boolean" is technically not an extension but we should list here anyways
65
+ 'sensing',
66
+ 'sound',
67
+ 'pen',
68
+ 'wedo2',
69
+ 'music',
70
+ 'microbit',
71
+ 'text2speech',
72
+ 'translate',
73
+ 'videoSensing',
74
+ 'ev3',
75
+ 'makeymakey',
76
+ 'boost',
77
+ 'gdxfor'
78
+ // intentionally not listing TurboWarp's 'tw' extension here.
79
+ ];
80
+
81
+ /**
82
+ * @param {object} project Parsed project.json.
83
+ * @returns {Set<string>} Set of valid extensions, including the primitive ones, that the project loads.
84
+ */
85
+ const getKnownExtensions = (project) => {
86
+ const extensions = project.extensions;
87
+ if (!Array.isArray(extensions)) {
88
+ throw new Error('extensions is not an array');
89
+ }
90
+ for (let i = 0; i < extensions.length; i++) {
91
+ if (typeof extensions[i] !== 'string') {
92
+ throw new Error(`extension ${i} is not a string`);
93
+ }
94
+ }
95
+ return new Set([
96
+ ...BUILTIN_EXTENSIONS,
97
+ ...extensions
98
+ ]);
99
+ };
100
+
101
+ /**
102
+ * @param {string|object} data project.json as a string or as a parsed object already. If object provided, it will be modified in-place.
103
+ * @param {Options} [options]
104
+ * @returns {object} Fixed project.json object. If the `data` argument was an object, this will point to the same object.
105
+ */
106
+ const fixJSON = (data, options = {}) => {
107
+ const platform = getPlatform(options);
108
+
109
+ /**
110
+ * @param {string} message
111
+ */
112
+ const log = (message) => {
113
+ if (options.logCallback) {
114
+ options.logCallback(message);
115
+ }
116
+ };
117
+
118
+ /**
119
+ * @param {string} id
120
+ * @param {unknown} variable
121
+ */
122
+ const fixVariableInPlace = (id, variable) => {
123
+ if (!Array.isArray(variable)) {
124
+ throw new Error(`variable object ${id} is not an array`);
125
+ }
126
+
127
+ const name = variable[0];
128
+ if (typeof name !== 'string') {
129
+ log(`variable or list ${id} name was not a string`);
130
+ variable[0] = String(variable[0]);
131
+ }
132
+
133
+ if (!platform.allowsNonScalarVariables) {
134
+ const value = variable[1];
135
+ if (typeof value !== 'number' && typeof value !== 'string' && typeof value !== 'boolean') {
136
+ log(`variable ${id} value was not a Scratch-compatible value`);
137
+ variable[1] = String(variable[1]);
138
+ }
139
+ }
140
+ };
141
+
142
+ /**
143
+ * @param {string} id
144
+ * @param {unknown} list
145
+ */
146
+ const fixListInPlace = (id, list) => {
147
+ if (!Array.isArray(list)) {
148
+ throw new Error(`list object ${id} is not an array`);
149
+ }
150
+
151
+ const name = list[0];
152
+ if (typeof name !== 'string') {
153
+ log(`list ${id} name was not a string`);
154
+ list[0] = String(list[0]);
155
+ }
156
+
157
+ if (!Array.isArray(list[1])) {
158
+ log(`list ${id} value was not an array`);
159
+ list[1] = [];
160
+ }
161
+
162
+ const listValue = list[1];
163
+ for (let i = 0; i < listValue.length; i++) {
164
+ const value = listValue[i];
165
+ if (typeof value !== 'number' && typeof value !== 'string' && typeof value !== 'boolean') {
166
+ log(`list ${id} index ${i} was not a Scratch-compatible value`);
167
+ listValue[i] = String(value);
168
+ }
169
+ }
170
+ };
171
+
172
+ /**
173
+ * @param {unknown[]} native
174
+ */
175
+ const fixCompressedNativeInPlace = (native) => {
176
+ if (!Array.isArray(native)) {
177
+ throw new Error('native is not an array');
178
+ }
179
+
180
+ const type = native[0];
181
+ if (typeof type !== 'number') {
182
+ throw new Error('native type is not a number');
183
+ }
184
+
185
+ switch (type) {
186
+ // Number primitive: [4, string|number]
187
+ // Positive number primitive: [5, string|number]
188
+ // Whole number primitive: [6, string|number]
189
+ // Integer primitive: [7, string|number]
190
+ // Angle primitive: [8, string|number]
191
+ case 4:
192
+ case 5:
193
+ case 6:
194
+ case 7:
195
+ case 8: {
196
+ if (native.length !== 2) {
197
+ throw new Error(`Number native is of unexpected length: ${native.length}`);
198
+ }
199
+ const value = native[1];
200
+ if (typeof value !== 'string' && typeof value !== 'number') {
201
+ log('number native had invalid value');
202
+ native[1] = String(value);
203
+ }
204
+ break;
205
+ }
206
+
207
+ // Color: [9, hex color]
208
+ case 9: {
209
+ if (native.length !== 2) {
210
+ throw new Error(`Color native is of unexpected length: ${native.length}`);
211
+ }
212
+ const color = native[1];
213
+ if (typeof color !== 'string' || !/^#[a-f0-9]{6}$/i.test(color)) {
214
+ log('color native had invalid value');
215
+ native[1] = '#000000';
216
+ }
217
+ break;
218
+ }
219
+
220
+ // Text: [10, string|number]
221
+ case 10: {
222
+ if (native.length !== 2) {
223
+ throw new Error(`Text native is of unexpected length: ${native.length}`);
224
+ }
225
+ const value = native[1];
226
+ if (typeof value !== 'string' && typeof value !== 'number') {
227
+ log('text native had invalid value');
228
+ native[1] = String(value);
229
+ }
230
+ break;
231
+ }
232
+
233
+ // Variable: [12, variable name, variable id, x?, y?]
234
+ // List: [13, list name, list id, x?, y?]
235
+ // x and y only present if the native is a top-level block
236
+ case 12:
237
+ case 13: {
238
+ if (native.length !== 3 && native.length !== 5) {
239
+ throw new Error(`Variable or list native is of unexpected length: ${native.length}`);
240
+ }
241
+ const name = native[1];
242
+ if (typeof name !== 'string') {
243
+ log(`variable or list native name was not a string`);
244
+ native[1] = String(native[1]);
245
+ }
246
+ break;
247
+ }
248
+ }
249
+ };
250
+
251
+ /**
252
+ * @param {string} id
253
+ * @param {unknown} block
254
+ */
255
+ const fixBlockInPlace = (id, block) => {
256
+ if (Array.isArray(block)) {
257
+ fixCompressedNativeInPlace(block);
258
+ } else if (isObject(block)) {
259
+ const inputs = block.inputs;
260
+ if (!isObject(inputs)) {
261
+ throw new Error('inputs is not an object');
262
+ }
263
+ for (const [inputName, input] of Object.entries(inputs)) {
264
+ if (!Array.isArray(input)) {
265
+ throw new Error(`block ${id} input ${inputName} is not an array`);
266
+ }
267
+ for (let i = 1; i < input.length; i++) {
268
+ if (Array.isArray(input[i])) {
269
+ fixCompressedNativeInPlace(input[i]);
270
+ }
271
+ }
272
+ }
273
+
274
+ const fields = block.fields;
275
+ if (!isObject(fields)) {
276
+ throw new Error('fields is not an object');
277
+ }
278
+ for (const [fieldName, field] of Object.entries(fields)) {
279
+ if (!Array.isArray(field)) {
280
+ throw new Error(`block ${id} field ${fieldName} is not an array`);
281
+ }
282
+ }
283
+ } else {
284
+ throw new Error(`block ${id} is not an object`);
285
+ }
286
+ };
287
+
288
+ /**
289
+ * @param {string} id
290
+ * @param {unknown} comment
291
+ */
292
+ const fixCommentInPlace = (id, comment) => {
293
+ if (!isObject(comment)) {
294
+ throw new Error('comment is not an object');
295
+ }
296
+
297
+ if (typeof comment.text !== 'string') {
298
+ throw new Error('comment text is not a string');
299
+ }
300
+
301
+ // Scratch requires comments to not exceed 8000 characters.
302
+ // We'll store the excess in .extraText so the text won't be truncated if opened in TurboWarp.
303
+ const MAX_LENGTH = 8000;
304
+ if (comment.text.length > MAX_LENGTH) {
305
+ log(`comment ${id} had length ${comment.text.length}`);
306
+ comment.extraText = comment.text.substring(MAX_LENGTH);
307
+ comment.text = comment.text.substring(0, MAX_LENGTH);
308
+ }
309
+ };
310
+
311
+ /**
312
+ * @param {unknown} target
313
+ */
314
+ const fixTargetInPlace = (target) => {
315
+ const costumes = target.costumes;
316
+ if (!Array.isArray(costumes)) {
317
+ throw new Error('costumes is not an array');
318
+ }
319
+ for (let i = costumes.length - 1; i >= 0; i--) {
320
+ const costume = costumes[i];
321
+ if (!isObject(costume)) {
322
+ throw new Error(`costume ${i} is not an object`);
323
+ }
324
+
325
+ if (typeof costume.name !== 'string') {
326
+ log(`costume ${i} name was not a string`);
327
+ costume.name = String(costume.name);
328
+ }
329
+
330
+ // https://github.com/scratchfoundation/scratch-parser/blob/665f05d739a202d565a4af70a201909393d456b2/lib/sb3_definitions.json#L51
331
+ const knownCostumeFormats = ['png', 'svg', 'jpeg', 'jpg', 'bmp', 'gif'];
332
+ if (!knownCostumeFormats.includes(costume.dataFormat)) {
333
+ if (typeof costume.md5ext === 'string' && costume.md5ext.endsWith('.svg')) {
334
+ log(`costume ${i} is vector, had invalid dataFormat ${costume.dataFormat}`);
335
+ costume.dataFormat = 'svg';
336
+ } else {
337
+ log(`costume ${i} is bitmap, had invalid dataFormat ${costume.dataFormat}`);
338
+ // dataFormat is only really used to detect vector or bitmap, so we don't
339
+ // need to set this to the real format
340
+ costume.dataFormat = 'png';
341
+ }
342
+ }
343
+
344
+ if (!('assetId' in costume)) {
345
+ log(`costume ${i} was missing assetId, deleted`);
346
+ costumes.splice(i, 1);
347
+ }
348
+ }
349
+ if (costumes.length === 0) {
350
+ log(`costumes was empty, adding empty costume`);
351
+ costumes.push({
352
+ // Empty SVG costume
353
+ name: 'costume1',
354
+ bitmapResolution: 1,
355
+ dataFormat: 'svg',
356
+ assetId: 'cd21514d0531fdffb22204e0ec5ed84a',
357
+ md5ext: 'cd21514d0531fdffb22204e0ec5ed84a.svg',
358
+ rotationCenterX: 0,
359
+ rotationCenterY: 0
360
+ });
361
+ }
362
+
363
+ const sounds = target.sounds;
364
+ if (!Array.isArray(sounds)) {
365
+ throw new Error('sounds is not an array');
366
+ }
367
+ for (let i = sounds.length - 1; i >= 0; i--) {
368
+ const sound = sounds[i];
369
+ if (!isObject(sound)) {
370
+ throw new Error(`sound ${i} is not an object`);
371
+ }
372
+
373
+ // https://github.com/scratchfoundation/scratch-parser/blob/665f05d739a202d565a4af70a201909393d456b2/lib/sb3_definitions.json#L81
374
+ const knownSoundFormats = ['wav', 'wave', 'mp3'];
375
+ if (!knownSoundFormats.includes(sound.dataFormat)) {
376
+ log(`sound ${i} had invalid dataFormat ${sound.dataFormat}`);
377
+ sound.dataFormat = 'mp3';
378
+ }
379
+
380
+ if (typeof sound.name !== 'string') {
381
+ log(`sound ${i} name was not a string`);
382
+ sound.name = String(sound.name);
383
+ }
384
+
385
+ if (!('assetId' in sound)) {
386
+ log(`sound ${i} was missing assetId, deleted`);
387
+ sounds.splice(i, 1);
388
+ }
389
+ }
390
+
391
+ const blocks = target.blocks;
392
+ if (!isObject(blocks)) {
393
+ throw new Error('blocks is not an object');
394
+ }
395
+ for (const [blockId, block] of Object.entries(blocks)) {
396
+ fixBlockInPlace(blockId, block);
397
+ }
398
+
399
+ // Comments are not required
400
+ const comments = target.comments;
401
+ if (comments) {
402
+ for (const [commentId, comment] of Object.entries(comments)) {
403
+ fixCommentInPlace(commentId, comment);
404
+ }
405
+ }
406
+
407
+ const variables = target.variables;
408
+ if (!isObject(variables)) {
409
+ throw new Error('variables is not an object');
410
+ }
411
+ for (const [variableId, variable] of Object.entries(variables)) {
412
+ fixVariableInPlace(variableId, variable);
413
+ }
414
+
415
+ const lists = target.lists;
416
+ if (!isObject(lists)) {
417
+ throw new Error('lists is not an object');
418
+ }
419
+ for (const [listId, list] of Object.entries(lists)) {
420
+ fixListInPlace(listId, list);
421
+ }
422
+
423
+ if (target.isStage) {
424
+ if (target.layerOrder !== 0) {
425
+ log('stage had invalid layerOrder');
426
+ target.layerOrder = 0;
427
+ }
428
+ } else {
429
+ if (target.layerOrder < 1) {
430
+ log('sprite had invalid layerOrder');
431
+ target.layerOrder = 1;
432
+ }
433
+ }
434
+
435
+ const ROTATION_STYLES = [
436
+ 'all around',
437
+ 'don\'t rotate',
438
+ 'left-right'
439
+ ];
440
+ if (!target.isStage && !ROTATION_STYLES.includes(target.rotationStyle)) {
441
+ log(`sprite had invalid rotation style ${target.rotationStyle}`);
442
+ target.rotationStyle = 'all around';
443
+ }
444
+
445
+ if (!target.isStage) {
446
+ const x = target.x;
447
+ if (typeof x !== 'number') {
448
+ log(`target x was ${typeof x}: ${x}`);
449
+ target.x = +x || 0;
450
+ }
451
+
452
+ const y = target.y;
453
+ if (typeof y !== 'number') {
454
+ log(`target y was ${typeof y}: ${y}`);
455
+ target.y = +y || 0;
456
+ }
457
+ }
458
+ };
459
+
460
+ /**
461
+ * @param {unknown} stage
462
+ */
463
+ const fixStageInPlace = (stage) => {
464
+ // stage's name must match exactly
465
+ if (stage.name !== 'Stage') {
466
+ log(`stage had wrong name: ${stage.name}`);
467
+ stage.name = 'Stage';
468
+ }
469
+
470
+ // In vanilla Scratch, "turn video < ... >" with anything that isn't the dropdown allows videoState
471
+ // to be set to something that isn't one of the expected strings. We'll play it safe and default to
472
+ // off.
473
+ const VIDEO_STATES = [
474
+ 'on',
475
+ 'off',
476
+ 'on-flipped'
477
+ ];
478
+ if (Object.prototype.hasOwnProperty.call(stage, 'videoState') && !VIDEO_STATES.includes(stage.videoState)) {
479
+ log(`stage had invalid videoState: ${stage.videoState}`);
480
+ stage.videoState = 'off';
481
+ }
482
+ };
483
+
484
+ /**
485
+ * @param {unknown} project
486
+ */
487
+ const fixProjectInPlace = (project) => {
488
+ if ('objName' in project) {
489
+ throw new Error('Scratch 2 (sb2) projects not supported');
490
+ }
491
+
492
+ if (!isObject(project)) {
493
+ throw new Error('Root JSON is not an object');
494
+ }
495
+
496
+ if ('name' in project) {
497
+ // Not a project. Just a sprite.
498
+ log('project is a sprite');
499
+ fixTargetInPlace(project);
500
+ return;
501
+ }
502
+
503
+ const targets = project.targets;
504
+ if (!Array.isArray(targets)) {
505
+ throw new Error('targets is not an array');
506
+ }
507
+ if (targets.length < 1) {
508
+ throw new Error('targets is empty');
509
+ }
510
+ for (let i = 0; i < targets.length; i++) {
511
+ log(`checking target ${i}`);
512
+ const target = targets[i];
513
+ if (!isObject(target)) {
514
+ throw new Error('target is not an object');
515
+ }
516
+ fixTargetInPlace(target);
517
+ }
518
+
519
+ const allStages = targets.filter((target) => target.isStage);
520
+ if (allStages.length === 0) {
521
+ log('stage is missing; adding an empty one');
522
+ targets.unshift({
523
+ isStage: true,
524
+ name: 'Stage',
525
+ variables: {},
526
+ lists: {},
527
+ broadcasts: {},
528
+ blocks: {},
529
+ currentCostume: 0,
530
+ costumes: [
531
+ {
532
+ name: 'backdrop1',
533
+ dataFormat: 'svg',
534
+ assetId: 'cd21514d0531fdffb22204e0ec5ed84a',
535
+ md5ext: 'cd21514d0531fdffb22204e0ec5ed84a.svg',
536
+ rotationCenterX: 240,
537
+ rotationCenterY: 180
538
+ }
539
+ ],
540
+ sounds: [],
541
+ volume: 100,
542
+ layerOrder: 0,
543
+ tempo: 60,
544
+ videoTransparency: 50,
545
+ videoState: "on",
546
+ textToSpeechLanguage: null
547
+ });
548
+ } else {
549
+ // We will accept the first stage in targets as the real stage
550
+ const firstStageIndex = targets.findIndex((target) => target.isStage);
551
+
552
+ // Stage must be the first target
553
+ if (firstStageIndex !== 0) {
554
+ log(`stage was at wrong index: ${firstStageIndex}`);
555
+ const stage = targets[firstStageIndex];
556
+ targets.splice(firstStageIndex, 1);
557
+ targets.unshift(stage);
558
+ }
559
+
560
+ // Remove all the other stages
561
+ for (let i = targets.length - 1; i > 0; i--) {
562
+ if (targets[i].isStage) {
563
+ log(`removing extra stage at index ${i}`);
564
+ targets.splice(i, 1);
565
+ }
566
+ }
567
+ }
568
+
569
+ // Above checks ensure this invariant holds
570
+ const stage = targets[0];
571
+ fixStageInPlace(stage);
572
+
573
+ const knownExtensions = getKnownExtensions(project);
574
+ const monitors = project.monitors;
575
+ if (!Array.isArray(monitors)) {
576
+ throw new Error('monitors is not an array');
577
+ }
578
+ project.monitors = project.monitors.filter((monitor, i) => {
579
+ const opcode = monitor.opcode;
580
+ if (typeof opcode !== 'string') {
581
+ throw new Error(`monitor ${i} opcode is not a string`);
582
+ }
583
+ const extension = opcode.split('_')[0];
584
+ if (!knownExtensions.has(extension)) {
585
+ log(`removed monitor ${i} from unknown extension ${extension}`);
586
+ return false;
587
+ }
588
+ return true;
589
+ });
590
+ };
591
+
592
+ if (typeof data === 'object' && data !== null) {
593
+ // Already parsed.
594
+ fixProjectInPlace(data);
595
+ return data;
596
+ } else if (typeof data === 'string') {
597
+ // Need to parse.
598
+ const parsed = JSON.parse(data);
599
+ fixProjectInPlace(parsed);
600
+ return parsed;
601
+ } else {
602
+ throw new Error('Unable to tell how to interpret input as JSON');
603
+ }
604
+ };
605
+
606
+ /**
607
+ * @param {ArrayBuffer|Uint8Array|Blob} data A compressed .sb3 file.
608
+ * @param {Options} [options]
609
+ * @returns {Promise<Uint8Array>} A promise that resolves to a fixed compressed .sb3 file.
610
+ */
611
+ const fixZip = async (data, options = {}) => {
612
+ /**
613
+ * @param {string} message
614
+ */
615
+ const log = (message) => {
616
+ if (options.logCallback) {
617
+ options.logCallback(message);
618
+ }
619
+ };
620
+
621
+ // JSZip is not a small library, so we'll load it somewhat lazily.
622
+ const JSZip = require('@turbowarp/jszip');
623
+
624
+ let zip = await JSZip.loadAsync(data, {
625
+ recoverCorrupted: true,
626
+ onCorruptCentralDirectory: (error) => {
627
+ log(`zip had corrupt central directory: ${error}`);
628
+ },
629
+ onUnrecoverableFileEntry: (error) => {
630
+ log(`zip had unrecoverable file entry: ${error}`);
631
+ }
632
+ });
633
+
634
+ /** @type {Array<[string, import("@turbowarp/jszip").JSZipObject]>} */
635
+ const zipFiles = [];
636
+ zip.forEach((relativePath, file) => {
637
+ zipFiles.push([relativePath, file]);
638
+ });
639
+
640
+ // Remove any unreadable files from the zip. This can notably happen if the compressed data in the zip was
641
+ // corrupted, which would make the uncompressed data size field not match. Scratch/JSZip will refuse to
642
+ // keep loading the project if that happens. If we remove the asset, at least there's a chance it can now
643
+ // be downloaded from the asset server instead.
644
+ for (const [relativePath, file] of zipFiles) {
645
+ try {
646
+ await file.async('uint8array');
647
+ } catch (error) {
648
+ log(`zip had unreadable file ${relativePath}: ${error}`);
649
+ zip.remove(relativePath);
650
+ }
651
+ }
652
+
653
+ // json is not guaranteed to be stored in the root.
654
+ const jsonFile = zip.file(/(?:project|sprite)\.json/)[0];
655
+ if (!jsonFile) {
656
+ throw new Error('Could not find project.json or sprite.json.');
657
+ }
658
+
659
+ const jsonText = await jsonFile.async('text');
660
+ const fixedJSON = fixJSON(jsonText, options);
661
+ const newProjectJSONText = JSON.stringify(fixedJSON);
662
+ zip.file(jsonFile.name, newProjectJSONText);
663
+
664
+ // By default, JSZip will use the current date as the modified timestamp, which would generated zips non-deterministic.
665
+ const date = new Date('Thu, 14 Mar 2024 00:00:00 GMT');
666
+ for (const file of Object.values(zip.files)) {
667
+ file.date = date;
668
+ }
669
+
670
+ const compressed = await zip.generateAsync({
671
+ type: 'uint8array',
672
+ compression: 'DEFLATE'
673
+ });
674
+ return compressed;
675
+ };
676
+
677
+ module.exports = {
678
+ fixJSON,
679
+ fixZip,
680
+ platforms
681
+ };