@triflare/fx 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +73 -2
  3. package/bin/fx.js +949 -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 +504 -0
  13. package/src/dev.js +390 -0
  14. package/src/errors.js +56 -0
  15. package/src/extensions.js +707 -0
  16. package/src/index.js +31 -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/bin/fx.js ADDED
@@ -0,0 +1,949 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Triflare Fx command line interface.
4
+ *
5
+ * fx build [input.tfx | dir] [-o out.pmp] [--no-config] (alias: b)
6
+ * fx decompile <input.pmp> [-o out.tfx] [--assets <dir>] (alias: d)
7
+ * fx check [input.tfx | dir] (alias: c)
8
+ * fx catalog [dir] [--json] (alias: l)
9
+ * fx init [dir] (alias: i)
10
+ */
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import crypto from "node:crypto";
14
+ import process from "node:process";
15
+ import { build } from "../src/build.js";
16
+ import { parse } from "../src/parser.js";
17
+ import { discoverSources, mergePrograms } from "../src/project.js";
18
+ import { decompile } from "../src/decompiler.js";
19
+ import { unzipSync } from "../src/sb3.js";
20
+ import { CATALOG, blockNames } from "../src/catalog.js";
21
+ import {
22
+ addProjectExtension,
23
+ findConfigPath,
24
+ loadConfigFrom,
25
+ normalizePackageTarget,
26
+ readProjectExtensions,
27
+ removeProjectExtension,
28
+ } from "../src/config.js";
29
+ import { loadExtensions } from "../src/extensions.js";
30
+ import { readSprite, spriteArchive, spriteSource } from "../src/sprite.js";
31
+ import { packageProject } from "../src/packager.js";
32
+ import {
33
+ extractArchive,
34
+ packageOutputPath,
35
+ packageSignature,
36
+ resolvePackageConfig,
37
+ slugify,
38
+ } from "../src/package.js";
39
+ import { startDevServer } from "../src/dev.js";
40
+ import {
41
+ brandText,
42
+ configFilename,
43
+ outputDirectory,
44
+ sourceDirectory,
45
+ sourceExtension,
46
+ } from "../src/brand.js";
47
+ import { createLogger } from "../src/log.js";
48
+ import { loadProduct } from "../src/product.js";
49
+
50
+ const product = loadProduct();
51
+ const log = createLogger(product);
52
+ const EXTENSION = sourceExtension(product);
53
+
54
+ function parseArgs(argv) {
55
+ const args = { _: [], flags: {} };
56
+ const booleanFlags = new Set([
57
+ "no-config",
58
+ "json",
59
+ "help",
60
+ "h",
61
+ "version",
62
+ "v",
63
+ "force",
64
+ "extract",
65
+ "lan",
66
+ "strict-port",
67
+ ]);
68
+ for (let i = 0; i < argv.length; i++) {
69
+ const token = argv[i];
70
+ if (token.startsWith("--")) {
71
+ const [key, inline] = token.slice(2).split("=");
72
+ if (inline !== undefined) args.flags[key] = inline;
73
+ else if (booleanFlags.has(key)) args.flags[key] = true;
74
+ else if (argv[i + 1] && !argv[i + 1].startsWith("-"))
75
+ args.flags[key] = argv[++i];
76
+ else args.flags[key] = true;
77
+ } else if (token.startsWith("-")) {
78
+ const key = token.slice(1);
79
+ if (booleanFlags.has(key)) args.flags[key] = true;
80
+ else if (argv[i + 1] && !argv[i + 1].startsWith("-"))
81
+ args.flags[key] = argv[++i];
82
+ else args.flags[key] = true;
83
+ } else {
84
+ args._.push(token);
85
+ }
86
+ }
87
+ return args;
88
+ }
89
+
90
+ function usage(stream = process.stdout) {
91
+ const command = brandText(product.command, stream);
92
+ const name = brandText(product.name, stream);
93
+ return `${name} ${product.version} — ${product.tagline}
94
+
95
+ Usage:
96
+ ${command} build [<input${EXTENSION}>|<dir>] [-o <output.pmp>] [--no-config]
97
+ ${command} package [<input${EXTENSION}>|<input.pmp>|<dir>] [-o <output>] [--target <target>] [--extract]
98
+ ${command} dev [<input${EXTENSION}>|<dir>] [--host [host]] [--lan] [--port <port>] [--strict-port]
99
+ ${command} decompile <input.pmp> [-o <output${EXTENSION}>] [--assets <dir>]
100
+ ${command} check [<input${EXTENSION}>|<dir>]
101
+ ${command} catalog [<dir>] [--json]
102
+ ${command} init [dir]
103
+
104
+ Commands:
105
+ build (b) Compile a source file, or every ${EXTENSION} file in a directory
106
+ (defaults to the working directory) into one .pmp.
107
+ package (p) Package a source, project archive or directory into HTML or a ZIP
108
+ (Windows/macOS/Linux desktop builds use Electron).
109
+ dev Serve the packaged HTML and reload when the output changes.
110
+ decompile (d) Convert a .pmp back into ${product.name} source.
111
+ check (c) Parse a file (or every ${EXTENSION} file in a directory) for errors.
112
+ catalog (l) List ${product.name} blocks, including those from ${configFilename(product)}
113
+ extensions (paths or URLs).
114
+ init (i) Scaffold a new project.
115
+
116
+ Command groups:
117
+ extension add <source> [--unsandboxed <id>]
118
+ Add an extension (path or URL) to ${configFilename(product)}.
119
+ extension remove <source|id>
120
+ Remove an extension from ${configFilename(product)}.
121
+ extension list [--json]
122
+ List configured extensions.
123
+ sprite add <url|file> [--name <name>] [--force]
124
+ Import a .sprite3/.pms sprite as .tfx source.
125
+ sprite build <name> [-o <output.pms>]
126
+ Compile one sprite into a .pms archive.
127
+ sprite remove <name>
128
+ Delete a source file that declares only that sprite.
129
+ sprite list [<dir>] [--json]
130
+ List declared sprites.
131
+
132
+ Options:
133
+ -o, --output Output file path
134
+ --no-config Ignore ${configFilename(product)}
135
+ --assets <dir> Extract project assets into <dir> when decompiling
136
+ --target <t> Packaging target (html, zip, windows, macos, linux, …)
137
+ --extract Unzip a packaged desktop/zip archive
138
+ --host [host] Dev server host; with no value listen on all interfaces
139
+ --lan Alias for listening on all interfaces
140
+ --port <port> Dev server port (default 3000)
141
+ --strict-port Fail instead of picking the next free dev port
142
+ --json Machine readable output (catalog)
143
+ -v, --version Print the version
144
+ `;
145
+ }
146
+
147
+ /** Long command name -> one-letter alias. */
148
+ const COMMAND_ALIASES = {
149
+ b: "build",
150
+ p: "package",
151
+ d: "decompile",
152
+ c: "check",
153
+ l: "catalog",
154
+ i: "init",
155
+ };
156
+
157
+ function fail(message) {
158
+ log.error(message);
159
+ process.exit(1);
160
+ }
161
+
162
+ async function loadProjectExtensions(dir, config, { localFileWarning } = {}) {
163
+ const catalog = {};
164
+ const extensionUrls = {};
165
+ const unsandboxed = [];
166
+ if (!config.extensions || config.extensions.length === 0)
167
+ return { catalog, extensionUrls, unsandboxed };
168
+ const loaded = await loadExtensions(config.extensions, {
169
+ baseDir: dir,
170
+ expectUnsandboxed: config.unsandboxed,
171
+ localFileWarning,
172
+ });
173
+ Object.assign(catalog, loaded.catalog);
174
+ Object.assign(extensionUrls, loaded.extensionUrls);
175
+ unsandboxed.push(...loaded.unsandboxed);
176
+ for (const warning of loaded.warnings)
177
+ process.stderr.write(`warning: ${warning}\n`);
178
+ return { catalog, extensionUrls, unsandboxed };
179
+ }
180
+
181
+ /** Split a positional build/check argument into `{file}` or `{dir}`. */
182
+ function resolveInput(input) {
183
+ if (!input) return { dir: process.cwd() };
184
+ const resolved = path.resolve(input);
185
+ let stat;
186
+ try {
187
+ stat = fs.statSync(resolved);
188
+ } catch {
189
+ fail(`input not found: ${input}`);
190
+ }
191
+ return stat.isDirectory() ? { dir: resolved } : { file: resolved };
192
+ }
193
+
194
+ async function cmdBuild(args) {
195
+ const target = resolveInput(args._[0]);
196
+ const searchDir = target.file ? path.dirname(target.file) : target.dir;
197
+
198
+ const loaded = args.flags["no-config"]
199
+ ? { project: null, fx: null, path: null }
200
+ : loadConfigFrom(searchDir);
201
+ const config = loaded.project;
202
+ const extBase = loaded.path ? path.dirname(loaded.path) : searchDir;
203
+ const { catalog, extensionUrls, unsandboxed } = await loadProjectExtensions(
204
+ extBase,
205
+ config || {},
206
+ );
207
+
208
+ const result = build({
209
+ ...target,
210
+ projectConfig: config,
211
+ fxConfig: loaded.fx,
212
+ catalog,
213
+ extensionUrls,
214
+ useConfigFile: !args.flags["no-config"],
215
+ });
216
+
217
+ const explicit = args.flags.o || args.flags.output;
218
+ const output =
219
+ explicit ||
220
+ (target.file
221
+ ? target.file.toLowerCase().endsWith(EXTENSION.toLowerCase())
222
+ ? `${target.file.slice(0, -EXTENSION.length)}.pmp`
223
+ : `${target.file}.pmp`
224
+ : path.join(
225
+ target.dir,
226
+ outputDirectory(product),
227
+ `${slugify(result.config.name || path.basename(target.dir))}.pmp`,
228
+ ));
229
+
230
+ fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
231
+ fs.writeFileSync(output, result.sb3);
232
+
233
+ const blockCount = result.project.targets.reduce(
234
+ (sum, t) => sum + Object.keys(t.blocks).length,
235
+ 0,
236
+ );
237
+ log.success(`Built ${output}`);
238
+ process.stdout.write(
239
+ ` sources: ${result.files.length} file(s)\n` +
240
+ ` targets: ${result.project.targets.length}\n` +
241
+ ` blocks: ${blockCount}\n` +
242
+ ` assets: ${result.assets.size}\n` +
243
+ ` extensions: ${result.project.extensions.join(", ") || "(none)"}\n` +
244
+ (unsandboxed.length
245
+ ? ` unsandboxed: ${unsandboxed.join(", ")} ` +
246
+ "(must be allowed to run unsandboxed in the editor)\n"
247
+ : ""),
248
+ );
249
+ }
250
+
251
+ function formatBytes(bytes) {
252
+ if (bytes < 1024) return `${bytes} B`;
253
+ const units = ["KB", "MB", "GB"];
254
+ let value = bytes / 1024;
255
+ let unit = units[0];
256
+ for (let i = 1; value >= 1024 && i < units.length; i += 1) {
257
+ value /= 1024;
258
+ unit = units[i];
259
+ }
260
+ return `${value.toFixed(value >= 10 ? 1 : 2)} ${unit}`;
261
+ }
262
+
263
+ /** Read a `.pmp` directly, or build `.tfx`/directory sources into one. */
264
+ async function readPackagingSource(target, loaded) {
265
+ const searchDir = target.file ? path.dirname(target.file) : target.dir;
266
+ if (target.file && path.extname(target.file).toLowerCase() === ".pmp") {
267
+ return {
268
+ sb3: fs.readFileSync(target.file),
269
+ projectName: path.basename(target.file, path.extname(target.file)),
270
+ searchDir,
271
+ };
272
+ }
273
+
274
+ const extBase = loaded.path ? path.dirname(loaded.path) : searchDir;
275
+ const { catalog, extensionUrls } = await loadProjectExtensions(
276
+ extBase,
277
+ loaded.project || {},
278
+ );
279
+ const result = build({
280
+ ...target,
281
+ projectConfig: loaded.project,
282
+ fxConfig: loaded.fx,
283
+ catalog,
284
+ extensionUrls,
285
+ useConfigFile: false,
286
+ });
287
+ return {
288
+ sb3: result.sb3,
289
+ projectName: result.config.name,
290
+ searchDir,
291
+ };
292
+ }
293
+
294
+ async function cmdPackage(args) {
295
+ const target = resolveInput(args._[0]);
296
+ const loaded = args.flags["no-config"]
297
+ ? { project: null, fx: null, package: null, path: null }
298
+ : loadConfigFrom(target.file ? path.dirname(target.file) : target.dir);
299
+ const source = await readPackagingSource(target, loaded);
300
+ const baseDir = loaded.path ? path.dirname(loaded.path) : source.searchDir;
301
+ const pkg = resolvePackageConfig(loaded.package, {
302
+ baseDir,
303
+ projectName: source.projectName,
304
+ });
305
+
306
+ if (args.flags.target !== undefined) {
307
+ if (typeof args.flags.target !== "string") fail("--target needs a value");
308
+ pkg.target = normalizePackageTarget(args.flags.target);
309
+ }
310
+ const explicit = args.flags.o || args.flags.output;
311
+ if (explicit !== undefined) {
312
+ if (typeof explicit !== "string") fail("-o/--output needs a value");
313
+ pkg.output = path.resolve(explicit);
314
+ }
315
+ const extract = Boolean(args.flags.extract) || pkg.extract;
316
+ if (extract && pkg.target === "html")
317
+ fail("--extract only applies to zip and desktop targets");
318
+
319
+ const output = packageOutputPath({
320
+ target: pkg.target,
321
+ output: pkg.output,
322
+ name: pkg.name || source.projectName,
323
+ dir: baseDir,
324
+ });
325
+ const result = await packageProject({
326
+ sb3: source.sb3,
327
+ projectName: source.projectName,
328
+ packageConfig: pkg,
329
+ });
330
+
331
+ fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
332
+ fs.writeFileSync(output, result.data);
333
+ log.success(`Packaged ${output}`);
334
+ process.stdout.write(
335
+ ` target: ${result.target}\n` +
336
+ ` type: ${result.type}\n` +
337
+ ` size: ${formatBytes(result.data.length)}\n`,
338
+ );
339
+
340
+ if (extract) {
341
+ const extension = path.extname(output);
342
+ const extractDir = path.join(
343
+ path.dirname(output),
344
+ path.basename(output, extension),
345
+ );
346
+ const files = await extractArchive(result.data, extractDir);
347
+ log.success(`Extracted ${files.length} file(s) to ${extractDir}`);
348
+ }
349
+ }
350
+
351
+ async function cmdDev(args) {
352
+ const target = resolveInput(args._[0]);
353
+ if (target.file && path.extname(target.file).toLowerCase() === ".pmp")
354
+ fail("fx dev needs .tfx sources or a directory, not a .pmp archive");
355
+
356
+ const searchDir = target.file ? path.dirname(target.file) : target.dir;
357
+ const useConfigFile = !args.flags["no-config"];
358
+ const configPath = useConfigFile ? findConfigPath(searchDir) : null;
359
+ const baseDir = configPath ? path.dirname(configPath) : searchDir;
360
+
361
+ let host = "127.0.0.1";
362
+ if (args.flags.lan) host = "0.0.0.0";
363
+ else if (args.flags.host === true) host = "0.0.0.0";
364
+ else if (typeof args.flags.host === "string") host = args.flags.host;
365
+
366
+ if (args.flags.port === true) fail("--port needs a number");
367
+ const port = args.flags.port === undefined ? 3000 : Number(args.flags.port);
368
+
369
+ let lastSb3Hash = null;
370
+ let lastSignature = null;
371
+ let lastHtml = null;
372
+
373
+ const buildForDev = async () => {
374
+ const loaded = useConfigFile
375
+ ? loadConfigFrom(searchDir)
376
+ : { project: null, fx: null, package: null, path: null };
377
+ const configBase = loaded.path ? path.dirname(loaded.path) : searchDir;
378
+ const { catalog, extensionUrls } = await loadProjectExtensions(
379
+ configBase,
380
+ loaded.project || {},
381
+ );
382
+ const result = build({
383
+ ...target,
384
+ projectConfig: loaded.project,
385
+ fxConfig: loaded.fx,
386
+ catalog,
387
+ extensionUrls,
388
+ useConfigFile: false,
389
+ });
390
+ const pkg = resolvePackageConfig(loaded.package, {
391
+ baseDir: configBase,
392
+ projectName: result.config.name,
393
+ });
394
+ // Dev always serves HTML, no matter what the package block requests.
395
+ pkg.target = "html";
396
+
397
+ const signature = packageSignature(pkg);
398
+ const sb3Hash = crypto
399
+ .createHash("sha256")
400
+ .update(result.sb3)
401
+ .digest("hex");
402
+ if (
403
+ lastHtml != null &&
404
+ sb3Hash === lastSb3Hash &&
405
+ signature === lastSignature
406
+ )
407
+ return lastHtml;
408
+
409
+ const packaged = await packageProject({
410
+ sb3: result.sb3,
411
+ projectName: result.config.name,
412
+ packageConfig: pkg,
413
+ });
414
+ lastSb3Hash = sb3Hash;
415
+ lastSignature = signature;
416
+ lastHtml = packaged.html ?? packaged.data.toString("utf8");
417
+ return lastHtml;
418
+ };
419
+
420
+ const server = await startDevServer({
421
+ watchDir: baseDir,
422
+ build: buildForDev,
423
+ host,
424
+ port,
425
+ strictPort: Boolean(args.flags["strict-port"]),
426
+ log,
427
+ });
428
+
429
+ process.stdout.write(`\n ${brandText(product.name)} dev\n`);
430
+ for (const url of server.localUrls) log.bullet(`Local: ${url}`);
431
+ for (const url of server.networkUrls) log.bullet(`Network: ${url}`);
432
+ log.info(" Press Ctrl+C to stop.");
433
+
434
+ let stopping = false;
435
+ const shutdown = () => {
436
+ if (stopping) return;
437
+ stopping = true;
438
+ server.close().finally(() => process.exit(0));
439
+ };
440
+ process.once("SIGINT", shutdown);
441
+ process.once("SIGTERM", shutdown);
442
+
443
+ await server.ready;
444
+ }
445
+
446
+ function cmdCheck(args) {
447
+ const target = resolveInput(args._[0]);
448
+ const files = target.file ? [target.file] : discoverSources(target.dir);
449
+ if (files.length === 0)
450
+ fail(`No ${EXTENSION} files found under ${target.dir}`);
451
+
452
+ const searchDir = target.file ? path.dirname(target.file) : target.dir;
453
+ const loaded = args.flags["no-config"]
454
+ ? { fx: null }
455
+ : loadConfigFrom(searchDir);
456
+ const entries = files.map((file) => ({
457
+ ast: parse(fs.readFileSync(file, "utf8"), file),
458
+ file,
459
+ }));
460
+ mergePrograms(entries, {
461
+ partialSprites: loaded.fx?.partialSprites ?? false,
462
+ });
463
+ log.success(`Checked ${files.length} file(s): OK`);
464
+ }
465
+
466
+ async function cmdCatalog(args) {
467
+ // Merge the built-in catalog with any extensions declared in fx.yml, so
468
+ // blocks from path- or URL-loaded extensions show up here too.
469
+ const loaded = args.flags["no-config"]
470
+ ? { project: null, path: null }
471
+ : loadConfigFrom(args._[0] || process.cwd());
472
+ const config = loaded.project;
473
+ const extBase = loaded.path ? path.dirname(loaded.path) : process.cwd();
474
+ const { catalog } = await loadProjectExtensions(extBase, config || {}, {
475
+ localFileWarning: false,
476
+ });
477
+
478
+ const merged = { ...CATALOG, ...catalog };
479
+ const names = Object.keys(merged).sort();
480
+
481
+ if (args.flags.json) {
482
+ process.stdout.write(`${JSON.stringify(merged, null, 2)}\n`);
483
+ return;
484
+ }
485
+ const builtIn = blockNames().length;
486
+ process.stdout.write(
487
+ `${product.name} catalog (${names.length} blocks` +
488
+ (names.length > builtIn
489
+ ? `, ${names.length - builtIn} from extensions`
490
+ : "") +
491
+ `):\n`,
492
+ );
493
+ for (const name of names) {
494
+ const entry = merged[name];
495
+ const tag = entry.extension ? ` (ext ${entry.extension})` : "";
496
+ process.stdout.write(
497
+ ` ${name.padEnd(32)} -> ${entry.opcode} [${entry.kind}]${tag}\n`,
498
+ );
499
+ }
500
+ }
501
+
502
+ function cmdDecompile(args) {
503
+ const input = args._[0];
504
+ if (!input)
505
+ fail(`decompile requires an input .pmp file\n\n${usage(process.stderr)}`);
506
+ const data = fs.readFileSync(input);
507
+ const files = unzipSync(data);
508
+
509
+ const assetDir = args.flags.assets;
510
+ let assetPrefix = "";
511
+ if (assetDir) {
512
+ fs.mkdirSync(assetDir, { recursive: true });
513
+ const resolvedAssetDir = fs.realpathSync(assetDir);
514
+ for (const [name, buffer] of files) {
515
+ if (!/^[0-9a-f]{32}\.[a-z0-9]+(?![\s\S])/i.test(name)) continue;
516
+ const destination = path.resolve(resolvedAssetDir, name);
517
+ if (path.dirname(destination) !== resolvedAssetDir) continue;
518
+ // Existing symlinks could redirect an otherwise safe name outside the directory.
519
+ if (
520
+ fs.lstatSync(destination, { throwIfNoEntry: false })?.isSymbolicLink()
521
+ )
522
+ continue;
523
+ fs.writeFileSync(destination, buffer);
524
+ }
525
+ // Reference assets relative to the directory the source file will live in.
526
+ const outDir = path.dirname(
527
+ path.resolve(
528
+ args.flags.o ||
529
+ args.flags.output ||
530
+ path.join(process.cwd(), `out${EXTENSION}`),
531
+ ),
532
+ );
533
+ const rel = path
534
+ .relative(outDir, path.resolve(assetDir))
535
+ .replace(/\\/g, "/");
536
+ assetPrefix = rel ? `${rel}/` : "";
537
+ }
538
+
539
+ const source = decompile(data, { assetPrefix });
540
+ const output = args.flags.o || args.flags.output;
541
+ if (output) {
542
+ fs.writeFileSync(output, source);
543
+ log.success(`Decompiled ${input} -> ${output}`);
544
+ } else {
545
+ process.stdout.write(source);
546
+ }
547
+ }
548
+
549
+ function initFiles() {
550
+ return {
551
+ [configFilename(product)]: `project:
552
+ name: "My ${product.name} Project"
553
+ fps: 60
554
+ width: 480
555
+ height: 360
556
+ turbo: false
557
+ interpolation: true
558
+ fx:
559
+ partialSprites: false
560
+ package:
561
+ target: html
562
+ `,
563
+ [`${sourceDirectory(product)}/main${EXTENSION}`]: `sprite "Cat" {
564
+ costumes: {
565
+ cat: "../assets/cat.svg"
566
+ };
567
+
568
+ let score = 0;
569
+
570
+ when greenFlag {
571
+ motion.goToXY(0, 0);
572
+ control.forever {
573
+ motion.turnRight(1);
574
+ }
575
+ }
576
+
577
+ when keyPressed("space") {
578
+ score += 1;
579
+ looks.sayForSecs(operators.join("Score: ", score), 1);
580
+ }
581
+ }
582
+ `,
583
+ "assets/cat.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="95" height="101" viewBox="0 0 95 101">
584
+ <ellipse cx="47" cy="60" rx="35" ry="35" fill="#ffab19" stroke="#cf8b17" stroke-width="4"/>
585
+ <circle cx="35" cy="50" r="6" fill="#000"/>
586
+ <circle cx="60" cy="50" r="6" fill="#000"/>
587
+ </svg>
588
+ `,
589
+ };
590
+ }
591
+
592
+ function cmdInit(args) {
593
+ const dir = args._[0] || ".";
594
+ for (const [rel, content] of Object.entries(initFiles())) {
595
+ const dest = path.join(dir, rel);
596
+ if (fs.existsSync(dest)) {
597
+ log.warn(`skip ${dest} (exists)`);
598
+ continue;
599
+ }
600
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
601
+ fs.writeFileSync(dest, content);
602
+ log.bullet(dest);
603
+ }
604
+ }
605
+
606
+ function requireConfigPath() {
607
+ const found = findConfigPath(process.cwd());
608
+ if (!found)
609
+ fail(
610
+ `No ${configFilename(product)} found. Run "${product.command} init" first.`,
611
+ );
612
+ return found;
613
+ }
614
+
615
+ async function readInputBytes(source) {
616
+ if (/^https?:\/\//i.test(source)) {
617
+ const response = await fetch(source, {
618
+ signal: AbortSignal.timeout(15000),
619
+ });
620
+ if (!response.ok)
621
+ throw new Error(
622
+ `Could not download ${source}: ${response.status} ${response.statusText}`,
623
+ );
624
+ return Buffer.from(await response.arrayBuffer());
625
+ }
626
+ return fs.readFileSync(source);
627
+ }
628
+
629
+ /** Every sprite declaration under `dir`, in source order. */
630
+ function spriteNames(dir) {
631
+ const sprites = [];
632
+ for (const file of discoverSources(dir)) {
633
+ const ast = parse(fs.readFileSync(file, "utf8"), file);
634
+ for (const sprite of ast.sprites) {
635
+ sprites.push({ name: sprite.name, file, partial: sprite.partial });
636
+ }
637
+ }
638
+ return sprites;
639
+ }
640
+
641
+ /** Group sprite declarations by name, tracking the base declaration. */
642
+ function spriteGroups(dir) {
643
+ const groups = new Map();
644
+ for (const entry of spriteNames(dir)) {
645
+ let group = groups.get(entry.name);
646
+ if (!group) {
647
+ group = {
648
+ name: entry.name,
649
+ file: entry.file,
650
+ files: [],
651
+ partial: true,
652
+ };
653
+ groups.set(entry.name, group);
654
+ }
655
+ group.files.push(entry.file);
656
+ if (!entry.partial) {
657
+ group.file = entry.file;
658
+ group.partial = false;
659
+ }
660
+ }
661
+ for (const group of groups.values()) {
662
+ // Show the base file first, then fragments in discovery order.
663
+ group.files = [...new Set([group.file, ...group.files])];
664
+ }
665
+ return [...groups.values()];
666
+ }
667
+
668
+ function cmdExtensionAdd(args) {
669
+ const source = args._[1];
670
+ if (!source) fail("extension add requires a path or URL");
671
+ const configPath = requireConfigPath();
672
+ let unsandboxed = null;
673
+ if (args.flags.unsandboxed !== undefined) {
674
+ if (typeof args.flags.unsandboxed !== "string")
675
+ fail("--unsandboxed needs an extension id");
676
+ unsandboxed = args.flags.unsandboxed;
677
+ }
678
+
679
+ const { added, unsandboxedAdded } = addProjectExtension(configPath, source, {
680
+ unsandboxed,
681
+ });
682
+ if (added) log.success(`Added ${source} to ${path.basename(configPath)}`);
683
+ else log.info(`${source} is already listed in ${path.basename(configPath)}`);
684
+ if (unsandboxed && unsandboxedAdded)
685
+ log.success(`Allowed ${unsandboxed} to run unsandboxed`);
686
+ }
687
+
688
+ function cmdExtensionRemove(args) {
689
+ const target = args._[1];
690
+ if (!target) fail("extension remove requires a path, URL or extension id");
691
+ const configPath = requireConfigPath();
692
+ const result = removeProjectExtension(configPath, target);
693
+ if (result.extensions.length > 0)
694
+ log.success(
695
+ `Removed ${result.extensions.join(", ")} from ${path.basename(configPath)}`,
696
+ );
697
+ if (result.unsandboxedRemoved)
698
+ log.success(`Removed ${target} from the unsandboxed list`);
699
+ }
700
+
701
+ function cmdExtensionList(args) {
702
+ const configPath = requireConfigPath();
703
+ const { extensions, unsandboxed } = readProjectExtensions(configPath);
704
+ if (args.flags.json) {
705
+ process.stdout.write(
706
+ `${JSON.stringify({ extensions, unsandboxed }, null, 2)}\n`,
707
+ );
708
+ return;
709
+ }
710
+ process.stdout.write(
711
+ `${path.basename(configPath)}: ${extensions.length} extension(s)\n`,
712
+ );
713
+ for (const entry of extensions) process.stdout.write(` ${entry}\n`);
714
+ process.stdout.write(`unsandboxed: ${unsandboxed.join(", ") || "(none)"}\n`);
715
+ }
716
+
717
+ function cmdExtension(args) {
718
+ switch (args._[0]) {
719
+ case "add":
720
+ return cmdExtensionAdd(args);
721
+ case "remove":
722
+ return cmdExtensionRemove(args);
723
+ case "list":
724
+ return cmdExtensionList(args);
725
+ default:
726
+ fail(`extension expects add, remove or list\n\n${usage(process.stderr)}`);
727
+ }
728
+ }
729
+
730
+ async function cmdSpriteAdd(args) {
731
+ const source = args._[1];
732
+ if (!source) fail("sprite add requires a .sprite3/.pms file or URL");
733
+ const configPath = findConfigPath(process.cwd());
734
+ const rootDir = configPath ? path.dirname(configPath) : process.cwd();
735
+
736
+ const { sprite, assets } = readSprite(await readInputBytes(source));
737
+ const name =
738
+ typeof args.flags.name === "string" ? args.flags.name : sprite.name;
739
+ if (!name) fail("sprite add could not read a sprite name; pass --name");
740
+ sprite.name = name;
741
+
742
+ const existing = spriteNames(rootDir).find((entry) => entry.name === name);
743
+ if (existing)
744
+ fail(
745
+ `A sprite named ${JSON.stringify(name)} already exists in ${existing.file}`,
746
+ );
747
+
748
+ const sourceDir = path.join(rootDir, sourceDirectory(product));
749
+ const file = path.join(sourceDir, `${slugify(name)}${EXTENSION}`);
750
+ if (fs.existsSync(file) && !args.flags.force)
751
+ fail(`${file} already exists; pass --force to overwrite`);
752
+
753
+ const assetsDir = path.join(rootDir, "assets");
754
+ const relative = path.relative(sourceDir, assetsDir).replace(/\\/g, "/");
755
+ const text = spriteSource(sprite, {
756
+ assetPrefix: relative ? `${relative}/` : "",
757
+ });
758
+ parse(text, file);
759
+
760
+ fs.mkdirSync(assetsDir, { recursive: true });
761
+ for (const [assetName, data] of assets)
762
+ fs.writeFileSync(path.join(assetsDir, assetName), data);
763
+ fs.mkdirSync(sourceDir, { recursive: true });
764
+ fs.writeFileSync(file, text);
765
+ log.success(`Added sprite ${name} -> ${path.relative(rootDir, file)}`);
766
+ }
767
+
768
+ async function cmdSpriteBuild(args) {
769
+ const name = args._[1];
770
+ if (!name) fail("sprite build requires a sprite name");
771
+ const dir = process.cwd();
772
+ const loaded = args.flags["no-config"]
773
+ ? { project: null, fx: null, path: null }
774
+ : loadConfigFrom(dir);
775
+ const config = loaded.project;
776
+ const extBase = loaded.path ? path.dirname(loaded.path) : dir;
777
+ const { catalog, extensionUrls } = await loadProjectExtensions(
778
+ extBase,
779
+ config || {},
780
+ );
781
+ const result = build({
782
+ dir,
783
+ projectConfig: config,
784
+ fxConfig: loaded.fx,
785
+ catalog,
786
+ extensionUrls,
787
+ useConfigFile: !args.flags["no-config"],
788
+ });
789
+
790
+ const sprite = result.project.targets.find(
791
+ (target) => !target.isStage && target.name === name,
792
+ );
793
+ if (!sprite) {
794
+ const available = result.project.targets
795
+ .filter((target) => !target.isStage)
796
+ .map((target) => target.name);
797
+ fail(
798
+ `No sprite named ${JSON.stringify(name)}. ` +
799
+ (available.length
800
+ ? `Available: ${available.join(", ")}`
801
+ : "The project has no sprites."),
802
+ );
803
+ }
804
+
805
+ const explicit = args.flags.o || args.flags.output;
806
+ const output =
807
+ explicit ||
808
+ path.join(dir, outputDirectory(product), `${slugify(name)}.pms`);
809
+ fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
810
+ fs.writeFileSync(output, spriteArchive(sprite, result.assets));
811
+ log.success(`Built sprite ${name} -> ${output}`);
812
+ }
813
+
814
+ function cmdSpriteRemove(args) {
815
+ const name = args._[1];
816
+ if (!name) fail("sprite remove requires a sprite name");
817
+ const dir = process.cwd();
818
+ const matches = spriteNames(dir).filter((entry) => entry.name === name);
819
+ if (matches.length === 0)
820
+ fail(`No sprite named ${JSON.stringify(name)} under ${dir}`);
821
+
822
+ const files = [...new Set(matches.map((entry) => entry.file))];
823
+ for (const file of files) {
824
+ const ast = parse(fs.readFileSync(file, "utf8"), file);
825
+ const onlyThisSprite =
826
+ ast.sprites.length > 0 &&
827
+ ast.sprites.every((sprite) => sprite.name === name) &&
828
+ ast.topItems.length === 0 &&
829
+ !ast.stage;
830
+ if (!onlyThisSprite)
831
+ fail(`${file} also declares other code; remove the sprite block by hand`);
832
+ }
833
+
834
+ for (const file of files) fs.rmSync(file);
835
+ log.success(
836
+ `Removed sprite ${name} (${files
837
+ .map((file) => path.relative(dir, file) || file)
838
+ .join(", ")})`,
839
+ );
840
+ }
841
+
842
+ function cmdSpriteList(args) {
843
+ const dir = path.resolve(args._[1] || process.cwd());
844
+ const sprites = spriteGroups(dir);
845
+ if (args.flags.json) {
846
+ process.stdout.write(
847
+ `${JSON.stringify(
848
+ sprites.map(({ name, file, files, partial }) => ({
849
+ name,
850
+ file: path.relative(dir, file),
851
+ files: files.map((entry) => path.relative(dir, entry)),
852
+ partial,
853
+ })),
854
+ null,
855
+ 2,
856
+ )}\n`,
857
+ );
858
+ return;
859
+ }
860
+ if (sprites.length === 0) {
861
+ log.info(`No sprites under ${dir}`);
862
+ return;
863
+ }
864
+ for (const { name, files } of sprites)
865
+ process.stdout.write(
866
+ ` ${name.padEnd(24)} ${files
867
+ .map((file) => path.relative(dir, file) || file)
868
+ .join(", ")}\n`,
869
+ );
870
+ }
871
+
872
+ async function cmdSprite(args) {
873
+ switch (args._[0]) {
874
+ case "add":
875
+ return cmdSpriteAdd(args);
876
+ case "build":
877
+ return cmdSpriteBuild(args);
878
+ case "remove":
879
+ return cmdSpriteRemove(args);
880
+ case "list":
881
+ return cmdSpriteList(args);
882
+ default:
883
+ fail(
884
+ `sprite expects add, build, remove or list\n\n${usage(process.stderr)}`,
885
+ );
886
+ }
887
+ }
888
+
889
+ function printVersion() {
890
+ process.stdout.write(`${brandText(product.name)} ${product.version}\n`);
891
+ }
892
+
893
+ async function main() {
894
+ const argv = process.argv.slice(2);
895
+ const rawCommand = argv.shift();
896
+ const command = COMMAND_ALIASES[rawCommand] || rawCommand;
897
+ const args = parseArgs(argv);
898
+
899
+ if (args.flags.version || args.flags.v) {
900
+ printVersion();
901
+ return;
902
+ }
903
+
904
+ switch (command) {
905
+ case "build":
906
+ return cmdBuild(args);
907
+ case "package":
908
+ return cmdPackage(args);
909
+ case "dev":
910
+ return cmdDev(args);
911
+ case "decompile":
912
+ return cmdDecompile(args);
913
+ case "check":
914
+ return cmdCheck(args);
915
+ case "catalog":
916
+ return cmdCatalog(args);
917
+ case "init":
918
+ return cmdInit(args);
919
+ case "extension":
920
+ return cmdExtension(args);
921
+ case "sprite":
922
+ return cmdSprite(args);
923
+ case "--version":
924
+ case "-v":
925
+ printVersion();
926
+ return;
927
+ case undefined:
928
+ case "help":
929
+ case "--help":
930
+ case "-h":
931
+ process.stdout.write(usage());
932
+ return;
933
+ default:
934
+ log.error(
935
+ `We don't recognize that command: ${JSON.stringify(command)}. ` +
936
+ `Try '${product.command} help'.`,
937
+ );
938
+ process.stdout.write(usage());
939
+ process.exitCode = 1;
940
+ return;
941
+ }
942
+ }
943
+
944
+ main().catch((err) => {
945
+ if (err && typeof err.format === "function")
946
+ process.stderr.write(`${err.format()}\n`);
947
+ else process.stderr.write(`error: ${err && err.stack ? err.stack : err}\n`);
948
+ process.exit(1);
949
+ });