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