@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.
Files changed (54) hide show
  1. package/README.md +32 -0
  2. package/bin/fx.js +4 -1160
  3. package/package.json +9 -3
  4. package/product.yml +1 -1
  5. package/src/brand.js +9 -3
  6. package/src/build.js +2 -1
  7. package/src/catalog.js +117 -140
  8. package/src/cli/commands/build.js +98 -0
  9. package/src/cli/commands/catalog.js +42 -0
  10. package/src/cli/commands/check.js +44 -0
  11. package/src/cli/commands/completion.js +54 -0
  12. package/src/cli/commands/decompile.js +266 -0
  13. package/src/cli/commands/dev.js +179 -0
  14. package/src/cli/commands/extension.js +110 -0
  15. package/src/cli/commands/index.js +39 -0
  16. package/src/cli/commands/init.js +169 -0
  17. package/src/cli/commands/package.js +137 -0
  18. package/src/cli/commands/sprite/add.js +85 -0
  19. package/src/cli/commands/sprite/build.js +62 -0
  20. package/src/cli/commands/sprite/helpers.js +66 -0
  21. package/src/cli/commands/sprite/index.js +29 -0
  22. package/src/cli/commands/sprite/list.js +31 -0
  23. package/src/cli/commands/sprite/remove.js +51 -0
  24. package/src/cli/commands/vendor/add.js +98 -0
  25. package/src/cli/commands/vendor/helpers.js +50 -0
  26. package/src/cli/commands/vendor/index.js +39 -0
  27. package/src/cli/commands/vendor/install.js +64 -0
  28. package/src/cli/commands/vendor/list.js +45 -0
  29. package/src/cli/commands/vendor/migrate.js +47 -0
  30. package/src/cli/commands/vendor/outdated.js +51 -0
  31. package/src/cli/commands/vendor/remove.js +34 -0
  32. package/src/cli/commands/vendor/update.js +49 -0
  33. package/src/cli/commands/vendor/verify.js +44 -0
  34. package/src/cli/completion/bash.js +10 -0
  35. package/src/cli/completion/fish.js +9 -0
  36. package/src/cli/completion/index.js +97 -0
  37. package/src/cli/completion/zsh.js +11 -0
  38. package/src/cli/context.js +257 -0
  39. package/src/cli/define-command.js +140 -0
  40. package/src/cli/errors.js +111 -0
  41. package/src/cli/format.js +31 -0
  42. package/src/cli/prompts.js +183 -0
  43. package/src/cli/reporter.js +194 -0
  44. package/src/cli/run.js +248 -0
  45. package/src/cli/templates/index.js +112 -0
  46. package/src/cli/theme.js +84 -0
  47. package/src/compiler.js +32 -3
  48. package/src/config-edit.js +280 -1
  49. package/src/config.js +137 -3
  50. package/src/decompiler.js +12 -2
  51. package/src/extensions.js +311 -48
  52. package/src/index.js +21 -0
  53. package/src/vendor.js +1323 -0
  54. 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
+ ];
@@ -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
- bitmapResolution: 1,
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
- switch (spec.type) {
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":
@@ -54,7 +54,8 @@ function childKey(lines, start, end, key) {
54
54
  }
55
55
 
56
56
  function childIndent(lines, start, end) {
57
- for (let index = start + 1; index < end; index++) {
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
+ }