@triflare/fx 2.1.0-beta.3 → 2.1.0-beta.5
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/package.json +1 -1
- package/product.yml +1 -1
- package/src/catalog.js +914 -0
- package/src/cli/commands/catalog.js +154 -20
- package/src/cli/commands/vendor/outdated.js +1 -1
- package/src/cli/format.js +52 -0
- package/src/cli/reporter.js +34 -18
- package/src/compiler.js +37 -2
- package/src/decompiler.js +27 -0
- package/src/terminal.js +27 -0
- package/src/vendor.js +9 -7
|
@@ -1,15 +1,104 @@
|
|
|
1
|
-
import { CATALOG
|
|
1
|
+
import { CATALOG } from "../../catalog.js";
|
|
2
2
|
import { loadProjectState } from "../context.js";
|
|
3
|
+
import { plural, terminalColumns } from "../format.js";
|
|
4
|
+
|
|
5
|
+
// Language order for the built-in categories; extension groups follow
|
|
6
|
+
// alphabetically so their blocks stay together at the end of the listing.
|
|
7
|
+
const CATEGORY_ORDER = [
|
|
8
|
+
"motion",
|
|
9
|
+
"looks",
|
|
10
|
+
"sound",
|
|
11
|
+
"sensing",
|
|
12
|
+
"operators",
|
|
13
|
+
"control",
|
|
14
|
+
"events",
|
|
15
|
+
"data",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
// One-character kind marker, so a name alone still says how the block is used.
|
|
19
|
+
const KIND_MARKS = {
|
|
20
|
+
command: " ",
|
|
21
|
+
reporter: "→",
|
|
22
|
+
boolean: "?",
|
|
23
|
+
hat: "▸",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** `SCREAMING_SNAKE_CASE` name for a catalog argument. */
|
|
27
|
+
function argumentName(name) {
|
|
28
|
+
return name
|
|
29
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
30
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
|
31
|
+
.toUpperCase();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Positional arguments a block takes, in the order the compiler fills them. */
|
|
35
|
+
function argumentNames(entry) {
|
|
36
|
+
// `pm.*` are argument reporters whose VALUE field is the reporter's own
|
|
37
|
+
// label ("is compiled?"), not a parameter the caller supplies.
|
|
38
|
+
if (entry.opcode?.startsWith("argument_reporter")) return [];
|
|
39
|
+
if (entry.kind === "hat")
|
|
40
|
+
return (entry.args || []).map((arg) => arg.field || arg.input);
|
|
41
|
+
return [
|
|
42
|
+
...Object.keys(entry.fields || {}),
|
|
43
|
+
...Object.entries(entry.inputs || {})
|
|
44
|
+
.filter(([, spec]) => spec.type !== "substack")
|
|
45
|
+
.map(([name]) => name),
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `category.block(ARG, ...)` call shape shown in the listing. */
|
|
50
|
+
function signature(name, entry) {
|
|
51
|
+
const args = argumentNames(entry).map(argumentName);
|
|
52
|
+
return `${name}(${args.join(", ")})`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function mark(entry) {
|
|
56
|
+
return KIND_MARKS[entry.kind] ?? " ";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Group block names by category, in reading order. */
|
|
60
|
+
function groupNames(names) {
|
|
61
|
+
const groups = new Map();
|
|
62
|
+
for (const name of names) {
|
|
63
|
+
const group = name.split(".")[0];
|
|
64
|
+
if (!groups.has(group)) groups.set(group, []);
|
|
65
|
+
groups.get(group).push(name);
|
|
66
|
+
}
|
|
67
|
+
const rank = (group) => {
|
|
68
|
+
const index = CATEGORY_ORDER.indexOf(group);
|
|
69
|
+
return index < 0 ? CATEGORY_ORDER.length : index;
|
|
70
|
+
};
|
|
71
|
+
return new Map(
|
|
72
|
+
[...groups.entries()].sort(
|
|
73
|
+
([left], [right]) =>
|
|
74
|
+
rank(left) - rank(right) || left.localeCompare(right),
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
3
78
|
|
|
4
79
|
export const catalogCommand = {
|
|
5
80
|
name: "catalog",
|
|
6
81
|
aliases: ["l", "blocks"],
|
|
7
82
|
group: "Core Commands:",
|
|
8
83
|
summary: "List available blocks",
|
|
9
|
-
description: `List block names, including blocks from extensions
|
|
10
|
-
project.`,
|
|
84
|
+
description: `List block names grouped by category, including blocks from extensions
|
|
85
|
+
configured in the project.`,
|
|
11
86
|
arguments: ["[dir]"],
|
|
12
|
-
|
|
87
|
+
options: [
|
|
88
|
+
{
|
|
89
|
+
flags: "--category <name>",
|
|
90
|
+
description: "Only list one category, such as motion or pen",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
flags: "--search <text>",
|
|
94
|
+
description: "Only list blocks whose name contains the text",
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
examples: [
|
|
98
|
+
"fx catalog",
|
|
99
|
+
"fx catalog --category motion",
|
|
100
|
+
"fx catalog --search glide",
|
|
101
|
+
],
|
|
13
102
|
async run({ args, cwd, options, reporter, product, prompts }) {
|
|
14
103
|
const state = await loadProjectState({
|
|
15
104
|
input: args[0],
|
|
@@ -19,24 +108,69 @@ project.`,
|
|
|
19
108
|
prompts,
|
|
20
109
|
});
|
|
21
110
|
const merged = { ...CATALOG, ...state.catalog };
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
")
|
|
111
|
+
const category =
|
|
112
|
+
typeof options.category === "string"
|
|
113
|
+
? options.category.toLowerCase()
|
|
114
|
+
: null;
|
|
115
|
+
const search =
|
|
116
|
+
typeof options.search === "string" ? options.search.toLowerCase() : null;
|
|
117
|
+
const names = Object.keys(merged)
|
|
118
|
+
.filter(
|
|
119
|
+
(name) => !category || name.split(".")[0].toLowerCase() === category,
|
|
120
|
+
)
|
|
121
|
+
.filter((name) => !search || name.toLowerCase().includes(search))
|
|
122
|
+
.sort();
|
|
123
|
+
const selected = Object.fromEntries(
|
|
124
|
+
names.map((name) => [name, merged[name]]),
|
|
31
125
|
);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
126
|
+
|
|
127
|
+
if (names.length === 0) {
|
|
128
|
+
const filters = [
|
|
129
|
+
category && `--category "${options.category}"`,
|
|
130
|
+
search && `--search "${options.search}"`,
|
|
131
|
+
]
|
|
132
|
+
.filter(Boolean)
|
|
133
|
+
.join(" and ");
|
|
134
|
+
reporter.info(`No blocks match ${filters}.`);
|
|
135
|
+
reporter.hint(`run "${product.command} catalog" to see every block`);
|
|
136
|
+
return selected;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const groups = groupNames(names);
|
|
140
|
+
let title = `${product.name} catalog — ${plural(names.length, "block")}`;
|
|
141
|
+
if (category) title += ` · ${category}`;
|
|
142
|
+
else if (search) title += ` matching "${options.search}"`;
|
|
143
|
+
else title += ` in ${plural(groups.size, "group")}`;
|
|
144
|
+
reporter.heading(title);
|
|
145
|
+
if (!category && !search)
|
|
146
|
+
reporter.hint("narrow with --category <name> or --search <text>");
|
|
147
|
+
|
|
148
|
+
const width = terminalColumns();
|
|
149
|
+
for (const [group, entries] of groups) {
|
|
150
|
+
reporter.text("");
|
|
151
|
+
reporter.item(group, "", { width: 0, suffix: `· ${entries.length}` });
|
|
152
|
+
if (reporter.verbose) {
|
|
153
|
+
for (const name of entries) {
|
|
154
|
+
const entry = merged[name];
|
|
155
|
+
reporter.item(
|
|
156
|
+
`${mark(entry)} ${signature(name, entry)}`,
|
|
157
|
+
`-> ${entry.opcode} [${entry.kind}]`,
|
|
158
|
+
{
|
|
159
|
+
width: 44,
|
|
160
|
+
suffix: entry.extension ? `(ext ${entry.extension})` : "",
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
reporter.columns(
|
|
166
|
+
entries.map(
|
|
167
|
+
(name) => `${mark(merged[name])} ${signature(name, merged[name])}`,
|
|
168
|
+
),
|
|
169
|
+
{ width },
|
|
170
|
+
);
|
|
171
|
+
}
|
|
38
172
|
}
|
|
39
173
|
|
|
40
|
-
return
|
|
174
|
+
return selected;
|
|
41
175
|
},
|
|
42
176
|
};
|
|
@@ -34,7 +34,7 @@ export const vendorOutdatedCommand = {
|
|
|
34
34
|
: result.status === "error"
|
|
35
35
|
? "danger"
|
|
36
36
|
: "success";
|
|
37
|
-
reporter.item(result.name,
|
|
37
|
+
reporter.item(result.name, label, { width: 24, tone });
|
|
38
38
|
if (result.error) reporter.warn(result.error);
|
|
39
39
|
}
|
|
40
40
|
if (outdated > 0)
|
package/src/cli/format.js
CHANGED
|
@@ -29,3 +29,55 @@ export function truncate(text, max) {
|
|
|
29
29
|
const value = String(text);
|
|
30
30
|
return value.length > max ? `${value.slice(0, max)}…` : value;
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
/** Usable terminal width, clamped so narrow and huge terminals stay readable. */
|
|
34
|
+
export function terminalColumns(
|
|
35
|
+
stream = process.stdout,
|
|
36
|
+
{ fallback = 80, min = 40, max = 120 } = {},
|
|
37
|
+
) {
|
|
38
|
+
const width = stream?.columns;
|
|
39
|
+
if (!Number.isFinite(width) || width <= 0) return fallback;
|
|
40
|
+
return Math.min(max, Math.max(min, width));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Lay items out in aligned columns that fit `width`. Entries wider than one
|
|
45
|
+
* cell get a line of their own, so a single long name does not collapse the
|
|
46
|
+
* whole listing into one column. Trailing whitespace is removed.
|
|
47
|
+
*/
|
|
48
|
+
export function formatColumns(
|
|
49
|
+
items,
|
|
50
|
+
{ width = 80, indent = 2, gap = 2, cellWidth = 38 } = {},
|
|
51
|
+
) {
|
|
52
|
+
if (items.length === 0) return [];
|
|
53
|
+
const available = Math.max(width - indent, 1);
|
|
54
|
+
const columns = Math.max(
|
|
55
|
+
1,
|
|
56
|
+
Math.min(4, Math.floor((available + gap) / (cellWidth + gap))),
|
|
57
|
+
);
|
|
58
|
+
const cell = Math.floor((available - gap * (columns - 1)) / columns);
|
|
59
|
+
const lines = [];
|
|
60
|
+
let row = [];
|
|
61
|
+
|
|
62
|
+
const flush = () => {
|
|
63
|
+
if (row.length === 0) return;
|
|
64
|
+
const cells = row.map((text, index) =>
|
|
65
|
+
index === row.length - 1 ? text : text.padEnd(cell),
|
|
66
|
+
);
|
|
67
|
+
lines.push(`${" ".repeat(indent)}${cells.join(" ".repeat(gap))}`.trimEnd());
|
|
68
|
+
row = [];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
for (const item of items) {
|
|
72
|
+
const text = String(item);
|
|
73
|
+
if (text.length > cell) {
|
|
74
|
+
flush();
|
|
75
|
+
lines.push(`${" ".repeat(indent)}${text}`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
row.push(text);
|
|
79
|
+
if (row.length === columns) flush();
|
|
80
|
+
}
|
|
81
|
+
flush();
|
|
82
|
+
return lines;
|
|
83
|
+
}
|
package/src/cli/reporter.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { styleText } from "node:util";
|
|
10
10
|
import { brandText } from "../brand.js";
|
|
11
|
+
import { sanitizeTerminalText } from "../terminal.js";
|
|
12
|
+
import { formatColumns } from "./format.js";
|
|
11
13
|
import { createTheme } from "./theme.js";
|
|
12
14
|
|
|
13
15
|
const STATUS = {
|
|
@@ -59,7 +61,7 @@ export function createReporter({
|
|
|
59
61
|
const glyph = mark(symbol);
|
|
60
62
|
const stream = type === "success" ? stdout : stderr;
|
|
61
63
|
const theme = themeFor(stream);
|
|
62
|
-
const text =
|
|
64
|
+
const text = sanitizeTerminalText(message);
|
|
63
65
|
const output = text
|
|
64
66
|
.split("\n")
|
|
65
67
|
.map((line, index) => {
|
|
@@ -101,48 +103,61 @@ export function createReporter({
|
|
|
101
103
|
|
|
102
104
|
info(message) {
|
|
103
105
|
if (!human) return;
|
|
104
|
-
stdout.write(`${message}\n`);
|
|
106
|
+
stdout.write(`${sanitizeTerminalText(message)}\n`);
|
|
105
107
|
},
|
|
106
108
|
|
|
107
109
|
bullet(message) {
|
|
108
110
|
if (!human) return;
|
|
109
111
|
const theme = themeFor(stdout);
|
|
110
112
|
const glyph = mark("bullet");
|
|
111
|
-
|
|
113
|
+
const text = sanitizeTerminalText(message);
|
|
114
|
+
stdout.write(` ${glyph ? `${theme.accent(glyph)} ` : ""}${text}\n`);
|
|
112
115
|
},
|
|
113
116
|
|
|
114
117
|
step(message) {
|
|
115
118
|
if (!human) return;
|
|
116
119
|
const theme = themeFor(stdout);
|
|
117
120
|
const glyph = mark("progress");
|
|
118
|
-
|
|
121
|
+
const text = sanitizeTerminalText(message);
|
|
122
|
+
stdout.write(`${glyph ? `${theme.accent(glyph)} ` : ""}${text}\n`);
|
|
119
123
|
},
|
|
120
124
|
|
|
121
125
|
heading(message) {
|
|
122
126
|
if (!human) return;
|
|
123
|
-
stdout.write(
|
|
127
|
+
stdout.write(
|
|
128
|
+
`${themeFor(stdout).heading(sanitizeTerminalText(message))}\n`,
|
|
129
|
+
);
|
|
124
130
|
},
|
|
125
131
|
|
|
126
132
|
/** Aligned `label value` row used by command summaries. */
|
|
127
133
|
detail(label, value, { width = 13 } = {}) {
|
|
128
134
|
if (!human) return;
|
|
129
135
|
const theme = themeFor(stdout);
|
|
130
|
-
const text =
|
|
136
|
+
const text = sanitizeTerminalText(value);
|
|
131
137
|
const rendered = text === "(none)" ? theme.muted(text) : theme.bold(text);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
);
|
|
138
|
+
const name = sanitizeTerminalText(label);
|
|
139
|
+
stdout.write(` ${theme.muted(name.padEnd(width))} ${rendered}\n`);
|
|
135
140
|
},
|
|
136
141
|
|
|
137
142
|
/** Aligned name + description row used by listings. */
|
|
138
|
-
item(label, value = "", { width = 24, suffix = "" } = {}) {
|
|
143
|
+
item(label, value = "", { width = 24, suffix = "", tone } = {}) {
|
|
139
144
|
if (!human) return;
|
|
140
145
|
const theme = themeFor(stdout);
|
|
141
|
-
const text =
|
|
146
|
+
const text = sanitizeTerminalText(label);
|
|
142
147
|
const padded = text.padEnd(Math.max(width, text.length));
|
|
143
|
-
const detail = value ? ` ${value}` : "";
|
|
144
|
-
const note = suffix
|
|
145
|
-
|
|
148
|
+
const detail = value ? ` ${sanitizeTerminalText(value)}` : "";
|
|
149
|
+
const note = suffix
|
|
150
|
+
? ` ${theme.muted(sanitizeTerminalText(suffix))}`
|
|
151
|
+
: "";
|
|
152
|
+
const rendered = tone ? theme.tone(tone, detail) : detail;
|
|
153
|
+
stdout.write(` ${theme.accent(padded)}${rendered}${note}\n`);
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
/** Compact multi-column rows for long block and file listings. */
|
|
157
|
+
columns(items, { width = 80, indent = 2, gap = 2 } = {}) {
|
|
158
|
+
if (!human) return;
|
|
159
|
+
for (const line of formatColumns(items, { width, indent, gap }))
|
|
160
|
+
stdout.write(`${line}\n`);
|
|
146
161
|
},
|
|
147
162
|
|
|
148
163
|
/** Plain block of text with no decoration. */
|
|
@@ -163,7 +178,9 @@ export function createReporter({
|
|
|
163
178
|
/** Quiet, de-emphasized line for incidental notes. */
|
|
164
179
|
note(message) {
|
|
165
180
|
if (!human) return;
|
|
166
|
-
stdout.write(
|
|
181
|
+
stdout.write(
|
|
182
|
+
`${themeFor(stdout).muted(sanitizeTerminalText(message))}\n`,
|
|
183
|
+
);
|
|
167
184
|
},
|
|
168
185
|
|
|
169
186
|
warn(message) {
|
|
@@ -183,9 +200,8 @@ export function createReporter({
|
|
|
183
200
|
hint(message) {
|
|
184
201
|
if (!human) return;
|
|
185
202
|
const theme = themeFor(stdout);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
);
|
|
203
|
+
const text = sanitizeTerminalText(message);
|
|
204
|
+
stdout.write(` ${theme.accent("↳")} ${theme.muted(`hint: ${text}`)}\n`);
|
|
189
205
|
},
|
|
190
206
|
|
|
191
207
|
/** One JSON document, used for the command result envelope. */
|
package/src/compiler.js
CHANGED
|
@@ -45,6 +45,8 @@ const NUMBER_TYPES = {
|
|
|
45
45
|
const TERMINAL_OPCODES = new Set([
|
|
46
46
|
"procedures_return",
|
|
47
47
|
"control_delete_this_clone",
|
|
48
|
+
"control_exitLoop",
|
|
49
|
+
"control_continueLoop",
|
|
48
50
|
]);
|
|
49
51
|
|
|
50
52
|
let seed = 0n;
|
|
@@ -778,6 +780,9 @@ export class Compiler {
|
|
|
778
780
|
if (block.opcode === "procedures_return") return "`return`";
|
|
779
781
|
if (block.opcode === "control_delete_this_clone")
|
|
780
782
|
return "`control.deleteThisClone()`";
|
|
783
|
+
if (block.opcode === "control_exitLoop") return "`control.escapeLoop()`";
|
|
784
|
+
if (block.opcode === "control_continueLoop")
|
|
785
|
+
return "`control.continueLoop()`";
|
|
781
786
|
if (block.opcode === "control_stop") {
|
|
782
787
|
const option = block.fields.STOP_OPTION
|
|
783
788
|
? block.fields.STOP_OPTION[0]
|
|
@@ -1034,14 +1039,17 @@ export class Compiler {
|
|
|
1034
1039
|
*/
|
|
1035
1040
|
emitCall(ctx, node, parentId) {
|
|
1036
1041
|
const segments = node.segments;
|
|
1042
|
+
const entry = this.catalog[node.name];
|
|
1037
1043
|
|
|
1038
1044
|
// List method: `items.push(x)`, `items.at(i)`, ...
|
|
1039
1045
|
if (segments.length === 2) {
|
|
1040
1046
|
const listInfo = ctx.resolveList(segments[0]);
|
|
1041
|
-
|
|
1047
|
+
// Canonical list methods take precedence; aliases resolve in emitListCall
|
|
1048
|
+
// only when the name does not identify a catalog block.
|
|
1049
|
+
if (listInfo && (Object.hasOwn(LIST_OPS, segments[1]) || !entry))
|
|
1050
|
+
return this.emitListCall(ctx, node, listInfo, parentId);
|
|
1042
1051
|
}
|
|
1043
1052
|
|
|
1044
|
-
const entry = this.catalog[node.name];
|
|
1045
1053
|
if (entry) return this.emitCatalogCall(ctx, node, entry, parentId);
|
|
1046
1054
|
|
|
1047
1055
|
if (segments.length === 1 && ctx.funcs.has(node.name)) {
|
|
@@ -1098,6 +1106,33 @@ export class Compiler {
|
|
|
1098
1106
|
for (const name of inputNames) {
|
|
1099
1107
|
const spec = entry.inputs[name];
|
|
1100
1108
|
if (spec.type === "substack") continue;
|
|
1109
|
+
// Variable and list references are stored as fields in Scratch JSON
|
|
1110
|
+
// even though they read as positional arguments in Fx source.
|
|
1111
|
+
if (spec.type === "variable" || spec.type === "list") {
|
|
1112
|
+
if (node.args[argIndex] === undefined)
|
|
1113
|
+
this.fail(
|
|
1114
|
+
`${node.name} requires a ${spec.type} name for ${name}`,
|
|
1115
|
+
node.loc,
|
|
1116
|
+
);
|
|
1117
|
+
const refName = String(this.literalValue(node.args[argIndex]));
|
|
1118
|
+
const resolved =
|
|
1119
|
+
spec.type === "list"
|
|
1120
|
+
? ctx.resolveList(refName)
|
|
1121
|
+
: ctx.resolveValue(refName);
|
|
1122
|
+
if (
|
|
1123
|
+
!resolved ||
|
|
1124
|
+
(spec.type === "list"
|
|
1125
|
+
? resolved.kind !== "list"
|
|
1126
|
+
: resolved.kind !== "var")
|
|
1127
|
+
)
|
|
1128
|
+
this.fail(
|
|
1129
|
+
`Unknown ${spec.type} ${JSON.stringify(refName)} for ${node.name}`,
|
|
1130
|
+
node.loc,
|
|
1131
|
+
);
|
|
1132
|
+
fields[name] = [resolved.name, resolved.id];
|
|
1133
|
+
argIndex += 1;
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1101
1136
|
if (node.args[argIndex] !== undefined) {
|
|
1102
1137
|
inputs[name] = this.buildInputForSpec(spec, node.args[argIndex], id);
|
|
1103
1138
|
argIndex += 1;
|
package/src/decompiler.js
CHANGED
|
@@ -18,7 +18,15 @@ const MENU_OPCODES = new Set([
|
|
|
18
18
|
"looks_backdrops",
|
|
19
19
|
"sound_sounds_menu",
|
|
20
20
|
"control_create_clone_of_menu",
|
|
21
|
+
"control_stop_sprite_menu",
|
|
21
22
|
"sensing_of_object_menu",
|
|
23
|
+
"sensing_fulltouchingobjectmenu",
|
|
24
|
+
"sensing_touchingobjectmenusprites",
|
|
25
|
+
"sensing_scrolldirections",
|
|
26
|
+
"sensing_fingeroptions",
|
|
27
|
+
"looks_changeVisibilityOfSprite_menu",
|
|
28
|
+
"looks_getOtherSpriteVisible_menu",
|
|
29
|
+
"looks_getinput_menu",
|
|
22
30
|
"pen_menu_colorParam",
|
|
23
31
|
]);
|
|
24
32
|
|
|
@@ -50,6 +58,7 @@ const LIST_OPCODE_METHOD = {
|
|
|
50
58
|
};
|
|
51
59
|
|
|
52
60
|
const SAFE_TEMP_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
61
|
+
const SAFE_VAR_REF = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
53
62
|
|
|
54
63
|
function escapeString(value) {
|
|
55
64
|
return String(value)
|
|
@@ -236,6 +245,12 @@ class Decompiler {
|
|
|
236
245
|
}
|
|
237
246
|
for (const [inputName, spec] of Object.entries(entry.inputs || {})) {
|
|
238
247
|
if (spec.type === "substack") continue;
|
|
248
|
+
if (spec.type === "variable" || spec.type === "list") {
|
|
249
|
+
const field = (block.fields || {})[inputName];
|
|
250
|
+
const ref = field ? field[0] : "0";
|
|
251
|
+
args.push(SAFE_VAR_REF.test(ref) ? ref : `"${escapeString(ref)}"`);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
239
254
|
args.push(this.renderExpression(block.inputs[inputName]));
|
|
240
255
|
}
|
|
241
256
|
return `${name}(${args.join(", ")})`;
|
|
@@ -396,6 +411,18 @@ class Decompiler {
|
|
|
396
411
|
option === "timer" ? "timerGreaterThan" : "loudnessGreaterThan";
|
|
397
412
|
return `${pad}when ${kind}(${this.renderExpression(block.inputs.VALUE)}) {\n${body(block.next)}\n}`;
|
|
398
413
|
}
|
|
414
|
+
case "event_always":
|
|
415
|
+
return `${pad}when always {\n${body(block.next)}\n}`;
|
|
416
|
+
case "event_whenanything":
|
|
417
|
+
return `${pad}when whenAnything(${this.renderExpression(block.inputs.ANYTHING)}) {\n${body(block.next)}\n}`;
|
|
418
|
+
case "event_whenjavascript":
|
|
419
|
+
return `${pad}when whenJavaScript(${this.renderExpression(block.inputs.JS)}) {\n${body(block.next)}\n}`;
|
|
420
|
+
case "event_whenkeyhit":
|
|
421
|
+
return `${pad}when whenKeyHit("${escapeString((block.fields.KEY_OPTION || ["space"])[0])}") {\n${body(block.next)}\n}`;
|
|
422
|
+
case "event_whenmousescrolled":
|
|
423
|
+
return `${pad}when whenMouseScrolled("${escapeString((block.fields.KEY_OPTION || ["up"])[0])}") {\n${body(block.next)}\n}`;
|
|
424
|
+
case "event_whenstopclicked":
|
|
425
|
+
return `${pad}when whenStopClicked {\n${body(block.next)}\n}`;
|
|
399
426
|
default:
|
|
400
427
|
return this.renderExtensionHat(block, pad, body);
|
|
401
428
|
}
|
package/src/terminal.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal output safety.
|
|
3
|
+
*
|
|
4
|
+
* Names, paths and source text can come from a repository the user did not
|
|
5
|
+
* write, so untrusted text is rendered with visible `\xNN` escapes instead of
|
|
6
|
+
* letting the terminal interpret control characters and escape sequences.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Replace terminal control characters with visible `\xNN` escapes.
|
|
11
|
+
*
|
|
12
|
+
* Tabs and newlines are kept because callers print multi-line blocks.
|
|
13
|
+
* @param {unknown} value
|
|
14
|
+
*/
|
|
15
|
+
export function sanitizeTerminalText(value) {
|
|
16
|
+
let clean = "";
|
|
17
|
+
for (const char of String(value)) {
|
|
18
|
+
const code = char.codePointAt(0);
|
|
19
|
+
const printable =
|
|
20
|
+
code === 0x09 ||
|
|
21
|
+
code === 0x0a ||
|
|
22
|
+
(code >= 0x20 && code <= 0x7e) ||
|
|
23
|
+
code >= 0xa0;
|
|
24
|
+
clean += printable ? char : `\\x${code.toString(16).padStart(2, "0")}`;
|
|
25
|
+
}
|
|
26
|
+
return clean;
|
|
27
|
+
}
|
package/src/vendor.js
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
readExtensionDataUri,
|
|
24
24
|
resolveSource,
|
|
25
25
|
} from "./extensions.js";
|
|
26
|
+
import { sanitizeTerminalText } from "./terminal.js";
|
|
26
27
|
|
|
27
28
|
export const LOCK_FILENAME = "fx.lock";
|
|
28
29
|
export const LOCK_VERSION = 1;
|
|
@@ -1146,13 +1147,14 @@ export async function loadVendorExtensions({
|
|
|
1146
1147
|
|
|
1147
1148
|
/** A compact line diff, enough to review an extension or config change. */
|
|
1148
1149
|
export function formatDiff(before, after, filename = "") {
|
|
1150
|
+
const header = sanitizeTerminalText(filename);
|
|
1149
1151
|
const a = String(before).split(/\r?\n/);
|
|
1150
1152
|
const b = String(after).split(/\r?\n/);
|
|
1151
1153
|
const rows = a.length;
|
|
1152
1154
|
const columns = b.length;
|
|
1153
1155
|
// Keep very large extensions from allocating an enormous matrix.
|
|
1154
1156
|
if (rows * columns > 4_000_000) {
|
|
1155
|
-
return `--- ${
|
|
1157
|
+
return `--- ${header}\n+++ ${header}\n@@ source changed @@\n`;
|
|
1156
1158
|
}
|
|
1157
1159
|
const table = Array.from(
|
|
1158
1160
|
{ length: rows + 1 },
|
|
@@ -1166,24 +1168,24 @@ export function formatDiff(before, after, filename = "") {
|
|
|
1166
1168
|
: Math.max(table[i + 1][j], table[i][j + 1]);
|
|
1167
1169
|
}
|
|
1168
1170
|
}
|
|
1169
|
-
const lines = [`--- ${
|
|
1171
|
+
const lines = [`--- ${header}`, `+++ ${header}`];
|
|
1170
1172
|
let i = 0;
|
|
1171
1173
|
let j = 0;
|
|
1172
1174
|
while (i < rows && j < columns) {
|
|
1173
1175
|
if (a[i] === b[j]) {
|
|
1174
|
-
lines.push(` ${a[i]}`);
|
|
1176
|
+
lines.push(` ${sanitizeTerminalText(a[i])}`);
|
|
1175
1177
|
i += 1;
|
|
1176
1178
|
j += 1;
|
|
1177
1179
|
} else if (table[i + 1][j] >= table[i][j + 1]) {
|
|
1178
|
-
lines.push(`-${a[i]}`);
|
|
1180
|
+
lines.push(`-${sanitizeTerminalText(a[i])}`);
|
|
1179
1181
|
i += 1;
|
|
1180
1182
|
} else {
|
|
1181
|
-
lines.push(`+${b[j]}`);
|
|
1183
|
+
lines.push(`+${sanitizeTerminalText(b[j])}`);
|
|
1182
1184
|
j += 1;
|
|
1183
1185
|
}
|
|
1184
1186
|
}
|
|
1185
|
-
while (i < rows) lines.push(`-${a[i++]}`);
|
|
1186
|
-
while (j < columns) lines.push(`+${b[j++]}`);
|
|
1187
|
+
while (i < rows) lines.push(`-${sanitizeTerminalText(a[i++])}`);
|
|
1188
|
+
while (j < columns) lines.push(`+${sanitizeTerminalText(b[j++])}`);
|
|
1187
1189
|
return `${lines.join("\n")}\n`;
|
|
1188
1190
|
}
|
|
1189
1191
|
|