@triflare/fx 1.0.2 → 2.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.
- package/README.md +32 -0
- package/bin/fx.js +4 -1160
- package/package.json +9 -3
- package/product.yml +1 -1
- package/src/brand.js +9 -3
- package/src/build.js +2 -1
- package/src/catalog.js +117 -140
- package/src/cli/commands/build.js +98 -0
- package/src/cli/commands/catalog.js +42 -0
- package/src/cli/commands/check.js +44 -0
- package/src/cli/commands/completion.js +54 -0
- package/src/cli/commands/decompile.js +266 -0
- package/src/cli/commands/dev.js +179 -0
- package/src/cli/commands/extension.js +110 -0
- package/src/cli/commands/index.js +39 -0
- package/src/cli/commands/init.js +169 -0
- package/src/cli/commands/package.js +137 -0
- package/src/cli/commands/sprite/add.js +85 -0
- package/src/cli/commands/sprite/build.js +62 -0
- package/src/cli/commands/sprite/helpers.js +66 -0
- package/src/cli/commands/sprite/index.js +29 -0
- package/src/cli/commands/sprite/list.js +31 -0
- package/src/cli/commands/sprite/remove.js +51 -0
- package/src/cli/commands/vendor/add.js +98 -0
- package/src/cli/commands/vendor/helpers.js +50 -0
- package/src/cli/commands/vendor/index.js +39 -0
- package/src/cli/commands/vendor/install.js +64 -0
- package/src/cli/commands/vendor/list.js +45 -0
- package/src/cli/commands/vendor/migrate.js +47 -0
- package/src/cli/commands/vendor/outdated.js +51 -0
- package/src/cli/commands/vendor/remove.js +34 -0
- package/src/cli/commands/vendor/update.js +49 -0
- package/src/cli/commands/vendor/verify.js +44 -0
- package/src/cli/completion/bash.js +10 -0
- package/src/cli/completion/fish.js +9 -0
- package/src/cli/completion/index.js +97 -0
- package/src/cli/completion/zsh.js +11 -0
- package/src/cli/context.js +257 -0
- package/src/cli/define-command.js +140 -0
- package/src/cli/errors.js +111 -0
- package/src/cli/format.js +31 -0
- package/src/cli/prompts.js +183 -0
- package/src/cli/reporter.js +194 -0
- package/src/cli/run.js +248 -0
- package/src/cli/templates/index.js +112 -0
- package/src/cli/theme.js +84 -0
- package/src/compiler.js +32 -3
- package/src/config-edit.js +280 -1
- package/src/config.js +137 -3
- package/src/decompiler.js +12 -2
- package/src/extensions.js +311 -48
- package/src/index.js +21 -0
- package/src/vendor.js +1323 -0
- package/vendor/pm-packager-dist/.fx-build.json +1 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffolding templates used by `fx init`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
configFilename,
|
|
7
|
+
sourceDirectory,
|
|
8
|
+
sourceExtension,
|
|
9
|
+
} from "../../brand.js";
|
|
10
|
+
|
|
11
|
+
const CAT_ASSET = `<svg xmlns="http://www.w3.org/2000/svg" width="95" height="101" viewBox="0 0 95 101">
|
|
12
|
+
<ellipse cx="47" cy="60" rx="35" ry="35" fill="#ffab19" stroke="#cf8b17" stroke-width="4"/>
|
|
13
|
+
<circle cx="35" cy="50" r="6" fill="#000"/>
|
|
14
|
+
<circle cx="60" cy="50" r="6" fill="#000"/>
|
|
15
|
+
</svg>
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
function configText({ name, width, height, target }) {
|
|
19
|
+
const lines = [
|
|
20
|
+
"project:",
|
|
21
|
+
` name: ${JSON.stringify(name)}`,
|
|
22
|
+
" fps: 60",
|
|
23
|
+
` width: ${width}`,
|
|
24
|
+
` height: ${height}`,
|
|
25
|
+
" turbo: false",
|
|
26
|
+
" interpolation: true",
|
|
27
|
+
"fx:",
|
|
28
|
+
" partialSprites: false",
|
|
29
|
+
];
|
|
30
|
+
if (target !== "none") {
|
|
31
|
+
lines.push("package:", ` target: ${target}`);
|
|
32
|
+
}
|
|
33
|
+
return `${lines.join("\n")}\n`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const CAT_SOURCE = `sprite "Cat" {
|
|
37
|
+
costumes: {
|
|
38
|
+
cat: "../assets/cat.svg"
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
let score = 0;
|
|
42
|
+
|
|
43
|
+
when greenFlag {
|
|
44
|
+
motion.goToXY(0, 0);
|
|
45
|
+
control.forever {
|
|
46
|
+
motion.turnRight(1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
when keyPressed("space") {
|
|
51
|
+
score += 1;
|
|
52
|
+
looks.sayForSecs(operators.join("Score: ", score), 1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
const EMPTY_SOURCE = `// Your project starts here.
|
|
58
|
+
when greenFlag {
|
|
59
|
+
looks.say("Hello, world!");
|
|
60
|
+
}
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const BLANK_SOURCE = `// Your project starts here.
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the file map for an init run.
|
|
68
|
+
*
|
|
69
|
+
* @param {{
|
|
70
|
+
* product: object,
|
|
71
|
+
* name: string,
|
|
72
|
+
* template: "cat"|"empty"|"blank",
|
|
73
|
+
* width: number,
|
|
74
|
+
* height: number,
|
|
75
|
+
* target: string,
|
|
76
|
+
* }} options
|
|
77
|
+
*/
|
|
78
|
+
export function initFiles({ product, name, template, width, height, target }) {
|
|
79
|
+
const files = {
|
|
80
|
+
[configFilename(product)]: configText({ name, width, height, target }),
|
|
81
|
+
[`${sourceDirectory(product)}/main${sourceExtension(product)}`]:
|
|
82
|
+
template === "cat"
|
|
83
|
+
? CAT_SOURCE
|
|
84
|
+
: template === "empty"
|
|
85
|
+
? EMPTY_SOURCE
|
|
86
|
+
: BLANK_SOURCE,
|
|
87
|
+
};
|
|
88
|
+
if (template === "cat") files["assets/cat.svg"] = CAT_ASSET;
|
|
89
|
+
return files;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const TEMPLATES = [
|
|
93
|
+
{
|
|
94
|
+
value: "cat",
|
|
95
|
+
label: "Cat demo",
|
|
96
|
+
hint: "sprite, costumes, events, variables",
|
|
97
|
+
},
|
|
98
|
+
{ value: "empty", label: "Empty project", hint: "one green-flag script" },
|
|
99
|
+
{ value: "blank", label: "Blank", hint: "just the config and a source file" },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
export const TARGETS = [
|
|
103
|
+
{ value: "none", label: "None", hint: "source only" },
|
|
104
|
+
{ value: "html", label: "HTML", hint: "single-file web build" },
|
|
105
|
+
{ value: "zip", label: "ZIP", hint: "packaged archive" },
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
export const STAGE_SIZES = [
|
|
109
|
+
{ value: "480x360", label: "480 × 360", hint: "Scratch default" },
|
|
110
|
+
{ value: "640x360", label: "640 × 360", hint: "wide" },
|
|
111
|
+
{ value: "1920x1080", label: "1920 × 1080", hint: "full HD" },
|
|
112
|
+
];
|
package/src/cli/theme.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal theme shared by help output and the reporter.
|
|
3
|
+
*
|
|
4
|
+
* Colors are derived from the product palette so a rebrand reaches the CLI
|
|
5
|
+
* too. Everything falls back to plain text when the stream cannot show color.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { styleText } from "node:util";
|
|
9
|
+
import { brandingColors } from "../brand.js";
|
|
10
|
+
|
|
11
|
+
const STATUS_COLORS = {
|
|
12
|
+
success: "#34D399",
|
|
13
|
+
warning: "#FBBF24",
|
|
14
|
+
danger: "#F87171",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function rgb(hex) {
|
|
18
|
+
const digits =
|
|
19
|
+
hex.length === 4 ? hex.slice(1).replace(/./g, "$&$&") : hex.slice(1);
|
|
20
|
+
return digits.match(/../g).map((part) => parseInt(part, 16));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function mix(from, to, amount) {
|
|
24
|
+
const [r1, g1, b1] = rgb(from);
|
|
25
|
+
const [r2, g2, b2] = rgb(to);
|
|
26
|
+
const channel = (a, b) => Math.round(a + (b - a) * amount);
|
|
27
|
+
return [channel(r1, r2), channel(g1, g2), channel(b1, b2)];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function supportsColor(stream) {
|
|
31
|
+
if (!stream?.isTTY) return false;
|
|
32
|
+
if (process.env.NO_COLOR) return false;
|
|
33
|
+
if (process.env.TERM === "dumb") return false;
|
|
34
|
+
return styleText("red", "x", { stream }).includes("\x1b");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {{ product?: object, color?: "auto"|"always"|"never", stream?: NodeJS.WriteStream }} [options]
|
|
39
|
+
*/
|
|
40
|
+
export function createTheme({
|
|
41
|
+
product,
|
|
42
|
+
color = "auto",
|
|
43
|
+
stream = process.stdout,
|
|
44
|
+
} = {}) {
|
|
45
|
+
const palette = brandingColors(product);
|
|
46
|
+
const enabled =
|
|
47
|
+
color === "always" || (color !== "never" && supportsColor(stream));
|
|
48
|
+
|
|
49
|
+
const paint = (text, codes) => {
|
|
50
|
+
if (!enabled || codes.length === 0) return String(text);
|
|
51
|
+
return `\x1b[${codes.join(";")}m${text}\x1b[0m`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const foreground = (colorValue) => {
|
|
55
|
+
const [r, g, b] = Array.isArray(colorValue) ? colorValue : rgb(colorValue);
|
|
56
|
+
return `38;2;${r};${g};${b}`;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const command = mix(palette.primary, "#FFFFFF", 0.35);
|
|
60
|
+
const heading = mix(palette.primary, "#FFFFFF", 0.2);
|
|
61
|
+
const option = mix(palette.primary, "#FFFFFF", 0.58);
|
|
62
|
+
const argument = mix(palette.primary, "#FFFFFF", 0.74);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
enabled,
|
|
66
|
+
stream,
|
|
67
|
+
|
|
68
|
+
accent: (text) => paint(text, [foreground(palette.primary)]),
|
|
69
|
+
command: (text) => paint(text, ["1", foreground(command)]),
|
|
70
|
+
heading: (text) => paint(text, ["1", foreground(heading)]),
|
|
71
|
+
option: (text) => paint(text, [foreground(option)]),
|
|
72
|
+
argument: (text) => paint(text, [foreground(argument)]),
|
|
73
|
+
muted: (text) => paint(text, ["2"]),
|
|
74
|
+
bold: (text) => paint(text, ["1"]),
|
|
75
|
+
success: (text) => paint(text, [foreground(STATUS_COLORS.success)]),
|
|
76
|
+
warning: (text) => paint(text, [foreground(STATUS_COLORS.warning)]),
|
|
77
|
+
danger: (text) => paint(text, [foreground(STATUS_COLORS.danger)]),
|
|
78
|
+
|
|
79
|
+
tone(name, text) {
|
|
80
|
+
const value = STATUS_COLORS[name] ?? palette.primary;
|
|
81
|
+
return paint(text, [foreground(value)]);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
package/src/compiler.js
CHANGED
|
@@ -25,6 +25,11 @@ const HAT_ALIASES = {
|
|
|
25
25
|
timerGreaterThan: "events.timerGreaterThan",
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
// `dataFormat` values Scratch 3 can load (see the `VM.Costume` / `VM.Sound`
|
|
29
|
+
// types). Anything else is still written through, but may not load.
|
|
30
|
+
const COSTUME_FORMATS = new Set(["svg", "png", "jpg"]);
|
|
31
|
+
const SOUND_FORMATS = new Set(["wav", "mp3"]);
|
|
32
|
+
|
|
28
33
|
const NUMBER_TYPES = {
|
|
29
34
|
number: 4,
|
|
30
35
|
positive: 5,
|
|
@@ -133,6 +138,8 @@ export class Compiler {
|
|
|
133
138
|
this.catalog = catalog ? { ...CATALOG, ...catalog } : CATALOG;
|
|
134
139
|
this.extensionUrls = extensionUrls;
|
|
135
140
|
this.file = file;
|
|
141
|
+
/** @type {string[]} Non-fatal problems worth surfacing to the user. */
|
|
142
|
+
this.warnings = [];
|
|
136
143
|
|
|
137
144
|
// opcode -> extension id, derived from the (merged) catalog. This lets
|
|
138
145
|
// synthesized extension blocks declare an id that isn't inferable from
|
|
@@ -463,12 +470,20 @@ export class Compiler {
|
|
|
463
470
|
applyCostumes(ctx, property) {
|
|
464
471
|
for (const entry of property.value) {
|
|
465
472
|
const format = detectFormat(entry.value);
|
|
473
|
+
if (!COSTUME_FORMATS.has(format))
|
|
474
|
+
this.warnings.push(
|
|
475
|
+
`costume ${JSON.stringify(entry.key)} uses dataFormat ` +
|
|
476
|
+
`"${format}"; Scratch 3 costumes are svg, png or jpg`,
|
|
477
|
+
);
|
|
466
478
|
const buffer = this.readAsset(entry.value, entry.loc);
|
|
467
479
|
const { width, height } = imageSize(buffer, format);
|
|
468
480
|
const asset = this.registerAsset(buffer, format);
|
|
469
481
|
ctx.target.costumes.push({
|
|
470
482
|
name: entry.key,
|
|
471
|
-
|
|
483
|
+
// Scratch 3 saves bitmap costumes at 2x; the VM converts a 1 to 2 on
|
|
484
|
+
// load anyway (`import/load-costume.js`), and the renderer's
|
|
485
|
+
// BitmapSkin defaults to 2. SVGs are always 1.
|
|
486
|
+
bitmapResolution: format === "svg" ? 1 : 2,
|
|
472
487
|
dataFormat: format,
|
|
473
488
|
assetId: asset.assetId,
|
|
474
489
|
md5ext: asset.md5ext,
|
|
@@ -481,6 +496,11 @@ export class Compiler {
|
|
|
481
496
|
applySounds(ctx, property) {
|
|
482
497
|
for (const entry of property.value) {
|
|
483
498
|
const format = detectFormat(entry.value);
|
|
499
|
+
if (!SOUND_FORMATS.has(format))
|
|
500
|
+
this.warnings.push(
|
|
501
|
+
`sound ${JSON.stringify(entry.key)} uses dataFormat ` +
|
|
502
|
+
`"${format}"; Scratch 3 sounds are wav or mp3`,
|
|
503
|
+
);
|
|
484
504
|
const buffer = this.readAsset(entry.value, entry.loc);
|
|
485
505
|
const info =
|
|
486
506
|
format === "wav" ? wavInfo(buffer) : { rate: 48000, sampleCount: 0 };
|
|
@@ -577,7 +597,7 @@ export class Compiler {
|
|
|
577
597
|
}
|
|
578
598
|
} else if (spec.input) {
|
|
579
599
|
inputs[spec.input] = this.buildInputForSpec(
|
|
580
|
-
{ type: spec.type || "any" },
|
|
600
|
+
{ type: spec.type || "any", default: spec.default },
|
|
581
601
|
argExpr,
|
|
582
602
|
null,
|
|
583
603
|
);
|
|
@@ -1110,7 +1130,16 @@ export class Compiler {
|
|
|
1110
1130
|
}
|
|
1111
1131
|
|
|
1112
1132
|
defaultPrim(spec) {
|
|
1113
|
-
|
|
1133
|
+
const prim = this.defaultPrimForType(spec.type);
|
|
1134
|
+
// Extensions declare the value a call falls back to when it omits an
|
|
1135
|
+
// argument; built-in catalog entries never set `default`.
|
|
1136
|
+
if (spec.default !== undefined && spec.default !== null)
|
|
1137
|
+
prim[1] = String(spec.default);
|
|
1138
|
+
return prim;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
defaultPrimForType(type) {
|
|
1142
|
+
switch (type) {
|
|
1114
1143
|
case "number":
|
|
1115
1144
|
return [4, "0"];
|
|
1116
1145
|
case "positive":
|
package/src/config-edit.js
CHANGED
|
@@ -54,7 +54,8 @@ function childKey(lines, start, end, key) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
function childIndent(lines, start, end) {
|
|
57
|
-
|
|
57
|
+
const limit = Math.min(end, lines.length);
|
|
58
|
+
for (let index = start + 1; index < limit; index++) {
|
|
58
59
|
const match = /^([ \t]+)\S/.exec(content(lines[index]));
|
|
59
60
|
if (match) return match[1];
|
|
60
61
|
}
|
|
@@ -336,3 +337,281 @@ export function removeProjectExtension(configPath, target) {
|
|
|
336
337
|
unsandboxedRemoved: unsandboxedRemoved.length > 0,
|
|
337
338
|
};
|
|
338
339
|
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Remove `project.extensions` entirely (used by migration).
|
|
343
|
+
*
|
|
344
|
+
* Unlike {@link removeProjectExtension}, this drops the key itself instead of
|
|
345
|
+
* leaving an empty list, so a fully migrated file has no legacy section left.
|
|
346
|
+
*/
|
|
347
|
+
export function removeProjectExtensions(configPath) {
|
|
348
|
+
const text = fs.readFileSync(configPath, "utf8");
|
|
349
|
+
const lines = splitLines(text);
|
|
350
|
+
const project = topLevel(lines, "project");
|
|
351
|
+
if (!project) return [];
|
|
352
|
+
const { value: rest, comment } = splitComment(project.rest);
|
|
353
|
+
|
|
354
|
+
if (rest.startsWith("{")) {
|
|
355
|
+
if (!rest.endsWith("}"))
|
|
356
|
+
throw new Error("project must be a mapping to edit it");
|
|
357
|
+
const mapping = parseValue(rest);
|
|
358
|
+
if (!mapping || typeof mapping !== "object" || Array.isArray(mapping))
|
|
359
|
+
throw new Error("project must be a mapping to edit it");
|
|
360
|
+
if (!Object.hasOwn(mapping, "extensions")) return [];
|
|
361
|
+
|
|
362
|
+
const removed = stringList(mapping.extensions);
|
|
363
|
+
delete mapping.extensions;
|
|
364
|
+
const line = lines[project.index];
|
|
365
|
+
lines[project.index] =
|
|
366
|
+
`project: ${JSON.stringify(mapping)}` +
|
|
367
|
+
`${comment ? ` ${comment}` : ""}${ending(line) || eolOf(text)}`;
|
|
368
|
+
writeDocument(configPath, lines.join(""));
|
|
369
|
+
return removed;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const bounds = projectBounds(lines);
|
|
373
|
+
if (!bounds) return [];
|
|
374
|
+
const list = readList(lines, "extensions", bounds.index, bounds.end);
|
|
375
|
+
if (!list) return [];
|
|
376
|
+
|
|
377
|
+
const removed = [...list.values];
|
|
378
|
+
if (list.style === "block") {
|
|
379
|
+
const end = Math.max(list.keyLine.index, list.lastLine) + 1;
|
|
380
|
+
lines.splice(list.keyLine.index, end - list.keyLine.index);
|
|
381
|
+
} else {
|
|
382
|
+
lines.splice(list.keyLine.index, 1);
|
|
383
|
+
}
|
|
384
|
+
writeDocument(configPath, lines.join(""));
|
|
385
|
+
return removed;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// --------------------------------------------------------------- vendor edits
|
|
389
|
+
|
|
390
|
+
const SAFE_KEY = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
|
|
391
|
+
|
|
392
|
+
/** Quote a mapping key when YAML would otherwise parse it as non-text. */
|
|
393
|
+
function quoteKey(key) {
|
|
394
|
+
// YAML 1.1 treats bare yes/no/on/off/null as non-strings.
|
|
395
|
+
return SAFE_KEY.test(key) &&
|
|
396
|
+
!/^(?:y|yes|n|no|on|off|null|true|false)$/i.test(key)
|
|
397
|
+
? key
|
|
398
|
+
: JSON.stringify(String(key));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Serialize one vendor entry, expanding options only when needed. */
|
|
402
|
+
function vendorEntryLines(name, source, options, indent, eol) {
|
|
403
|
+
const extra = [];
|
|
404
|
+
if (options.unsandboxed) extra.push(`${indent} unsandboxed: true${eol}`);
|
|
405
|
+
if (options.volatile !== undefined)
|
|
406
|
+
extra.push(`${indent} volatile: ${Boolean(options.volatile)}${eol}`);
|
|
407
|
+
if (extra.length === 0)
|
|
408
|
+
return [`${indent}${quoteKey(name)}: ${quote(source)}${eol}`];
|
|
409
|
+
return [
|
|
410
|
+
`${indent}${quoteKey(name)}:${eol}`,
|
|
411
|
+
`${indent} url: ${quote(source)}${eol}`,
|
|
412
|
+
...extra,
|
|
413
|
+
];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Locate a top-level mapping, accepting an inline flow mapping. */
|
|
417
|
+
function topLevelBounds(lines, key) {
|
|
418
|
+
const found = topLevel(lines, key);
|
|
419
|
+
if (!found) return null;
|
|
420
|
+
const { value: rest, comment } = splitComment(found.rest);
|
|
421
|
+
if (rest !== "") {
|
|
422
|
+
// A flow mapping can be expanded losslessly (comments on the line aside).
|
|
423
|
+
if (rest.startsWith("{") && rest.endsWith("}")) {
|
|
424
|
+
const mapping = parseValue(rest);
|
|
425
|
+
if (mapping && typeof mapping === "object" && !Array.isArray(mapping))
|
|
426
|
+
return {
|
|
427
|
+
index: found.index,
|
|
428
|
+
end: found.index + 1,
|
|
429
|
+
inline: rest,
|
|
430
|
+
comment,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
throw new Error(`${key} must be a block mapping to edit it`);
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
index: found.index,
|
|
437
|
+
end: sectionEnd(lines, found.index),
|
|
438
|
+
inline: null,
|
|
439
|
+
comment,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Find direct child entries and their line ranges in a mapping. */
|
|
444
|
+
function mappingEntrySpans(lines, start, end) {
|
|
445
|
+
const indent = childIndent(lines, start, end);
|
|
446
|
+
const spans = [];
|
|
447
|
+
const limit = Math.min(end, lines.length);
|
|
448
|
+
for (let index = start + 1; index < limit; index++) {
|
|
449
|
+
const text = content(lines[index]);
|
|
450
|
+
if (text.trim() === "" || /^[ \t]*#/.test(text)) continue;
|
|
451
|
+
const outer = /^([ \t]+)(.*)$/.exec(text);
|
|
452
|
+
if (!outer || outer[1] !== indent) continue;
|
|
453
|
+
const match = /^(.+?)[ \t]*:[ \t]*(.*)$/.exec(outer[2]);
|
|
454
|
+
if (!match) continue;
|
|
455
|
+
let key;
|
|
456
|
+
try {
|
|
457
|
+
const parsed = yaml.load(`${match[1]}: null`);
|
|
458
|
+
key =
|
|
459
|
+
parsed && typeof parsed === "object" ? Object.keys(parsed)[0] : null;
|
|
460
|
+
} catch {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (key == null) continue;
|
|
464
|
+
spans.push({ index, indent, key: String(key), rest: match[2] });
|
|
465
|
+
}
|
|
466
|
+
for (let index = 0; index < spans.length; index++)
|
|
467
|
+
spans[index].end = index + 1 < spans.length ? spans[index + 1].index : end;
|
|
468
|
+
return spans;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Replace a half-open line range in place. */
|
|
472
|
+
function replaceRange(lines, start, end, added) {
|
|
473
|
+
lines.splice(start, end - start, ...added);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Add or replace a `vendor:` entry without reformatting the rest of fx.yml.
|
|
478
|
+
*
|
|
479
|
+
* @param {string} configPath
|
|
480
|
+
* @param {string} name
|
|
481
|
+
* @param {string} source
|
|
482
|
+
* @param {{unsandboxed?: boolean, volatile?: boolean}} [options]
|
|
483
|
+
* @returns {{added: boolean, updated: boolean}}
|
|
484
|
+
*/
|
|
485
|
+
export function addVendorEntry(configPath, name, source, options = {}) {
|
|
486
|
+
const text = fs.readFileSync(configPath, "utf8");
|
|
487
|
+
const lines = splitLines(text);
|
|
488
|
+
const eol = eolOf(text);
|
|
489
|
+
const key = String(name);
|
|
490
|
+
let added = false;
|
|
491
|
+
let updated = false;
|
|
492
|
+
|
|
493
|
+
const bounds = topLevelBounds(lines, "vendor");
|
|
494
|
+
if (!bounds) {
|
|
495
|
+
if (lines.length > 0 && !lines[lines.length - 1].endsWith("\n"))
|
|
496
|
+
lines[lines.length - 1] += eol;
|
|
497
|
+
lines.push(
|
|
498
|
+
`vendor:${eol}`,
|
|
499
|
+
...vendorEntryLines(key, source, options, " ", eol),
|
|
500
|
+
);
|
|
501
|
+
added = true;
|
|
502
|
+
} else if (bounds.inline) {
|
|
503
|
+
const mapping = parseValue(bounds.inline) || {};
|
|
504
|
+
updated = Object.hasOwn(mapping, key);
|
|
505
|
+
added = !updated;
|
|
506
|
+
mapping[key] =
|
|
507
|
+
options.unsandboxed || options.volatile !== undefined
|
|
508
|
+
? {
|
|
509
|
+
url: source,
|
|
510
|
+
...(options.unsandboxed ? { unsandboxed: true } : {}),
|
|
511
|
+
...(options.volatile !== undefined
|
|
512
|
+
? { volatile: Boolean(options.volatile) }
|
|
513
|
+
: {}),
|
|
514
|
+
}
|
|
515
|
+
: source;
|
|
516
|
+
const dumped = yaml
|
|
517
|
+
.dump({ vendor: mapping }, { lineWidth: -1, noRefs: true })
|
|
518
|
+
.split("\n")
|
|
519
|
+
.filter((line, index, all) => !(index === all.length - 1 && line === ""))
|
|
520
|
+
.map((line) => `${line}${eol}`);
|
|
521
|
+
if (bounds.comment) dumped[0] = `vendor: ${bounds.comment}${eol}`;
|
|
522
|
+
replaceRange(lines, bounds.index, bounds.index + 1, dumped);
|
|
523
|
+
} else {
|
|
524
|
+
const spans = mappingEntrySpans(lines, bounds.index, bounds.end);
|
|
525
|
+
const existing = spans.find((span) => span.key === key);
|
|
526
|
+
if (existing) {
|
|
527
|
+
replaceRange(
|
|
528
|
+
lines,
|
|
529
|
+
existing.index,
|
|
530
|
+
existing.end,
|
|
531
|
+
vendorEntryLines(key, source, options, existing.indent, eol),
|
|
532
|
+
);
|
|
533
|
+
updated = true;
|
|
534
|
+
} else {
|
|
535
|
+
const indent = childIndent(lines, bounds.index, bounds.end);
|
|
536
|
+
insertAt(
|
|
537
|
+
lines,
|
|
538
|
+
bounds.end,
|
|
539
|
+
vendorEntryLines(key, source, options, indent, eol),
|
|
540
|
+
eol,
|
|
541
|
+
);
|
|
542
|
+
added = true;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (added || updated) writeDocument(configPath, lines.join(""));
|
|
547
|
+
return { added, updated };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Remove a `vendor:` entry.
|
|
552
|
+
* @param {string} configPath
|
|
553
|
+
* @param {string} name
|
|
554
|
+
* @returns {{removed: string|null}}
|
|
555
|
+
*/
|
|
556
|
+
export function removeVendorEntry(configPath, name) {
|
|
557
|
+
const text = fs.readFileSync(configPath, "utf8");
|
|
558
|
+
const lines = splitLines(text);
|
|
559
|
+
const eol = eolOf(text);
|
|
560
|
+
const key = String(name);
|
|
561
|
+
const bounds = topLevelBounds(lines, "vendor");
|
|
562
|
+
if (!bounds) return { removed: null };
|
|
563
|
+
|
|
564
|
+
if (bounds.inline) {
|
|
565
|
+
const mapping = parseValue(bounds.inline);
|
|
566
|
+
if (!mapping || typeof mapping !== "object" || Array.isArray(mapping))
|
|
567
|
+
return { removed: null };
|
|
568
|
+
if (!Object.hasOwn(mapping, key)) return { removed: null };
|
|
569
|
+
const source =
|
|
570
|
+
typeof mapping[key] === "string"
|
|
571
|
+
? mapping[key]
|
|
572
|
+
: (mapping[key]?.url ?? mapping[key]?.source ?? null);
|
|
573
|
+
delete mapping[key];
|
|
574
|
+
const rest = Object.keys(mapping).length
|
|
575
|
+
? ` ${JSON.stringify(mapping)}`
|
|
576
|
+
: " {}";
|
|
577
|
+
const comment = bounds.comment ? ` ${bounds.comment}` : "";
|
|
578
|
+
const line = lines[bounds.index];
|
|
579
|
+
lines[bounds.index] = `vendor:${rest}${comment}${
|
|
580
|
+
/\r?\n$/.test(line) ? ending(line) : eol
|
|
581
|
+
}`;
|
|
582
|
+
writeDocument(configPath, lines.join(""));
|
|
583
|
+
return { removed: source };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const spans = mappingEntrySpans(lines, bounds.index, bounds.end);
|
|
587
|
+
const existing = spans.find((span) => span.key === key);
|
|
588
|
+
if (!existing) return { removed: null };
|
|
589
|
+
|
|
590
|
+
let source = null;
|
|
591
|
+
try {
|
|
592
|
+
const parsed = yaml.load(
|
|
593
|
+
[
|
|
594
|
+
content(lines[bounds.index]),
|
|
595
|
+
...lines.slice(existing.index, existing.end),
|
|
596
|
+
]
|
|
597
|
+
.join("\n")
|
|
598
|
+
.replace(/^vendor:/, "vendor:"),
|
|
599
|
+
);
|
|
600
|
+
const entry = parsed?.vendor?.[key];
|
|
601
|
+
source =
|
|
602
|
+
typeof entry === "string"
|
|
603
|
+
? entry
|
|
604
|
+
: (entry?.url ?? entry?.source ?? entry?.path ?? null);
|
|
605
|
+
} catch {
|
|
606
|
+
// The entry is removed either way.
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
replaceRange(lines, existing.index, existing.end, []);
|
|
610
|
+
const end = bounds.end - (existing.end - existing.index);
|
|
611
|
+
const remaining = mappingEntrySpans(lines, bounds.index, end);
|
|
612
|
+
if (remaining.length === 0) {
|
|
613
|
+
replaceRange(lines, bounds.index, end, [`vendor: {}${eol}`]);
|
|
614
|
+
}
|
|
615
|
+
writeDocument(configPath, lines.join(""));
|
|
616
|
+
return { removed: source ?? key };
|
|
617
|
+
}
|