@triflare/fx 0.0.0-stage → 1.0.1

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 (39) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +73 -2
  3. package/bin/fx.js +1166 -0
  4. package/package.json +64 -4
  5. package/product.yml +28 -0
  6. package/src/brand.js +105 -0
  7. package/src/build.js +181 -0
  8. package/src/catalog.js +753 -0
  9. package/src/compiler.js +1658 -0
  10. package/src/config-edit.js +338 -0
  11. package/src/config.js +400 -0
  12. package/src/decompiler.js +551 -0
  13. package/src/dev.js +390 -0
  14. package/src/errors.js +56 -0
  15. package/src/extensions.js +747 -0
  16. package/src/index.js +35 -0
  17. package/src/language.js +12 -0
  18. package/src/lexer.js +202 -0
  19. package/src/log.js +70 -0
  20. package/src/package.js +202 -0
  21. package/src/packager.js +177 -0
  22. package/src/parser.js +608 -0
  23. package/src/product.js +27 -0
  24. package/src/project.js +156 -0
  25. package/src/sb3.js +340 -0
  26. package/src/sprite.js +81 -0
  27. package/vendor/pm-packager-dist/.fx-build.json +6 -0
  28. package/vendor/pm-packager-dist/LICENSE +202 -0
  29. package/vendor/pm-packager-dist/NOTICE +6 -0
  30. package/vendor/pm-packager-dist/dist/290e09e569a1cab8e61ba93b0d23863f.png +0 -0
  31. package/vendor/pm-packager-dist/dist/icns.js +1 -0
  32. package/vendor/pm-packager-dist/dist/packager.js +154 -0
  33. package/vendor/pm-packager-dist/dist/scaffolding/addons.js +2 -0
  34. package/vendor/pm-packager-dist/dist/scaffolding/extension worker.js +2 -0
  35. package/vendor/pm-packager-dist/dist/scaffolding/scaffolding-full.js +93 -0
  36. package/vendor/pm-packager-dist/dist/scaffolding/scaffolding-min.js +93 -0
  37. package/vendor/pm-packager-dist/dist/scaffolding/vendors~iframe-extension-worker.js +2 -0
  38. package/vendor/pm-packager-dist/dist/vendors~sha256.js +3 -0
  39. package/vendor/pm-packager-dist/package.json +3 -0
