@triflare/fx 1.0.0 → 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.
package/bin/fx.js CHANGED
@@ -3,7 +3,8 @@
3
3
  * Triflare Fx command line interface.
4
4
  *
5
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)
6
+ * fx decompile <input.pmp> [-o out.tfx] [--assets <dir>] [--fetch-extensions]
7
+ * (alias: d)
7
8
  * fx check [input.tfx | dir] (alias: c)
8
9
  * fx catalog [dir] [--json] (alias: l)
9
10
  * fx init [dir] (alias: i)
@@ -15,7 +16,7 @@ import process from "node:process";
15
16
  import { build } from "../src/build.js";
16
17
  import { parse } from "../src/parser.js";
17
18
  import { discoverSources, mergePrograms } from "../src/project.js";
18
- import { decompile } from "../src/decompiler.js";
19
+ import { decompileProject } from "../src/decompiler.js";
19
20
  import { unzipSync } from "../src/sb3.js";
20
21
  import { CATALOG, blockNames } from "../src/catalog.js";
21
22
  import {
@@ -26,7 +27,7 @@ import {
26
27
  readProjectExtensions,
27
28
  removeProjectExtension,
28
29
  } from "../src/config.js";
29
- import { loadExtensions } from "../src/extensions.js";
30
+ import { loadExtensions, readExtensionDataUri } from "../src/extensions.js";
30
31
  import { readSprite, spriteArchive, spriteSource } from "../src/sprite.js";
31
32
  import { packageProject } from "../src/packager.js";
32
33
  import {
@@ -40,6 +41,7 @@ import { startDevServer } from "../src/dev.js";
40
41
  import {
41
42
  brandText,
42
43
  configFilename,
44
+ configFilenames,
43
45
  outputDirectory,
44
46
  sourceDirectory,
45
47
  sourceExtension,
@@ -64,6 +66,7 @@ function parseArgs(argv) {
64
66
  "extract",
65
67
  "lan",
66
68
  "strict-port",
69
+ "fetch-extensions",
67
70
  ]);
68
71
  for (let i = 0; i < argv.length; i++) {
69
72
  const token = argv[i];
@@ -96,7 +99,7 @@ Usage:
96
99
  ${command} build [<input${EXTENSION}>|<dir>] [-o <output.pmp>] [--no-config]
97
100
  ${command} package [<input${EXTENSION}>|<input.pmp>|<dir>] [-o <output>] [--target <target>] [--extract]
98
101
  ${command} dev [<input${EXTENSION}>|<dir>] [--host [host]] [--lan] [--port <port>] [--strict-port]
99
- ${command} decompile <input.pmp> [-o <output${EXTENSION}>] [--assets <dir>]
102
+ ${command} decompile <input.pmp> [-o <output${EXTENSION}>] [--assets <dir>] [--fetch-extensions]
100
103
  ${command} check [<input${EXTENSION}>|<dir>]
101
104
  ${command} catalog [<dir>] [--json]
102
105
  ${command} init [dir]
@@ -107,7 +110,8 @@ Commands:
107
110
  package (p) Package a source, project archive or directory into HTML or a ZIP
108
111
  (Windows/macOS/Linux desktop builds use Electron).
109
112
  dev Serve the packaged HTML and reload when the output changes.
110
- decompile (d) Convert a .pmp back into ${product.name} source.
113
+ decompile (d) Convert a .pmp back into ${product.name} source, restoring
114
+ extensions into ${configFilename(product)}.
111
115
  check (c) Parse a file (or every ${EXTENSION} file in a directory) for errors.
112
116
  catalog (l) List ${product.name} blocks, including those from ${configFilename(product)}
113
117
  extensions (paths or URLs).
@@ -133,6 +137,7 @@ Options:
133
137
  -o, --output Output file path
134
138
  --no-config Ignore ${configFilename(product)}
135
139
  --assets <dir> Extract project assets into <dir> when decompiling
140
+ --fetch-extensions Load remote (http/https) extensions when decompiling
136
141
  --target <t> Packaging target (html, zip, windows, macos, linux, …)
137
142
  --extract Unzip a packaged desktop/zip archive
138
143
  --host [host] Dev server host; with no value listen on all interfaces
@@ -159,7 +164,7 @@ function fail(message) {
159
164
  process.exit(1);
160
165
  }
161
166
 
162
- async function loadProjectExtensions(dir, config, { localFileWarning } = {}) {
167
+ async function loadProjectExtensions(dir, config) {
163
168
  const catalog = {};
164
169
  const extensionUrls = {};
165
170
  const unsandboxed = [];
@@ -168,7 +173,6 @@ async function loadProjectExtensions(dir, config, { localFileWarning } = {}) {
168
173
  const loaded = await loadExtensions(config.extensions, {
169
174
  baseDir: dir,
170
175
  expectUnsandboxed: config.unsandboxed,
171
- localFileWarning,
172
176
  });
173
177
  Object.assign(catalog, loaded.catalog);
174
178
  Object.assign(extensionUrls, loaded.extensionUrls);
@@ -471,9 +475,7 @@ async function cmdCatalog(args) {
471
475
  : loadConfigFrom(args._[0] || process.cwd());
472
476
  const config = loaded.project;
473
477
  const extBase = loaded.path ? path.dirname(loaded.path) : process.cwd();
474
- const { catalog } = await loadProjectExtensions(extBase, config || {}, {
475
- localFileWarning: false,
476
- });
478
+ const { catalog } = await loadProjectExtensions(extBase, config || {});
477
479
 
478
480
  const merged = { ...CATALOG, ...catalog };
479
481
  const names = Object.keys(merged).sort();
@@ -499,12 +501,228 @@ async function cmdCatalog(args) {
499
501
  }
500
502
  }
501
503
 
502
- function cmdDecompile(args) {
504
+ function truncate(text, max) {
505
+ const value = String(text);
506
+ return value.length > max ? `${value.slice(0, max)}…` : value;
507
+ }
508
+
509
+ /** Sanitize an extension id into a portable file name stem. */
510
+ function extensionFileStem(id) {
511
+ return (
512
+ String(id)
513
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
514
+ .replace(/^[.-]+|[.-]+$/g, "") || "extension"
515
+ );
516
+ }
517
+
518
+ /**
519
+ * Recover the extensions a decompiled project references.
520
+ *
521
+ * An archive only remembers extension sources through `project.extensionURLs`:
522
+ * Triflare Fx stores local extension files there as data URIs, and PenguinMod
523
+ * projects keep plain URLs. Data URIs are written next to the output and
524
+ * referenced from the config file; URLs stay URLs.
525
+ *
526
+ * A decompiled archive is untrusted and the output directory is often the
527
+ * user's workspace, so embedded sources are never written over an existing
528
+ * path and never through a symlink. An extension that cannot be written is
529
+ * still loaded from the archive in memory, so its blocks decompile normally,
530
+ * and its own data URI is recorded in the config so later builds keep it.
531
+ *
532
+ * @param {object} project parsed project.json
533
+ * @param {string|null} outDir directory that receives the config and extension files
534
+ * @param {{fetchRemote?: boolean}} [options] `fetchRemote` loads plain URLs
535
+ * instead of skipping them, which costs a network request per extension.
536
+ * @returns {{entries: string[], loadEntries: string[], files: string[], warnings: string[]}}
537
+ */
538
+ function restoreExtensions(project, outDir, { fetchRemote = false } = {}) {
539
+ const result = { entries: [], loadEntries: [], files: [], warnings: [] };
540
+ const urls = project && project.extensionURLs;
541
+ if (!urls || typeof urls !== "object") return result;
542
+
543
+ // Resolve the directory that receives embedded sources once, refusing a
544
+ // symlinked `extensions/` (which would redirect every write) and verifying
545
+ // that it really is a plain child of the output directory.
546
+ let extensionDir = null;
547
+ let extensionDirProblem = null;
548
+ const resolveExtensionDir = () => {
549
+ if (extensionDir || extensionDirProblem) return;
550
+ const dir = path.join(outDir, "extensions");
551
+ const existing = fs.lstatSync(dir, { throwIfNoEntry: false });
552
+ if (existing && !existing.isDirectory()) {
553
+ extensionDirProblem = existing.isSymbolicLink()
554
+ ? `"${dir}" is a symlink, so the extension would land outside the output directory`
555
+ : `"${dir}" is not a directory`;
556
+ return;
557
+ }
558
+ fs.mkdirSync(dir, { recursive: true });
559
+ const expected = path.join(fs.realpathSync(outDir), "extensions");
560
+ const actual = fs.realpathSync(dir);
561
+ if (actual !== expected) {
562
+ extensionDirProblem = `"${dir}" resolves to ${actual}, outside the output directory`;
563
+ return;
564
+ }
565
+ extensionDir = dir;
566
+ };
567
+
568
+ const used = new Set();
569
+ for (const [id, value] of Object.entries(urls)) {
570
+ const uri = typeof value === "string" ? value.trim() : "";
571
+ if (!uri) continue;
572
+ if (/^https?:\/\//i.test(uri)) {
573
+ result.entries.push(uri);
574
+ if (fetchRemote) result.loadEntries.push(uri);
575
+ else
576
+ result.warnings.push(
577
+ `extension ${id} points at a remote URL (${truncate(uri, 48)}); ` +
578
+ "not fetching it, so its blocks stay unsupported — pass " +
579
+ "--fetch-extensions to load it",
580
+ );
581
+ continue;
582
+ }
583
+ if (!/^data:/i.test(uri)) {
584
+ result.warnings.push(
585
+ `extension ${id} uses an unsupported URL (${truncate(uri, 48)}); ` +
586
+ `add it to ${configFilename(product)} by hand`,
587
+ );
588
+ continue;
589
+ }
590
+ const source = readExtensionDataUri(uri);
591
+ if (source == null) {
592
+ result.warnings.push(
593
+ `extension ${id} has an unreadable data URI; ` +
594
+ `add it to ${configFilename(product)} by hand`,
595
+ );
596
+ continue;
597
+ }
598
+ // When no file can be written, the archive's own copy is the only faithful
599
+ // reference left, so the config keeps its data URI: a later build still has
600
+ // the extension without overwriting, or depending on, a workspace file.
601
+ const keepEmbedded = (warning) => {
602
+ result.loadEntries.push(uri);
603
+ if (outDir) result.entries.push(uri);
604
+ result.warnings.push(warning);
605
+ };
606
+ if (!outDir) {
607
+ keepEmbedded(
608
+ `extension ${id} is embedded in the archive; pass -o to write it out ` +
609
+ `and reference it from ${configFilename(product)}`,
610
+ );
611
+ continue;
612
+ }
613
+ resolveExtensionDir();
614
+ if (extensionDirProblem) {
615
+ keepEmbedded(
616
+ `extension ${id} was not written: ${extensionDirProblem}; its blocks ` +
617
+ "still decompile and the config keeps its source, but the extension " +
618
+ "file is missing from the output",
619
+ );
620
+ continue;
621
+ }
622
+ const base = extensionFileStem(id);
623
+ let stem = base;
624
+ for (let suffix = 2; used.has(stem); suffix += 1)
625
+ stem = `${base}-${suffix}`;
626
+ used.add(stem);
627
+ const file = path.join(extensionDir, `${stem}.js`);
628
+ const relative = `extensions/${stem}.js`;
629
+ const existing = fs.lstatSync(file, { throwIfNoEntry: false });
630
+ if (existing) {
631
+ // Never replace a workspace file. An identical regular file is already
632
+ // the copy we would have written, so reference it and leave it alone.
633
+ if (existing.isFile() && fs.readFileSync(file, "utf8") === source) {
634
+ result.files.push(relative);
635
+ result.entries.push(relative);
636
+ result.loadEntries.push(file);
637
+ } else {
638
+ keepEmbedded(
639
+ `extension ${id} not written to ${relative}: ` +
640
+ (existing.isSymbolicLink()
641
+ ? "the path is a symlink"
642
+ : "a different file already exists") +
643
+ "; the config keeps the archive's copy, so delete or rename it to " +
644
+ "write the extension file",
645
+ );
646
+ }
647
+ continue;
648
+ }
649
+ try {
650
+ // `wx` fails instead of following a path that appeared in the meantime.
651
+ fs.writeFileSync(file, source, { flag: "wx" });
652
+ } catch (err) {
653
+ keepEmbedded(
654
+ `extension ${id} not written to ${relative}: ` +
655
+ (err.code === "EEXIST"
656
+ ? "the path appeared while decompiling"
657
+ : `${err.code || err.message}`) +
658
+ "; the config keeps the archive's copy, so delete or rename it to " +
659
+ "write the extension file",
660
+ );
661
+ continue;
662
+ }
663
+ result.files.push(relative);
664
+ result.entries.push(relative);
665
+ result.loadEntries.push(file);
666
+ }
667
+ return result;
668
+ }
669
+
670
+ /**
671
+ * Record restored extensions in `fx.yml`, creating one next to the decompiled
672
+ * source when the directory has none yet.
673
+ *
674
+ * @param {string} outDir
675
+ * @param {string[]} entries
676
+ * @returns {{file: string, added: number}|null}
677
+ */
678
+ function writeRestoredConfig(outDir, entries) {
679
+ if (entries.length === 0) return null;
680
+ const existing = configFilenames()
681
+ .map((name) => path.join(outDir, name))
682
+ .find((file) => fs.existsSync(file));
683
+ if (existing) {
684
+ let added = 0;
685
+ for (const entry of entries)
686
+ if (addProjectExtension(existing, entry).added) added += 1;
687
+ return { file: existing, added };
688
+ }
689
+ const file = path.join(outDir, configFilenames()[0]);
690
+ const lines = ["project:", " extensions:"];
691
+ for (const entry of entries) lines.push(` - ${JSON.stringify(entry)}`);
692
+ lines.push("");
693
+ fs.writeFileSync(file, lines.join("\n"));
694
+ return { file, added: entries.length };
695
+ }
696
+
697
+ async function cmdDecompile(args) {
503
698
  const input = args._[0];
504
699
  if (!input)
505
700
  fail(`decompile requires an input .pmp file\n\n${usage(process.stderr)}`);
506
701
  const data = fs.readFileSync(input);
507
702
  const files = unzipSync(data);
703
+ const projectJson = files.get("project.json");
704
+ if (!projectJson) fail("Not a Scratch project: missing project.json");
705
+ const project = JSON.parse(projectJson.toString("utf8"));
706
+
707
+ const output = args.flags.o || args.flags.output;
708
+ const outDir = output ? path.dirname(path.resolve(output)) : null;
709
+ const restored = restoreExtensions(project, outDir, {
710
+ fetchRemote: Boolean(args.flags["fetch-extensions"]),
711
+ });
712
+ for (const warning of restored.warnings)
713
+ process.stderr.write(`warning: ${warning}\n`);
714
+
715
+ // Extension sources also feed the catalog, so extension blocks decompile as
716
+ // real calls instead of "unsupported block".
717
+ let catalog = {};
718
+ if (restored.loadEntries.length > 0) {
719
+ const loaded = await loadExtensions(restored.loadEntries, {
720
+ baseDir: outDir || process.cwd(),
721
+ });
722
+ catalog = loaded.catalog;
723
+ for (const warning of loaded.warnings)
724
+ process.stderr.write(`warning: ${warning}\n`);
725
+ }
508
726
 
509
727
  const assetDir = args.flags.assets;
510
728
  let assetPrefix = "";
@@ -523,24 +741,23 @@ function cmdDecompile(args) {
523
741
  fs.writeFileSync(destination, buffer);
524
742
  }
525
743
  // 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
744
  const rel = path
534
- .relative(outDir, path.resolve(assetDir))
745
+ .relative(outDir || process.cwd(), path.resolve(assetDir))
535
746
  .replace(/\\/g, "/");
536
747
  assetPrefix = rel ? `${rel}/` : "";
537
748
  }
538
749
 
539
- const source = decompile(data, { assetPrefix });
540
- const output = args.flags.o || args.flags.output;
750
+ const source = decompileProject(project, { assetPrefix, catalog });
541
751
  if (output) {
542
752
  fs.writeFileSync(output, source);
753
+ const config = writeRestoredConfig(outDir, restored.entries);
543
754
  log.success(`Decompiled ${input} -> ${output}`);
755
+ if (config)
756
+ log.success(
757
+ `Restored ${config.added} extension(s) -> ${
758
+ path.relative(process.cwd(), config.file) || config.file
759
+ }`,
760
+ );
544
761
  } else {
545
762
  process.stdout.write(source);
546
763
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflare/fx",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Code PenguinMod projects with text.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/product.yml CHANGED
@@ -1,7 +1,7 @@
1
1
  # Identity
2
2
  name: "Triflare Fx"
3
3
  command: "fx"
4
- version: "1.0.0"
4
+ version: "1.0.1"
5
5
  tagline: "Code PenguinMod projects with text."
6
6
  author: "Triflare"
7
7
  homepage: "https://github.com/triflare/fx"
package/src/decompiler.js CHANGED
@@ -62,10 +62,25 @@ function numberText(value) {
62
62
  return String(value);
63
63
  }
64
64
 
65
+ /** Reverse lookup for a merged catalog: opcode -> canonical catalog name. */
66
+ function buildByOpcode(catalog) {
67
+ const map = new Map();
68
+ for (const [name, entry] of Object.entries(catalog)) {
69
+ if (!map.has(entry.opcode)) map.set(entry.opcode, name);
70
+ }
71
+ return map;
72
+ }
73
+
65
74
  class Decompiler {
66
75
  constructor(project, options = {}) {
67
76
  this.project = project;
68
77
  this.assetPrefix = options.assetPrefix || "";
78
+ // Extension entries come from `loadExtensions`, so projects that use
79
+ // extensions decompile into real calls instead of "unsupported block".
80
+ this.catalog = options.catalog
81
+ ? { ...CATALOG, ...options.catalog }
82
+ : CATALOG;
83
+ this.byOpcode = options.catalog ? buildByOpcode(this.catalog) : BY_OPCODE;
69
84
  this.returningProccodes = new Set();
70
85
  for (const target of project.targets) {
71
86
  for (const block of Object.values(target.blocks)) {
@@ -149,6 +164,12 @@ class Decompiler {
149
164
  const field = Object.values(block.fields || {})[0];
150
165
  return field ? `"${escapeString(field[0])}"` : '""';
151
166
  }
167
+ // Extension menus are shadow blocks the catalog never describes; their
168
+ // chosen option is the only thing worth emitting.
169
+ if (block.shadow) {
170
+ const field = Object.values(block.fields || {})[0];
171
+ if (field) return `"${escapeString(field[0])}"`;
172
+ }
152
173
  if (block.opcode === "data_variable") return block.fields.VARIABLE[0];
153
174
  if (block.opcode === "data_listcontents") return block.fields.LIST[0];
154
175
  if (
@@ -191,13 +212,13 @@ class Decompiler {
191
212
  return `(not ${this.renderExpression(block.inputs.OPERAND)})`;
192
213
  }
193
214
 
194
- const name = BY_OPCODE.get(block.opcode);
215
+ const name = this.byOpcode.get(block.opcode);
195
216
  if (name) return this.renderCatalogCall(name, block);
196
217
  return `/* unknown ${block.opcode} */`;
197
218
  }
198
219
 
199
220
  renderCatalogCall(name, block) {
200
- const entry = CATALOG[name];
221
+ const entry = this.catalog[name];
201
222
  const args = [];
202
223
  for (const key of Object.keys(entry.fields || {})) {
203
224
  const field = block.fields[key];
@@ -279,12 +300,22 @@ class Decompiler {
279
300
  );
280
301
  return `${pad}${listName}.${method}(${args.join(", ")});`;
281
302
  }
282
- const name = BY_OPCODE.get(block.opcode);
283
- if (name && CATALOG[name].kind === "command") {
303
+ const name = this.byOpcode.get(block.opcode);
304
+ if (!name) return `${pad}// unsupported block ${block.opcode}`;
305
+ const entry = this.catalog[name];
306
+ const substacks = Object.entries(entry.inputs || {})
307
+ .filter(([, spec]) => spec.type === "substack")
308
+ .map(([input]) => input);
309
+ if (substacks.length === 0)
284
310
  return `${pad}${this.renderCatalogCall(name, block)};`;
285
- }
286
- if (name) return `${pad}${this.renderCatalogCall(name, block)};`;
287
- return `${pad}// unsupported block ${block.opcode}`;
311
+ const primary = firstOf(block.inputs[substacks[0]]);
312
+ const alternate = substacks[1]
313
+ ? firstOf(block.inputs[substacks[1]])
314
+ : null;
315
+ const otherwise = alternate
316
+ ? ` else {${sub(firstOf(block.inputs[substacks[1]]))}\n${pad}}`
317
+ : "";
318
+ return `${pad}${this.renderCatalogCall(name, block)} {${sub(primary)}${otherwise}\n${pad}}`;
288
319
  }
289
320
  }
290
321
  }
@@ -321,10 +352,25 @@ class Decompiler {
321
352
  return `${pad}when ${kind}(${this.renderExpression(block.inputs.VALUE)}) {\n${body(block.next)}\n}`;
322
353
  }
323
354
  default:
324
- return null;
355
+ return this.renderExtensionHat(block, pad, body);
325
356
  }
326
357
  }
327
358
 
359
+ /** Render hats contributed by extensions, e.g. `when signals.onBang("x") {`. */
360
+ renderExtensionHat(block, pad, body) {
361
+ const name = this.byOpcode.get(block.opcode);
362
+ const entry = name ? this.catalog[name] : null;
363
+ if (!entry || entry.kind !== "hat") return null;
364
+ const args = (entry.args || []).map((spec) => {
365
+ if (spec.field) {
366
+ const field = (block.fields || {})[spec.field];
367
+ return `"${escapeString(field ? field[0] : "")}"`;
368
+ }
369
+ return this.renderExpression((block.inputs || {})[spec.input]);
370
+ });
371
+ return `${pad}when ${name}(${args.join(", ")}) {\n${body(block.next)}\n}`;
372
+ }
373
+
328
374
  renderFunction(defBlock) {
329
375
  const protoId = firstOf(defBlock.inputs.custom_block);
330
376
  const proto = this.target.blocks[protoId];
@@ -474,7 +520,8 @@ function indentText(text, prefix) {
474
520
  /**
475
521
  * Decompile project JSON into Triflare Fx source.
476
522
  * @param {object} project
477
- * @param {{assetPrefix?: string}} [options]
523
+ * @param {{assetPrefix?: string, catalog?: Record<string, object>}} [options]
524
+ * `catalog` adds extension entries from `loadExtensions`.
478
525
  * @returns {string}
479
526
  */
480
527
  export function decompileProject(project, options) {
@@ -484,7 +531,7 @@ export function decompileProject(project, options) {
484
531
  /**
485
532
  * Decompile a `.pmp`/`.sb3` buffer (or project JSON) into Triflare Fx source.
486
533
  * @param {Buffer|Uint8Array|object} input
487
- * @param {{assetPrefix?: string}} [options]
534
+ * @param {{assetPrefix?: string, catalog?: Record<string, object>}} [options]
488
535
  * @returns {string}
489
536
  */
490
537
  export function decompile(input, options) {
package/src/extensions.js CHANGED
@@ -534,9 +534,55 @@ async function evaluateExtension(
534
534
 
535
535
  // ------------------------------------------------------------------- loading
536
536
 
537
+ const DATA_URI = /^data:([^,]*),([\s\S]*)$/i;
538
+ const BASE64_PAYLOAD = /^[A-Za-z0-9+/=\s]*$/;
539
+
540
+ /**
541
+ * Encode extension source as a `data:` URI.
542
+ *
543
+ * Archives only remember extension sources through `project.extensionURLs`, so
544
+ * local extension files are baked in as data URIs (the same trick the
545
+ * PenguinMod packager uses when it bakes extensions). Base64 survives quotes,
546
+ * comments and non-ASCII text without escaping.
547
+ * @param {string} source
548
+ * @returns {string}
549
+ */
550
+ export function extensionDataUri(source) {
551
+ const base64 = Buffer.from(String(source), "utf8").toString("base64");
552
+ return `data:text/javascript;base64,${base64}`;
553
+ }
554
+
555
+ /**
556
+ * Decode a `data:` URI back into extension source text.
557
+ * @param {string} uri
558
+ * @returns {string|null} source text, or null when the URI is unsupported
559
+ */
560
+ export function readExtensionDataUri(uri) {
561
+ const match = DATA_URI.exec(String(uri).trim());
562
+ if (!match) return null;
563
+ const [, meta, payload] = match;
564
+ try {
565
+ if (/;base64\s*$/i.test(meta)) {
566
+ if (!BASE64_PAYLOAD.test(payload)) return null;
567
+ return Buffer.from(payload, "base64").toString("utf8");
568
+ }
569
+ return decodeURIComponent(payload);
570
+ } catch {
571
+ return null;
572
+ }
573
+ }
574
+
537
575
  /** Resolve a config entry to source text. */
538
576
  async function resolveSource(entry, baseDir, fetchImpl) {
539
577
  const value = String(entry);
578
+ if (/^data:/i.test(value)) {
579
+ const source = readExtensionDataUri(value);
580
+ if (source == null)
581
+ throw new Error(
582
+ "unsupported data URI; expected base64 or percent-encoded JavaScript",
583
+ );
584
+ return { source, remote: false, dataUri: true, url: value };
585
+ }
540
586
  if (/^https?:\/\//i.test(value)) {
541
587
  const fetchFn = fetchImpl || globalThis.fetch;
542
588
  if (typeof fetchFn !== "function")
@@ -597,23 +643,16 @@ async function evaluateExtensionAny(source, filename) {
597
643
  /**
598
644
  * Load a list of extension references.
599
645
  *
600
- * @param {Array<string>} list Paths or `http(s)://` URLs.
601
- * @param {{baseDir?: string, fetchImpl?: typeof fetch, expectUnsandboxed?: string|string[], localFileWarning?: boolean}} [options]
646
+ * @param {Array<string>} list Paths, `http(s)://` URLs or `data:` URIs.
647
+ * @param {{baseDir?: string, fetchImpl?: typeof fetch, expectUnsandboxed?: string|string[]}} [options]
602
648
  * `expectUnsandboxed` acknowledges extensions that are known to require
603
- * unsandboxed mode, silencing the warning for them. `localFileWarning`
604
- * (default true) emits the build-specific notice that local files are not
605
- * recorded in `extensionURLs`; listing commands turn it off.
649
+ * unsandboxed mode, silencing the warning for them.
606
650
  * @returns {Promise<{catalog: Record<string, object>, extensionUrls: Record<string, string>, warnings: string[], ids: string[], unsandboxed: string[]}>}
607
651
  * `unsandboxed` holds the ids of extensions that only load unsandboxed.
608
652
  */
609
653
  export async function loadExtensions(
610
654
  list,
611
- {
612
- baseDir = process.cwd(),
613
- fetchImpl,
614
- expectUnsandboxed = [],
615
- localFileWarning = true,
616
- } = {},
655
+ { baseDir = process.cwd(), fetchImpl, expectUnsandboxed = [] } = {},
617
656
  ) {
618
657
  const catalog = {};
619
658
  const extensionUrls = {};
@@ -631,62 +670,63 @@ export async function loadExtensions(
631
670
 
632
671
  for (const entry of list || []) {
633
672
  const label = String(entry);
673
+ // Data URIs are enormous; keep warning lines readable without breaking the
674
+ // path/URL matching that `expectUnsandboxed` relies on.
675
+ const name = label.length > 64 ? `${label.slice(0, 60)}…` : label;
634
676
  try {
635
- const { source, remote, url, file } = await resolveSource(
677
+ const { source, remote, url, file, dataUri } = await resolveSource(
636
678
  entry,
637
679
  baseDir,
638
680
  fetchImpl,
639
681
  );
640
682
  const { infos, unsandboxed: needsUnsandboxed } =
641
- await evaluateExtensionAny(source, remote ? url : file);
683
+ await evaluateExtensionAny(
684
+ source,
685
+ remote ? url : (file ?? "extension"),
686
+ );
642
687
  if (infos.length === 0) {
643
688
  warnings.push(
644
- `extension ${label} did not call Scratch.extensions.register()`,
689
+ `extension ${name} did not call Scratch.extensions.register()`,
645
690
  );
646
691
  continue;
647
692
  }
648
693
  for (const info of infos) {
649
- const count = registerInfo(catalog, info, label);
694
+ const count = registerInfo(catalog, info, name);
650
695
  if (!ids.includes(info.id)) ids.push(info.id);
651
696
 
652
- for (const name of [info.id, label]) {
653
- if (!declared.has(name)) continue;
654
- declaredSeen.add(name);
655
- if (!needsUnsandboxed) declaredNotNeeded.add(name);
697
+ for (const candidate of [info.id, label]) {
698
+ if (!declared.has(candidate)) continue;
699
+ declaredSeen.add(candidate);
700
+ if (!needsUnsandboxed) declaredNotNeeded.add(candidate);
656
701
  }
657
702
 
658
703
  if (needsUnsandboxed) {
659
704
  if (!unsandboxed.includes(info.id)) unsandboxed.push(info.id);
660
- needsDeclaration.push({ id: info.id, label });
705
+ needsDeclaration.push({ id: info.id, label, name });
661
706
  }
662
707
 
663
- if (remote) {
664
- extensionUrls[info.id] = url;
665
- } else if (localFileWarning) {
666
- warnings.push(
667
- `extension ${label} (id "${info.id}") is a local file; it will be referenced in ` +
668
- "project.extensions but not in extensionURLs. Bundle it with the PenguinMod packager " +
669
- "or host it before loading in the editor.",
670
- );
671
- }
708
+ // Remote URLs travel as-is; local (and already embedded) sources are
709
+ // recorded as data URIs so the archive carries the extension with it.
710
+ extensionUrls[info.id] =
711
+ remote || dataUri ? url : extensionDataUri(source);
672
712
  if (count === 0)
673
713
  warnings.push(
674
- `extension ${label} (id "${info.id}") exposes no usable blocks`,
714
+ `extension ${name} (id "${info.id}") exposes no usable blocks`,
675
715
  );
676
716
  }
677
717
  } catch (err) {
678
718
  warnings.push(
679
- `failed to load extension ${label}: ${err && err.message ? err.message : err}`,
719
+ `failed to load extension ${name}: ${err && err.message ? err.message : err}`,
680
720
  );
681
721
  }
682
722
  }
683
723
 
684
724
  // Extensions that only load unsandboxed error out unless the editor is allowed
685
725
  // to run them unsandboxed (or the project is packaged), so say so up front.
686
- for (const { id, label } of needsDeclaration) {
726
+ for (const { id, label, name } of needsDeclaration) {
687
727
  if (declared.has(id) || declared.has(label)) continue;
688
728
  warnings.push(
689
- `extension ${label} (id "${id}") requires unsandboxed mode; allow it to run ` +
729
+ `extension ${name} (id "${id}") requires unsandboxed mode; allow it to run ` +
690
730
  `unsandboxed when opening the project in PenguinMod (or add "${id}" to ` +
691
731
  "project.unsandboxed in fx.yml once you trust it)",
692
732
  );
package/src/index.js CHANGED
@@ -13,7 +13,11 @@ export { build, writeSb3 } from "./build.js";
13
13
  export { discoverSources, mergePrograms } from "./project.js";
14
14
  export { decompile, decompileProject } from "./decompiler.js";
15
15
  export { zipSync, unzipSync, md5hex, imageSize, wavInfo } from "./sb3.js";
16
- export { loadExtensions } from "./extensions.js";
16
+ export {
17
+ loadExtensions,
18
+ extensionDataUri,
19
+ readExtensionDataUri,
20
+ } from "./extensions.js";
17
21
  export { readSprite, spriteArchive, spriteSource } from "./sprite.js";
18
22
  export { packageProject, loadPackager, packagerDistPath } from "./packager.js";
19
23
  export {