@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.
- package/LICENSE +201 -0
- package/README.md +73 -2
- package/bin/fx.js +1166 -0
- package/package.json +64 -4
- package/product.yml +28 -0
- package/src/brand.js +105 -0
- package/src/build.js +181 -0
- package/src/catalog.js +753 -0
- package/src/compiler.js +1658 -0
- package/src/config-edit.js +338 -0
- package/src/config.js +400 -0
- package/src/decompiler.js +551 -0
- package/src/dev.js +390 -0
- package/src/errors.js +56 -0
- package/src/extensions.js +747 -0
- package/src/index.js +35 -0
- package/src/language.js +12 -0
- package/src/lexer.js +202 -0
- package/src/log.js +70 -0
- package/src/package.js +202 -0
- package/src/packager.js +177 -0
- package/src/parser.js +608 -0
- package/src/product.js +27 -0
- package/src/project.js +156 -0
- package/src/sb3.js +340 -0
- package/src/sprite.js +81 -0
- package/vendor/pm-packager-dist/.fx-build.json +6 -0
- package/vendor/pm-packager-dist/LICENSE +202 -0
- package/vendor/pm-packager-dist/NOTICE +6 -0
- package/vendor/pm-packager-dist/dist/290e09e569a1cab8e61ba93b0d23863f.png +0 -0
- package/vendor/pm-packager-dist/dist/icns.js +1 -0
- package/vendor/pm-packager-dist/dist/packager.js +154 -0
- package/vendor/pm-packager-dist/dist/scaffolding/addons.js +2 -0
- package/vendor/pm-packager-dist/dist/scaffolding/extension worker.js +2 -0
- package/vendor/pm-packager-dist/dist/scaffolding/scaffolding-full.js +93 -0
- package/vendor/pm-packager-dist/dist/scaffolding/scaffolding-min.js +93 -0
- package/vendor/pm-packager-dist/dist/scaffolding/vendors~iframe-extension-worker.js +2 -0
- package/vendor/pm-packager-dist/dist/vendors~sha256.js +3 -0
- package/vendor/pm-packager-dist/package.json +3 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Triflare Fx public API.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* import {build} from 'fx';
|
|
6
|
+
* const {sb3} = build({file: 'src/player.tfx'});
|
|
7
|
+
* fs.writeFileSync('player.pmp', sb3);
|
|
8
|
+
*/
|
|
9
|
+
export { tokenize } from "./lexer.js";
|
|
10
|
+
export { parse } from "./parser.js";
|
|
11
|
+
export { Compiler, compile } from "./compiler.js";
|
|
12
|
+
export { build, writeSb3 } from "./build.js";
|
|
13
|
+
export { discoverSources, mergePrograms } from "./project.js";
|
|
14
|
+
export { decompile, decompileProject } from "./decompiler.js";
|
|
15
|
+
export { zipSync, unzipSync, md5hex, imageSize, wavInfo } from "./sb3.js";
|
|
16
|
+
export {
|
|
17
|
+
loadExtensions,
|
|
18
|
+
extensionDataUri,
|
|
19
|
+
readExtensionDataUri,
|
|
20
|
+
} from "./extensions.js";
|
|
21
|
+
export { readSprite, spriteArchive, spriteSource } from "./sprite.js";
|
|
22
|
+
export { packageProject, loadPackager, packagerDistPath } from "./packager.js";
|
|
23
|
+
export {
|
|
24
|
+
applyCustomHtml,
|
|
25
|
+
extractArchive,
|
|
26
|
+
packageExtension,
|
|
27
|
+
packageOutputPath,
|
|
28
|
+
packageSignature,
|
|
29
|
+
resolvePackageConfig,
|
|
30
|
+
slugify,
|
|
31
|
+
} from "./package.js";
|
|
32
|
+
export { formatDevUrls, injectDevClient, startDevServer } from "./dev.js";
|
|
33
|
+
export * from "./catalog.js";
|
|
34
|
+
export * from "./config.js";
|
|
35
|
+
export { FxError, LexError, ParseError, CompileError } from "./errors.js";
|
package/src/language.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Triflare Fx language modules, browser-safe.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the root `@triflare/fx` entry point, this subpath only
|
|
5
|
+
* re-exports the lexer, parser, error types and block catalog: pure ESM with
|
|
6
|
+
* no Node built-ins, so it can be bundled for browsers, web workers and
|
|
7
|
+
* editor tooling (e.g. the VS Code extension under `editors/vscode/`).
|
|
8
|
+
*/
|
|
9
|
+
export { tokenize } from "./lexer.js";
|
|
10
|
+
export { parse } from "./parser.js";
|
|
11
|
+
export { FxError, LexError, ParseError } from "./errors.js";
|
|
12
|
+
export * from "./catalog.js";
|
package/src/lexer.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Triflare Fx lexer.
|
|
3
|
+
*
|
|
4
|
+
* Produces a flat list of tokens with 1-based line/column information so that
|
|
5
|
+
* downstream errors can point at the exact source location.
|
|
6
|
+
*/
|
|
7
|
+
import { LexError } from "./errors.js";
|
|
8
|
+
|
|
9
|
+
const PUNCTUATION = [
|
|
10
|
+
// longest first so that greedy matching works
|
|
11
|
+
"+=",
|
|
12
|
+
"-=",
|
|
13
|
+
"*=",
|
|
14
|
+
"/=",
|
|
15
|
+
"==",
|
|
16
|
+
"!=",
|
|
17
|
+
"<=",
|
|
18
|
+
">=",
|
|
19
|
+
"&&",
|
|
20
|
+
"||",
|
|
21
|
+
"{",
|
|
22
|
+
"}",
|
|
23
|
+
"(",
|
|
24
|
+
")",
|
|
25
|
+
"[",
|
|
26
|
+
"]",
|
|
27
|
+
",",
|
|
28
|
+
";",
|
|
29
|
+
":",
|
|
30
|
+
".",
|
|
31
|
+
"=",
|
|
32
|
+
"<",
|
|
33
|
+
">",
|
|
34
|
+
"+",
|
|
35
|
+
"-",
|
|
36
|
+
"*",
|
|
37
|
+
"/",
|
|
38
|
+
"%",
|
|
39
|
+
"!",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const isIdentStart = (ch) => /[A-Za-z_$]/.test(ch);
|
|
43
|
+
const isIdentPart = (ch) => /[A-Za-z0-9_$]/.test(ch);
|
|
44
|
+
const isDigit = (ch) => ch >= "0" && ch <= "9";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {object} Token
|
|
48
|
+
* @property {'ident'|'number'|'string'|'punct'|'eof'} type
|
|
49
|
+
* @property {string} value
|
|
50
|
+
* @property {number} line
|
|
51
|
+
* @property {number} column
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Tokenize a Triflare Fx source string.
|
|
56
|
+
* @param {string} source
|
|
57
|
+
* @param {string} [file]
|
|
58
|
+
* @returns {Token[]}
|
|
59
|
+
*/
|
|
60
|
+
export function tokenize(source, file = "<input>") {
|
|
61
|
+
const tokens = [];
|
|
62
|
+
let i = 0;
|
|
63
|
+
let line = 1;
|
|
64
|
+
let column = 1;
|
|
65
|
+
|
|
66
|
+
const push = (type, value, startLine, startColumn) => {
|
|
67
|
+
tokens.push({ type, value, line: startLine, column: startColumn, file });
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const fail = (message, startLine = line, startColumn = column) => {
|
|
71
|
+
throw new LexError(message, {
|
|
72
|
+
file,
|
|
73
|
+
line: startLine,
|
|
74
|
+
column: startColumn,
|
|
75
|
+
source,
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const advance = (n = 1) => {
|
|
80
|
+
for (let k = 0; k < n; k++) {
|
|
81
|
+
if (source[i] === "\n") {
|
|
82
|
+
line++;
|
|
83
|
+
column = 1;
|
|
84
|
+
} else {
|
|
85
|
+
column++;
|
|
86
|
+
}
|
|
87
|
+
i++;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
while (i < source.length) {
|
|
92
|
+
const ch = source[i];
|
|
93
|
+
|
|
94
|
+
// --- whitespace -----------------------------------------------------
|
|
95
|
+
if (ch === " " || ch === "\t" || ch === "\r" || ch === "\n") {
|
|
96
|
+
advance();
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- comments -------------------------------------------------------
|
|
101
|
+
if (ch === "/" && source[i + 1] === "/") {
|
|
102
|
+
while (i < source.length && source[i] !== "\n") advance();
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (ch === "/" && source[i + 1] === "*") {
|
|
106
|
+
const startLine = line;
|
|
107
|
+
const startColumn = column;
|
|
108
|
+
advance(2);
|
|
109
|
+
while (i < source.length && !(source[i] === "*" && source[i + 1] === "/"))
|
|
110
|
+
advance();
|
|
111
|
+
if (i >= source.length)
|
|
112
|
+
fail("Unterminated block comment", startLine, startColumn);
|
|
113
|
+
advance(2);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const startLine = line;
|
|
118
|
+
const startColumn = column;
|
|
119
|
+
|
|
120
|
+
// --- strings --------------------------------------------------------
|
|
121
|
+
if (ch === '"' || ch === "'") {
|
|
122
|
+
const quote = ch;
|
|
123
|
+
advance();
|
|
124
|
+
let out = "";
|
|
125
|
+
while (i < source.length && source[i] !== quote) {
|
|
126
|
+
if (source[i] === "\\") {
|
|
127
|
+
const next = source[i + 1];
|
|
128
|
+
const escapes = {
|
|
129
|
+
n: "\n",
|
|
130
|
+
t: "\t",
|
|
131
|
+
r: "\r",
|
|
132
|
+
'"': '"',
|
|
133
|
+
"'": "'",
|
|
134
|
+
"\\": "\\",
|
|
135
|
+
};
|
|
136
|
+
if (next in escapes) {
|
|
137
|
+
out += escapes[next];
|
|
138
|
+
advance(2);
|
|
139
|
+
} else {
|
|
140
|
+
out += next ?? "";
|
|
141
|
+
advance(2);
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
out += source[i];
|
|
145
|
+
advance();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (i >= source.length)
|
|
149
|
+
fail("Unterminated string literal", startLine, startColumn);
|
|
150
|
+
advance(); // closing quote
|
|
151
|
+
push("string", out, startLine, startColumn);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- numbers --------------------------------------------------------
|
|
156
|
+
if (isDigit(ch) || (ch === "." && isDigit(source[i + 1]))) {
|
|
157
|
+
let out = "";
|
|
158
|
+
while (i < source.length && (isDigit(source[i]) || source[i] === ".")) {
|
|
159
|
+
out += source[i];
|
|
160
|
+
advance();
|
|
161
|
+
}
|
|
162
|
+
if (i < source.length && /[eE]/.test(source[i])) {
|
|
163
|
+
out += source[i];
|
|
164
|
+
advance();
|
|
165
|
+
if (source[i] === "+" || source[i] === "-") {
|
|
166
|
+
out += source[i];
|
|
167
|
+
advance();
|
|
168
|
+
}
|
|
169
|
+
while (i < source.length && isDigit(source[i])) {
|
|
170
|
+
out += source[i];
|
|
171
|
+
advance();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
push("number", out, startLine, startColumn);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// --- identifiers ----------------------------------------------------
|
|
179
|
+
if (isIdentStart(ch)) {
|
|
180
|
+
let out = "";
|
|
181
|
+
while (i < source.length && isIdentPart(source[i])) {
|
|
182
|
+
out += source[i];
|
|
183
|
+
advance();
|
|
184
|
+
}
|
|
185
|
+
push("ident", out, startLine, startColumn);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --- punctuation ----------------------------------------------------
|
|
190
|
+
const punct = PUNCTUATION.find((p) => source.startsWith(p, i));
|
|
191
|
+
if (punct) {
|
|
192
|
+
advance(punct.length);
|
|
193
|
+
push("punct", punct, startLine, startColumn);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
fail(`Unexpected character ${JSON.stringify(ch)}`, startLine, startColumn);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
push("eof", "", line, column);
|
|
201
|
+
return tokens;
|
|
202
|
+
}
|
package/src/log.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { formatWithOptions, styleText } from "node:util";
|
|
2
|
+
import { brand } from "./brand.js";
|
|
3
|
+
|
|
4
|
+
// The CLI has no logging dependency, so this is the reporter. Glyphs and colors
|
|
5
|
+
// still come from product.yml, which keeps the terminal voice in one place.
|
|
6
|
+
const TYPES = {
|
|
7
|
+
success: { symbol: "success", color: "green" },
|
|
8
|
+
error: { symbol: "error", color: "red" },
|
|
9
|
+
warn: { symbol: "progress", color: "yellow" },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function stringify(message) {
|
|
13
|
+
if (typeof message === "string") return message;
|
|
14
|
+
if (message instanceof Error) return message.stack ?? message.message;
|
|
15
|
+
return formatWithOptions({ colors: false }, message);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// styleText's own escape for a color, so a line can be re-colored after a
|
|
19
|
+
// nested brand-colored word resets the foreground.
|
|
20
|
+
function colorOpen(color, stream) {
|
|
21
|
+
const marker = "\u0000";
|
|
22
|
+
const sample = styleText(color, marker, { stream });
|
|
23
|
+
const index = sample.indexOf(marker);
|
|
24
|
+
return index > 0 ? sample.slice(0, index) : "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function paint(text, color, stream) {
|
|
28
|
+
if (!color) return text;
|
|
29
|
+
const open = colorOpen(color, stream);
|
|
30
|
+
let painted = styleText(color, text, { stream });
|
|
31
|
+
if (open) {
|
|
32
|
+
painted = painted.replaceAll("\x1b[39m", `\x1b[39m${open}`);
|
|
33
|
+
if (painted.endsWith(open)) painted = painted.slice(0, -open.length);
|
|
34
|
+
}
|
|
35
|
+
return painted;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createLogger(product = brand(), streams = {}) {
|
|
39
|
+
const stdout = streams.stdout ?? process.stdout;
|
|
40
|
+
const stderr = streams.stderr ?? process.stderr;
|
|
41
|
+
const symbol = (name) => product.symbols?.[name] ?? "";
|
|
42
|
+
|
|
43
|
+
function report(type, message) {
|
|
44
|
+
const { symbol: glyph, color } = TYPES[type];
|
|
45
|
+
const mark = symbol(glyph);
|
|
46
|
+
const stream = type === "success" ? stdout : stderr;
|
|
47
|
+
const output = stringify(message)
|
|
48
|
+
.split("\n")
|
|
49
|
+
.map((line) => paint(mark ? `${mark} ${line}` : line, color, stream))
|
|
50
|
+
.join("\n");
|
|
51
|
+
stream.write(`${output}\n`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function mark(name, message) {
|
|
55
|
+
const glyph = symbol(name);
|
|
56
|
+
stdout.write(`${glyph ? `${glyph} ` : ""}${stringify(message)}\n`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
success: (message) => report("success", message),
|
|
61
|
+
error: (message) => report("error", message),
|
|
62
|
+
warn: (message) => report("warn", message),
|
|
63
|
+
progress: (message) => mark("progress", message),
|
|
64
|
+
bullet: (message) => {
|
|
65
|
+
const glyph = symbol("bullet");
|
|
66
|
+
stdout.write(` ${glyph ? `${glyph} ` : ""}${stringify(message)}\n`);
|
|
67
|
+
},
|
|
68
|
+
info: (message) => stdout.write(`${stringify(message)}\n`),
|
|
69
|
+
};
|
|
70
|
+
}
|
package/src/package.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Packaging helpers shared by `fx package`, `fx dev`, and the public API.
|
|
3
|
+
*
|
|
4
|
+
* `fx.yml` values can be inline text or a `{ file: "..." }` reference. File
|
|
5
|
+
* references are resolved here, relative to the directory that contains
|
|
6
|
+
* `fx.yml`, before the packager sees them.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { outputDirectory } from "./brand.js";
|
|
11
|
+
import { DEFAULT_PACKAGE, normalizePackageTarget } from "./config.js";
|
|
12
|
+
|
|
13
|
+
/** Turn a project name into a filesystem-friendly slug. */
|
|
14
|
+
export function slugify(name) {
|
|
15
|
+
return (
|
|
16
|
+
String(name || "project")
|
|
17
|
+
.trim()
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
20
|
+
.replace(/^-+|-+$/g, "") || "project"
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @param {unknown} value @param {string} label @param {string} baseDir */
|
|
25
|
+
function readTextSource(value, label, baseDir) {
|
|
26
|
+
if (value == null) return "";
|
|
27
|
+
if (typeof value === "string") return value;
|
|
28
|
+
if (value && typeof value === "object" && typeof value.file === "string") {
|
|
29
|
+
const file = path.resolve(baseDir, value.file);
|
|
30
|
+
try {
|
|
31
|
+
return fs.readFileSync(file, "utf8");
|
|
32
|
+
} catch (err) {
|
|
33
|
+
throw new Error(`Could not read ${label} from ${file}: ${err.message}`, {
|
|
34
|
+
cause: err,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`${label} must be text or { file: "<path>" }`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve file-backed values and paths in a normalized `package:` block.
|
|
43
|
+
*
|
|
44
|
+
* @param {typeof DEFAULT_PACKAGE} [pkg]
|
|
45
|
+
* @param {{baseDir?: string, projectName?: string}} [options]
|
|
46
|
+
* @returns {{
|
|
47
|
+
* target: string,
|
|
48
|
+
* output: string|null,
|
|
49
|
+
* extract: boolean,
|
|
50
|
+
* name: string|null,
|
|
51
|
+
* version: string|null,
|
|
52
|
+
* icon: string|null,
|
|
53
|
+
* custom: {js: string, css: string, html: string},
|
|
54
|
+
* options: object,
|
|
55
|
+
* baseDir: string,
|
|
56
|
+
* }}
|
|
57
|
+
*/
|
|
58
|
+
export function resolvePackageConfig(pkg = DEFAULT_PACKAGE, options = {}) {
|
|
59
|
+
const baseDir = path.resolve(options.baseDir || process.cwd());
|
|
60
|
+
const projectName = options.projectName || null;
|
|
61
|
+
return {
|
|
62
|
+
target: normalizePackageTarget(pkg.target),
|
|
63
|
+
output: pkg.output ? path.resolve(baseDir, pkg.output) : null,
|
|
64
|
+
extract: Boolean(pkg.extract),
|
|
65
|
+
name: pkg.name || projectName || null,
|
|
66
|
+
version: pkg.version || null,
|
|
67
|
+
icon: pkg.icon ? path.resolve(baseDir, pkg.icon) : null,
|
|
68
|
+
custom: {
|
|
69
|
+
js: readTextSource(pkg.custom?.js, "package.custom.js", baseDir),
|
|
70
|
+
css: readTextSource(pkg.custom?.css, "package.custom.css", baseDir),
|
|
71
|
+
html: readTextSource(pkg.custom?.html, "package.custom.html", baseDir),
|
|
72
|
+
},
|
|
73
|
+
options: pkg.options || {},
|
|
74
|
+
baseDir,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether a target produces an HTML file rather than a ZIP archive. */
|
|
79
|
+
export function isHtmlTarget(target) {
|
|
80
|
+
return normalizePackageTarget(target) === "html";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** File extension for a packager target. */
|
|
84
|
+
export function packageExtension(target) {
|
|
85
|
+
return isHtmlTarget(target) ? ".html" : ".zip";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Pick an output path. A directory (existing, or ending in a slash) gets the
|
|
90
|
+
* default filename appended; an extensionless path gets the target extension.
|
|
91
|
+
*
|
|
92
|
+
* @param {{target: string, output?: string|null, name?: string|null, dir?: string}} options
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
export function packageOutputPath(options) {
|
|
96
|
+
const dir = path.resolve(options.dir || process.cwd());
|
|
97
|
+
const extension = packageExtension(options.target);
|
|
98
|
+
const filename = `${slugify(options.name)}${extension}`;
|
|
99
|
+
if (!options.output) return path.join(dir, outputDirectory(), filename);
|
|
100
|
+
|
|
101
|
+
const requested = path.resolve(dir, String(options.output));
|
|
102
|
+
let isDirectory;
|
|
103
|
+
try {
|
|
104
|
+
isDirectory = fs.statSync(requested).isDirectory();
|
|
105
|
+
} catch {
|
|
106
|
+
isDirectory = /[\\/]$/.test(String(options.output));
|
|
107
|
+
}
|
|
108
|
+
if (isDirectory) return path.join(requested, filename);
|
|
109
|
+
if (!path.extname(requested)) return `${requested}${extension}`;
|
|
110
|
+
return requested;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A stable signature of resolved package settings, for dev rebuilds. */
|
|
114
|
+
export function packageSignature(pkg) {
|
|
115
|
+
let signature = JSON.stringify(pkg);
|
|
116
|
+
if (pkg.icon) {
|
|
117
|
+
try {
|
|
118
|
+
const stat = fs.statSync(pkg.icon);
|
|
119
|
+
signature += `:icon:${stat.size}:${stat.mtimeMs}`;
|
|
120
|
+
} catch {
|
|
121
|
+
// The packager will report the missing icon with a better message.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return signature;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Insert custom HTML just before the closing body tag. If a document has no
|
|
129
|
+
* body tag (for example a fragment), append it instead.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} html
|
|
132
|
+
* @param {string} customHtml
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
export function applyCustomHtml(html, customHtml) {
|
|
136
|
+
if (!customHtml) return html;
|
|
137
|
+
const block = `<!-- fx: custom html -->\n${customHtml}\n`;
|
|
138
|
+
if (/<\/body\s*>/i.test(html)) {
|
|
139
|
+
return html.replace(/<\/body\s*>/i, `${block}</body>`);
|
|
140
|
+
}
|
|
141
|
+
return `${html}${html.endsWith("\n") ? "" : "\n"}${block}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Apply custom HTML inside a packaged ZIP archive by replacing its index.html.
|
|
146
|
+
* The packager has no native HTML hook, so this is how desktop and zip targets
|
|
147
|
+
* get the same custom HTML as the plain HTML target.
|
|
148
|
+
*
|
|
149
|
+
* @param {Buffer|Uint8Array} data
|
|
150
|
+
* @param {string} customHtml
|
|
151
|
+
* @returns {Promise<Buffer>}
|
|
152
|
+
*/
|
|
153
|
+
export async function applyCustomHtmlToArchive(data, customHtml) {
|
|
154
|
+
if (!customHtml) return Buffer.from(data);
|
|
155
|
+
const { default: JSZip } = await import("jszip");
|
|
156
|
+
const zip = await JSZip.loadAsync(data);
|
|
157
|
+
const index = zip.file("index.html");
|
|
158
|
+
if (!index) throw new Error("Packaged archive is missing index.html");
|
|
159
|
+
const html = await index.async("string");
|
|
160
|
+
zip.file("index.html", applyCustomHtml(html, customHtml), {
|
|
161
|
+
date: index.date,
|
|
162
|
+
unixPermissions: index.unixPermissions,
|
|
163
|
+
});
|
|
164
|
+
return zip.generateAsync({
|
|
165
|
+
type: "nodebuffer",
|
|
166
|
+
compression: "DEFLATE",
|
|
167
|
+
platform: "UNIX",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Extract a packaged ZIP to a directory, preserving unix execute bits (which
|
|
173
|
+
* desktop builds need for their launchers).
|
|
174
|
+
*
|
|
175
|
+
* @param {Buffer|Uint8Array} data
|
|
176
|
+
* @param {string} destination
|
|
177
|
+
* @returns {Promise<string[]>} written file names
|
|
178
|
+
*/
|
|
179
|
+
export async function extractArchive(data, destination) {
|
|
180
|
+
const { default: JSZip } = await import("jszip");
|
|
181
|
+
const zip = await JSZip.loadAsync(data);
|
|
182
|
+
const root = path.resolve(destination);
|
|
183
|
+
const written = [];
|
|
184
|
+
|
|
185
|
+
for (const [name, entry] of Object.entries(zip.files)) {
|
|
186
|
+
const target = path.resolve(root, name);
|
|
187
|
+
const relative = path.relative(root, target);
|
|
188
|
+
if (relative.startsWith("..") || path.isAbsolute(relative))
|
|
189
|
+
throw new Error(`Refusing to extract unsafe path: ${name}`);
|
|
190
|
+
if (entry.dir) {
|
|
191
|
+
fs.mkdirSync(target, { recursive: true });
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
195
|
+
fs.writeFileSync(target, await entry.async("nodebuffer"));
|
|
196
|
+
if (entry.unixPermissions)
|
|
197
|
+
fs.chmodSync(target, entry.unixPermissions & 0o777);
|
|
198
|
+
written.push(name);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return written;
|
|
202
|
+
}
|
package/src/packager.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin wrapper around the vendored PenguinMod Packager Node API.
|
|
3
|
+
*
|
|
4
|
+
* The packager is copied into `vendor/pm-packager-dist` by
|
|
5
|
+
* `scripts/build-packager.js`. It is a CommonJS UMD bundle, so it is loaded
|
|
6
|
+
* with a dynamic import and validated before use.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
|
+
import { DEFAULT_PACKAGE, normalizePackageTarget } from "./config.js";
|
|
12
|
+
import {
|
|
13
|
+
applyCustomHtml,
|
|
14
|
+
applyCustomHtmlToArchive,
|
|
15
|
+
slugify,
|
|
16
|
+
} from "./package.js";
|
|
17
|
+
|
|
18
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
19
|
+
const DIST = path.join(ROOT, "vendor", "pm-packager-dist", "dist");
|
|
20
|
+
const ENTRY = path.join(DIST, "packager.js");
|
|
21
|
+
|
|
22
|
+
let cached = null;
|
|
23
|
+
|
|
24
|
+
/** Absolute path of the generated packager distribution. */
|
|
25
|
+
export function packagerDistPath() {
|
|
26
|
+
return DIST;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Import the vendored packager, with a useful error when it has not been built.
|
|
31
|
+
* @returns {Promise<{Packager: Function, Image: Function, loadProject: Function}>}
|
|
32
|
+
*/
|
|
33
|
+
export async function loadPackager() {
|
|
34
|
+
if (cached) return cached;
|
|
35
|
+
if (!fs.existsSync(ENTRY)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
"The PenguinMod Packager is not built. Run `npm run build:packager` " +
|
|
38
|
+
"from the Triflare Fx checkout (or reinstall the package).",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const mod = await import(pathToFileURL(ENTRY).href);
|
|
42
|
+
const api = mod.default ?? mod;
|
|
43
|
+
if (
|
|
44
|
+
!api ||
|
|
45
|
+
typeof api.Packager !== "function" ||
|
|
46
|
+
typeof api.loadProject !== "function"
|
|
47
|
+
) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`The PenguinMod Packager at ${ENTRY} did not export the expected API.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
cached = api;
|
|
53
|
+
return cached;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isPlainObject(value) {
|
|
57
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Merge partial packager options without dropping nested defaults. */
|
|
61
|
+
function mergeOptions(target, source) {
|
|
62
|
+
for (const [key, value] of Object.entries(source || {})) {
|
|
63
|
+
if (isPlainObject(value) && isPlainObject(target[key]))
|
|
64
|
+
mergeOptions(target[key], value);
|
|
65
|
+
else target[key] = value;
|
|
66
|
+
}
|
|
67
|
+
return target;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {string} html @param {string} customHtml */
|
|
71
|
+
function htmlWithCustomization(html, customHtml) {
|
|
72
|
+
return applyCustomHtml(html, customHtml);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Package `.pmp` bytes into an HTML file or ZIP archive.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} options
|
|
79
|
+
* @param {Buffer|Uint8Array} options.sb3 Project archive bytes.
|
|
80
|
+
* @param {string} [options.projectName] Used for the window title and package name.
|
|
81
|
+
* @param {ReturnType<import("./package.js").resolvePackageConfig>} [options.packageConfig]
|
|
82
|
+
* @returns {Promise<{
|
|
83
|
+
* data: Buffer,
|
|
84
|
+
* type: "text/html"|"application/zip",
|
|
85
|
+
* filename: string,
|
|
86
|
+
* target: string,
|
|
87
|
+
* html: string|null,
|
|
88
|
+
* }>}
|
|
89
|
+
*/
|
|
90
|
+
export async function packageProject(options = {}) {
|
|
91
|
+
const Packager = await loadPackager();
|
|
92
|
+
const config = options.packageConfig || {
|
|
93
|
+
...DEFAULT_PACKAGE,
|
|
94
|
+
target: "html",
|
|
95
|
+
custom: { ...DEFAULT_PACKAGE.custom },
|
|
96
|
+
options: {},
|
|
97
|
+
};
|
|
98
|
+
const target = normalizePackageTarget(config.target);
|
|
99
|
+
const displayName = config.name || options.projectName || "Packaged Project";
|
|
100
|
+
const packageName = slugify(displayName);
|
|
101
|
+
|
|
102
|
+
const loaded = await Packager.loadProject(
|
|
103
|
+
Buffer.from(options.sb3 ?? []),
|
|
104
|
+
options.onProgress,
|
|
105
|
+
);
|
|
106
|
+
const packager = new Packager.Packager();
|
|
107
|
+
packager.project = loaded;
|
|
108
|
+
|
|
109
|
+
mergeOptions(packager.options, config.options || {});
|
|
110
|
+
packager.options.target = target;
|
|
111
|
+
|
|
112
|
+
// The packager's defaults already fill in app metadata ("Packaged Project"),
|
|
113
|
+
// so only leave those defaults in place when the user set them explicitly.
|
|
114
|
+
const appOptions = config.options?.app || {};
|
|
115
|
+
packager.options.app ||= {};
|
|
116
|
+
if (appOptions.windowTitle === undefined)
|
|
117
|
+
packager.options.app.windowTitle = displayName;
|
|
118
|
+
if (appOptions.packageName === undefined)
|
|
119
|
+
packager.options.app.packageName = packageName;
|
|
120
|
+
if (appOptions.version === undefined)
|
|
121
|
+
packager.options.app.version = config.version || "1.0.0";
|
|
122
|
+
|
|
123
|
+
packager.options.custom ||= {};
|
|
124
|
+
packager.options.custom.js = config.custom?.js || "";
|
|
125
|
+
packager.options.custom.css = config.custom?.css || "";
|
|
126
|
+
|
|
127
|
+
if (config.icon) {
|
|
128
|
+
if (path.extname(config.icon).toLowerCase() !== ".png")
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Packager icons must be PNG files; received ${path.basename(config.icon)}`,
|
|
131
|
+
);
|
|
132
|
+
let icon;
|
|
133
|
+
try {
|
|
134
|
+
icon = fs.readFileSync(config.icon);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Could not read package icon ${config.icon}: ${err.message}`,
|
|
138
|
+
{ cause: err },
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
packager.options.app.icon = new Packager.Image("image/png", icon);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (options.onEvent && typeof packager.addEventListener === "function") {
|
|
145
|
+
for (const [event, listener] of Object.entries(options.onEvent))
|
|
146
|
+
packager.addEventListener(event, listener);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const result = await packager.package();
|
|
150
|
+
const customHtml = config.custom?.html || "";
|
|
151
|
+
const isHtml = result.type === "text/html";
|
|
152
|
+
let data;
|
|
153
|
+
let html = null;
|
|
154
|
+
let type;
|
|
155
|
+
|
|
156
|
+
if (isHtml) {
|
|
157
|
+
html =
|
|
158
|
+
typeof result.data === "string"
|
|
159
|
+
? result.data
|
|
160
|
+
: Buffer.from(result.data).toString("utf8");
|
|
161
|
+
html = htmlWithCustomization(html, customHtml);
|
|
162
|
+
data = Buffer.from(html);
|
|
163
|
+
type = "text/html";
|
|
164
|
+
} else {
|
|
165
|
+
data = Buffer.from(result.data);
|
|
166
|
+
if (customHtml) data = await applyCustomHtmlToArchive(data, customHtml);
|
|
167
|
+
type = "application/zip";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
data,
|
|
172
|
+
type,
|
|
173
|
+
filename: result.filename,
|
|
174
|
+
target,
|
|
175
|
+
html,
|
|
176
|
+
};
|
|
177
|
+
}
|