package/src/project.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Multi-file project assembly.
3
+ *
4
+ * A Triflare Fx project can be spread across any number of `.tfx` files in a
5
+ * directory tree. This module discovers those files and merges their ASTs into
6
+ * a single `Program`, so the rest of the pipeline keeps working with one AST.
7
+ */
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { ParseError } from "./errors.js";
11
+ import { configFilename, sourceExtension } from "./brand.js";
12
+
13
+ /** Directories never scanned for `.tfx` sources. */
14
+ const IGNORED_DIRS = new Set([
15
+ "node_modules",
16
+ "build",
17
+ "dist",
18
+ "coverage",
19
+ ".cache",
20
+ ]);
21
+
22
+ /**
23
+ * Recursively find every `.tfx` file under `dir`, skipping hidden entries and
24
+ * well-known build/dependency directories. Results are deterministic (sorted by
25
+ * path) so builds are reproducible.
26
+ * @param {string} dir
27
+ * @returns {string[]} absolute file paths
28
+ */
29
+ export function discoverSources(dir) {
30
+ const root = path.resolve(dir);
31
+ const found = [];
32
+ const extension = sourceExtension().toLowerCase();
33
+
34
+ const walk = (current) => {
35
+ let entries;
36
+ try {
37
+ entries = fs.readdirSync(current, { withFileTypes: true });
38
+ } catch {
39
+ return;
40
+ }
41
+ entries.sort((a, b) => a.name.localeCompare(b.name));
42
+ for (const entry of entries) {
43
+ if (entry.name.startsWith(".")) continue;
44
+ const full = path.join(current, entry.name);
45
+ if (entry.isDirectory()) {
46
+ if (IGNORED_DIRS.has(entry.name)) continue;
47
+ walk(full);
48
+ } else if (
49
+ entry.isFile() &&
50
+ entry.name.toLowerCase().endsWith(extension)
51
+ ) {
52
+ found.push(full);
53
+ }
54
+ }
55
+ };
56
+
57
+ walk(root);
58
+ return found.sort();
59
+ }
60
+
61
+ /** @param {object} loc */
62
+ function errorInfo(loc) {
63
+ return {
64
+ file: loc && loc.file,
65
+ line: loc && loc.line,
66
+ column: loc && loc.column,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Merge parsed programs into a single project AST.
72
+ *
73
+ * - At most one `stage { }` declaration is allowed across all files.
74
+ * - Top-level statements from every file become stage content.
75
+ * - At most one plain `sprite "Name" { }` declaration is allowed per name.
76
+ * Additional `partial sprite "Name" { }` fragments are appended to it.
77
+ * - Partial sprites only merge when `partialSprites` is enabled.
78
+ *
79
+ * Bodies are composed base-first, then fragments in build order, so costume
80
+ * order and local variable initializers stay predictable regardless of which
81
+ * file happens to sort first.
82
+ *
83
+ * @param {Array<{ast: object, file: string}>} entries
84
+ * @param {{partialSprites?: boolean}} [options]
85
+ * @returns {object} a `Program` node
86
+ */
87
+ export function mergePrograms(entries, { partialSprites = false } = {}) {
88
+ let stage = null;
89
+ const topItems = [];
90
+ /** @type {Map<string, {name: string, loc: object, base: object|null, fragments: object[], files: string[]}>} */
91
+ const groups = new Map();
92
+
93
+ for (const { ast, file } of entries) {
94
+ if (ast.stage) {
95
+ if (stage) {
96
+ throw new ParseError(
97
+ "A project may only declare one stage",
98
+ errorInfo(ast.stage.loc),
99
+ );
100
+ }
101
+ stage = ast.stage;
102
+ }
103
+ topItems.push(...ast.topItems);
104
+ for (const sprite of ast.sprites) {
105
+ if (sprite.partial && !partialSprites) {
106
+ throw new ParseError(
107
+ `partial sprite ${JSON.stringify(sprite.name)} is disabled; ` +
108
+ `set "fx.partialSprites: true" in ${configFilename()} to enable it`,
109
+ errorInfo(sprite.loc),
110
+ );
111
+ }
112
+
113
+ let group = groups.get(sprite.name);
114
+ if (!group) {
115
+ group = {
116
+ name: sprite.name,
117
+ loc: sprite.loc,
118
+ base: null,
119
+ fragments: [],
120
+ files: [],
121
+ };
122
+ groups.set(sprite.name, group);
123
+ }
124
+ group.files.push(file);
125
+
126
+ if (sprite.partial) {
127
+ group.fragments.push({ decl: sprite, file });
128
+ } else if (group.base) {
129
+ throw new ParseError(
130
+ `Duplicate sprite ${JSON.stringify(sprite.name)} ` +
131
+ `(declared in ${group.base.file} and ${file}); ` +
132
+ `use \`partial sprite ${JSON.stringify(sprite.name)}\` to split it across files`,
133
+ errorInfo(sprite.loc),
134
+ );
135
+ } else {
136
+ group.base = { decl: sprite, file };
137
+ }
138
+ }
139
+ }
140
+
141
+ const sprites = [...groups.values()].map((group) => {
142
+ const base = group.base ? [group.base.decl] : [];
143
+ const fragments = group.fragments.map(({ decl }) => decl);
144
+ const [first] = base.length ? base : fragments;
145
+ return {
146
+ type: "SpriteDecl",
147
+ name: group.name,
148
+ body: [...base, ...fragments].flatMap((decl) => decl.body),
149
+ loc: first.loc,
150
+ partial: false,
151
+ files: group.files,
152
+ };
153
+ });
154
+
155
+ return { type: "Program", stage, sprites, topItems };
156
+ }
package/src/sb3.js ADDED
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Minimal, dependency-free ZIP reader/writer for Scratch 3 project archives
3
+ * (both `.sb3` and PenguinMod's `.pmp`, which share the same format) plus
4
+ * asset helpers.
5
+ *
6
+ * The Scratch 3 project format is a ZIP archive containing `project.json`
7
+ * alongside content-addressed asset files named `<md5>.<ext>`.
8
+ */
9
+ import zlib from "node:zlib";
10
+ import crypto from "node:crypto";
11
+
12
+ // --------------------------------------------------------------------- crc32
13
+
14
+ let CRC_TABLE = null;
15
+ function crcTable() {
16
+ if (CRC_TABLE) return CRC_TABLE;
17
+ CRC_TABLE = new Uint32Array(256);
18
+ for (let n = 0; n < 256; n++) {
19
+ let c = n;
20
+ for (let k = 0; k < 8; k++) {
21
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
22
+ }
23
+ CRC_TABLE[n] = c >>> 0;
24
+ }
25
+ return CRC_TABLE;
26
+ }
27
+
28
+ /** @param {Buffer|Uint8Array} buf */
29
+ export function crc32(buf) {
30
+ const table = crcTable();
31
+ let crc = 0xffffffff;
32
+ for (let i = 0; i < buf.length; i++) {
33
+ crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
34
+ }
35
+ return (crc ^ 0xffffffff) >>> 0;
36
+ }
37
+
38
+ /** @param {Buffer|Uint8Array} buf */
39
+ export function md5hex(buf) {
40
+ return crypto.createHash("md5").update(buf).digest("hex");
41
+ }
42
+
43
+ // ----------------------------------------------------------------------- zip
44
+
45
+ const DOS_TIME = 0; // 00:00:00
46
+ const DOS_DATE = ((2024 - 1980) << 9) | (1 << 5) | 1; // 2024-01-01
47
+
48
+ // Upper bound on a single entry's uncompressed size, guarding against
49
+ // decompression bombs. 512 MiB is far beyond any realistic Scratch asset.
50
+ const MAX_UNCOMPRESSED_SIZE = 512 * 1024 * 1024;
51
+ // Cap cumulative output retained while extracting an archive as well.
52
+ const MAX_ARCHIVE_UNCOMPRESSED_SIZE = 512 * 1024 * 1024;
53
+
54
+ /**
55
+ * Build a ZIP archive entirely in memory.
56
+ * @param {Array<{name: string, data: Buffer|Uint8Array, compress?: boolean}>} files
57
+ * @returns {Buffer}
58
+ */
59
+ export function zipSync(files) {
60
+ const chunks = [];
61
+ const central = [];
62
+ let offset = 0;
63
+
64
+ for (const file of files) {
65
+ const nameBuf = Buffer.from(file.name, "utf8");
66
+ const raw = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.data);
67
+ const crc = crc32(raw);
68
+ let method = 0;
69
+ let stored = raw;
70
+ if (file.compress !== false && raw.length > 0) {
71
+ const deflated = zlib.deflateRawSync(raw, { level: 9 });
72
+ if (deflated.length < raw.length) {
73
+ method = 8;
74
+ stored = deflated;
75
+ }
76
+ }
77
+
78
+ const local = Buffer.alloc(30);
79
+ local.writeUInt32LE(0x04034b50, 0);
80
+ local.writeUInt16LE(20, 4); // version needed
81
+ local.writeUInt16LE(0x0800, 6); // UTF-8 flag
82
+ local.writeUInt16LE(method, 8);
83
+ local.writeUInt16LE(DOS_TIME, 10);
84
+ local.writeUInt16LE(DOS_DATE, 12);
85
+ local.writeUInt32LE(crc, 14);
86
+ local.writeUInt32LE(stored.length, 18);
87
+ local.writeUInt32LE(raw.length, 22);
88
+ local.writeUInt16LE(nameBuf.length, 26);
89
+ local.writeUInt16LE(0, 28);
90
+ chunks.push(local, nameBuf, stored);
91
+
92
+ const cd = Buffer.alloc(46);
93
+ cd.writeUInt32LE(0x02014b50, 0);
94
+ cd.writeUInt16LE(20, 4); // version made by
95
+ cd.writeUInt16LE(20, 6); // version needed
96
+ cd.writeUInt16LE(0x0800, 8);
97
+ cd.writeUInt16LE(method, 10);
98
+ cd.writeUInt16LE(DOS_TIME, 12);
99
+ cd.writeUInt16LE(DOS_DATE, 14);
100
+ cd.writeUInt32LE(crc, 16);
101
+ cd.writeUInt32LE(stored.length, 20);
102
+ cd.writeUInt32LE(raw.length, 24);
103
+ cd.writeUInt16LE(nameBuf.length, 28);
104
+ cd.writeUInt16LE(0, 30); // extra
105
+ cd.writeUInt16LE(0, 32); // comment
106
+ cd.writeUInt16LE(0, 34); // disk
107
+ cd.writeUInt16LE(0, 36); // internal attrs
108
+ cd.writeUInt32LE(0, 38); // external attrs
109
+ cd.writeUInt32LE(offset, 42);
110
+ central.push(cd, nameBuf);
111
+
112
+ offset += local.length + nameBuf.length + stored.length;
113
+ }
114
+
115
+ const centralBuf = Buffer.concat(central);
116
+ const eocd = Buffer.alloc(22);
117
+ eocd.writeUInt32LE(0x06054b50, 0);
118
+ eocd.writeUInt16LE(0, 4);
119
+ eocd.writeUInt16LE(0, 6);
120
+ eocd.writeUInt16LE(files.length, 8);
121
+ eocd.writeUInt16LE(files.length, 10);
122
+ eocd.writeUInt32LE(centralBuf.length, 12);
123
+ eocd.writeUInt32LE(offset, 16);
124
+ eocd.writeUInt16LE(0, 20);
125
+
126
+ return Buffer.concat([...chunks, centralBuf, eocd]);
127
+ }
128
+
129
+ /**
130
+ * Read a ZIP archive produced by {@link zipSync} (or any standard 32-bit zip).
131
+ * @param {Buffer|Uint8Array} buf
132
+ * @returns {Map<string, Buffer>}
133
+ */
134
+ export function unzipSync(buf) {
135
+ const data = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
136
+ // Find the End Of Central Directory record.
137
+ let eocd = -1;
138
+ for (let i = data.length - 22; i >= 0; i--) {
139
+ if (data.readUInt32LE(i) === 0x06054b50) {
140
+ eocd = i;
141
+ break;
142
+ }
143
+ }
144
+ if (eocd < 0)
145
+ throw new Error("Not a ZIP archive (no end-of-central-directory record)");
146
+
147
+ const count = data.readUInt16LE(eocd + 10);
148
+ let ptr = data.readUInt32LE(eocd + 16);
149
+ const out = new Map();
150
+ let totalUncompressedSize = 0;
151
+
152
+ for (let i = 0; i < count; i++) {
153
+ if (data.readUInt32LE(ptr) !== 0x02014b50)
154
+ throw new Error("Corrupt ZIP central directory");
155
+ const method = data.readUInt16LE(ptr + 10);
156
+ const compSize = data.readUInt32LE(ptr + 20);
157
+ const uncompSize = data.readUInt32LE(ptr + 24);
158
+ const nameLen = data.readUInt16LE(ptr + 28);
159
+ const extraLen = data.readUInt16LE(ptr + 30);
160
+ const commentLen = data.readUInt16LE(ptr + 32);
161
+ const localOffset = data.readUInt32LE(ptr + 42);
162
+ const name = data.slice(ptr + 46, ptr + 46 + nameLen).toString("utf8");
163
+
164
+ if (uncompSize > MAX_UNCOMPRESSED_SIZE)
165
+ throw new Error(
166
+ `ZIP entry "${name}" exceeds the ${MAX_UNCOMPRESSED_SIZE}-byte size limit`,
167
+ );
168
+ const remainingSize = MAX_ARCHIVE_UNCOMPRESSED_SIZE - totalUncompressedSize;
169
+ if (uncompSize > remainingSize)
170
+ throw new Error(
171
+ `ZIP archive exceeds the ${MAX_ARCHIVE_UNCOMPRESSED_SIZE}-byte size limit`,
172
+ );
173
+ if (method !== 0 && method !== 8)
174
+ throw new Error(`Unsupported ZIP compression method: ${method}`);
175
+
176
+ const localNameLen = data.readUInt16LE(localOffset + 26);
177
+ const localExtraLen = data.readUInt16LE(localOffset + 28);
178
+ const dataStart = localOffset + 30 + localNameLen + localExtraLen;
179
+ const raw = data.slice(dataStart, dataStart + compSize);
180
+ const uncompressed =
181
+ method === 8
182
+ ? zlib.inflateRawSync(raw, {
183
+ // zlib requires a positive limit, even for an empty entry.
184
+ maxOutputLength: Math.max(
185
+ 1,
186
+ Math.min(MAX_UNCOMPRESSED_SIZE, remainingSize),
187
+ ),
188
+ })
189
+ : raw;
190
+ // Account for actual output too, in case a header understates its size.
191
+ totalUncompressedSize += Math.max(uncompSize, uncompressed.length);
192
+ if (totalUncompressedSize > MAX_ARCHIVE_UNCOMPRESSED_SIZE)
193
+ throw new Error(
194
+ `ZIP archive exceeds the ${MAX_ARCHIVE_UNCOMPRESSED_SIZE}-byte size limit`,
195
+ );
196
+ out.set(name, uncompressed);
197
+
198
+ ptr += 46 + nameLen + extraLen + commentLen;
199
+ }
200
+ return out;
201
+ }
202
+
203
+ // -------------------------------------------------------------------- assets
204
+
205
+ const EXT_FORMAT = {
206
+ svg: "svg",
207
+ png: "png",
208
+ jpg: "jpg",
209
+ jpeg: "jpg",
210
+ gif: "gif",
211
+ bmp: "bmp",
212
+ wav: "wav",
213
+ mp3: "mp3",
214
+ ogg: "ogg",
215
+ };
216
+
217
+ /**
218
+ * @param {string} filePath
219
+ * @returns {string} the Scratch dataFormat for a path/URL.
220
+ */
221
+ export function extensionOf(filePath) {
222
+ const clean = String(filePath).split(/[?#]/)[0];
223
+ const match = /\.([a-z0-9]+)$/i.exec(clean);
224
+ return match ? match[1].toLowerCase() : "";
225
+ }
226
+
227
+ /**
228
+ * Read the pixel dimensions of an image buffer (SVG, PNG, JPG or GIF).
229
+ * Falls back to 0x0 when unknown.
230
+ * @param {Buffer} buffer
231
+ * @param {string} format
232
+ * @returns {{width: number, height: number}}
233
+ */
234
+ export function imageSize(buffer, format) {
235
+ try {
236
+ if (
237
+ format === "png" &&
238
+ buffer.length >= 24 &&
239
+ buffer.readUInt32BE(0) === 0x89504e47
240
+ ) {
241
+ return {
242
+ width: buffer.readUInt32BE(16),
243
+ height: buffer.readUInt32BE(20),
244
+ };
245
+ }
246
+ if (format === "gif" && buffer.length >= 10) {
247
+ return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
248
+ }
249
+ if (format === "jpg") {
250
+ let i = 2;
251
+ while (i < buffer.length) {
252
+ if (buffer[i] !== 0xff) {
253
+ i++;
254
+ continue;
255
+ }
256
+ const marker = buffer[i + 1];
257
+ if (
258
+ marker >= 0xc0 &&
259
+ marker <= 0xcf &&
260
+ marker !== 0xc4 &&
261
+ marker !== 0xc8 &&
262
+ marker !== 0xcc
263
+ ) {
264
+ return {
265
+ height: buffer.readUInt16BE(i + 5),
266
+ width: buffer.readUInt16BE(i + 7),
267
+ };
268
+ }
269
+ i += 2 + buffer.readUInt16BE(i + 2);
270
+ }
271
+ }
272
+ if (format === "svg") {
273
+ const text = buffer.toString("utf8");
274
+ const w = /<svg[^>]*\bwidth\s*=\s*["']([\d.]+)([^"']*)["']/i.exec(text);
275
+ const h = /<svg[^>]*\bheight\s*=\s*["']([\d.]+)([^"']*)["']/i.exec(text);
276
+ if (w && h && !w[2].includes("%") && !h[2].includes("%"))
277
+ return { width: Number(w[1]), height: Number(h[1]) };
278
+ const vb = /viewBox\s*=\s*["']([^"']+)["']/i.exec(text);
279
+ if (vb) {
280
+ const nums = vb[1]
281
+ .trim()
282
+ .split(/[\s,]+/)
283
+ .map(Number);
284
+ if (nums.length === 4 && nums.every((n) => Number.isFinite(n))) {
285
+ return { width: nums[2], height: nums[3] };
286
+ }
287
+ }
288
+ }
289
+ } catch {
290
+ /* fall through to zero dimensions */
291
+ }
292
+ return { width: 0, height: 0 };
293
+ }
294
+
295
+ /**
296
+ * Parse a WAV file header to recover rate + sample count.
297
+ * @param {Buffer} buffer
298
+ * @returns {{rate: number, sampleCount: number}}
299
+ */
300
+ export function wavInfo(buffer) {
301
+ try {
302
+ if (
303
+ buffer.length >= 44 &&
304
+ buffer.toString("ascii", 0, 4) === "RIFF" &&
305
+ buffer.toString("ascii", 8, 12) === "WAVE"
306
+ ) {
307
+ let fmt = null;
308
+ // Locate the format and data chunks, skipping unknown chunks and padding.
309
+ let i = 12;
310
+ while (i + 8 <= buffer.length) {
311
+ const id = buffer.toString("ascii", i, i + 4);
312
+ const size = buffer.readUInt32LE(i + 4);
313
+ if (id === "fmt " && size >= 16) {
314
+ fmt = {
315
+ rate: buffer.readUInt32LE(i + 12),
316
+ bits: buffer.readUInt16LE(i + 22),
317
+ channels: buffer.readUInt16LE(i + 10),
318
+ };
319
+ }
320
+ if (id === "data" && fmt) {
321
+ const bytesPerSample = Math.max(1, (fmt.bits / 8) * fmt.channels);
322
+ return {
323
+ rate: fmt.rate,
324
+ sampleCount: Math.floor(size / bytesPerSample),
325
+ };
326
+ }
327
+ i += 8 + size + (size % 2);
328
+ }
329
+ return { rate: fmt?.rate ?? 48000, sampleCount: 0 };
330
+ }
331
+ } catch {
332
+ /* ignore */
333
+ }
334
+ return { rate: 48000, sampleCount: 0 };
335
+ }
336
+
337
+ /** @returns {string} Normalise a costume dataFormat used in project.json. */
338
+ export function detectFormat(filePath) {
339
+ return EXT_FORMAT[extensionOf(filePath)] || extensionOf(filePath) || "png";
340
+ }
package/src/sprite.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Single-sprite archives (`.pms`) and imports (`.sprite3`/`.pms`).
3
+ *
4
+ * A sprite archive is the same ZIP Scratch uses for `.sprite3`: `sprite.json`
5
+ * plus content-addressed `<md5>.<ext>` assets at the archive root. PenguinMod
6
+ * documents the same `sprite.json` target layout for saved sprites.
7
+ */
8
+ import { unzipSync, zipSync } from "./sb3.js";
9
+ import { decompileProject } from "./decompiler.js";
10
+
11
+ const ASSET_NAME = /^[0-9a-f]{32}\.[a-z0-9]+$/i;
12
+
13
+ /** Asset filenames a sprite references through its costumes and sounds. */
14
+ export function spriteAssets(sprite) {
15
+ const names = [];
16
+ for (const asset of [...(sprite.costumes ?? []), ...(sprite.sounds ?? [])]) {
17
+ if (asset && asset.md5ext) names.push(asset.md5ext);
18
+ }
19
+ return [...new Set(names)];
20
+ }
21
+
22
+ /** Decompile one sprite target into standalone `.tfx` source. */
23
+ export function spriteSource(sprite, options) {
24
+ return decompileProject({ targets: [sprite] }, options);
25
+ }
26
+
27
+ /** Pack a sprite target and its assets into a `.pms` archive. */
28
+ export function spriteArchive(sprite, assets) {
29
+ const files = [
30
+ { name: "sprite.json", data: Buffer.from(JSON.stringify(sprite)) },
31
+ ];
32
+ for (const name of spriteAssets(sprite)) {
33
+ if (!ASSET_NAME.test(name))
34
+ throw new Error(`Unsafe asset name ${JSON.stringify(name)}`);
35
+ const data = assets.get(name);
36
+ if (!data)
37
+ throw new Error(
38
+ `Missing asset ${name} for sprite ${JSON.stringify(sprite.name)}`,
39
+ );
40
+ files.push({ name, data });
41
+ }
42
+ return zipSync(files);
43
+ }
44
+
45
+ /**
46
+ * Read a `.sprite3`/`.pms` archive, or bare `sprite.json` with no assets.
47
+ * @param {Buffer|Uint8Array} input
48
+ * @returns {{sprite: object, assets: Map<string, Buffer>}}
49
+ */
50
+ export function readSprite(input) {
51
+ const bytes = Buffer.isBuffer(input) ? input : Buffer.from(input);
52
+ const text = bytes.toString("utf8").trimStart();
53
+ const assets = new Map();
54
+
55
+ let sprite;
56
+ if (text.startsWith("{")) {
57
+ sprite = JSON.parse(text);
58
+ if (spriteAssets(sprite).length > 0)
59
+ throw new Error(
60
+ "That JSON has costumes or sounds but no assets; use the .sprite3/.pms archive",
61
+ );
62
+ } else {
63
+ const files = unzipSync(bytes);
64
+ const json = files.get("sprite.json");
65
+ if (!json) throw new Error("Not a sprite archive: missing sprite.json");
66
+ sprite = JSON.parse(json.toString("utf8"));
67
+ for (const name of spriteAssets(sprite)) {
68
+ if (!ASSET_NAME.test(name))
69
+ throw new Error(`Unsafe asset name ${JSON.stringify(name)}`);
70
+ const data = files.get(name);
71
+ if (!data) throw new Error(`Sprite archive is missing asset ${name}`);
72
+ assets.set(name, data);
73
+ }
74
+ }
75
+
76
+ if (!sprite || typeof sprite !== "object" || Array.isArray(sprite))
77
+ throw new Error("sprite.json must contain a target object");
78
+ if (sprite.isStage) throw new Error("That archive is a stage, not a sprite");
79
+ if (!sprite.name) throw new Error("sprite.json has no name");
80
+ return { sprite, assets };
81
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "@penguinmod/packager",
3
+ "version": "1.9.2",
4
+ "commit": "53f868b85ec21d2edab9ce00fd2d709da93da6d6",
5
+ "builtAt": "2026-11-03T06:22:44.624Z"
6
+ }