@triflare/fx 1.0.3 → 2.1.0-beta.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.
Files changed (68) hide show
  1. package/README.md +59 -0
  2. package/bin/fx.js +4 -1162
  3. package/package.json +8 -4
  4. package/product.yml +1 -1
  5. package/src/brand.js +9 -3
  6. package/src/catalog.js +103 -0
  7. package/src/cli/commands/build.js +98 -0
  8. package/src/cli/commands/catalog.js +42 -0
  9. package/src/cli/commands/check.js +44 -0
  10. package/src/cli/commands/completion.js +54 -0
  11. package/src/cli/commands/decompile.js +266 -0
  12. package/src/cli/commands/dev.js +179 -0
  13. package/src/cli/commands/extension.js +110 -0
  14. package/src/cli/commands/feature.js +114 -0
  15. package/src/cli/commands/fmt.js +173 -0
  16. package/src/cli/commands/index.js +45 -0
  17. package/src/cli/commands/init.js +169 -0
  18. package/src/cli/commands/lint.js +270 -0
  19. package/src/cli/commands/package.js +137 -0
  20. package/src/cli/commands/sprite/add.js +85 -0
  21. package/src/cli/commands/sprite/build.js +62 -0
  22. package/src/cli/commands/sprite/helpers.js +66 -0
  23. package/src/cli/commands/sprite/index.js +29 -0
  24. package/src/cli/commands/sprite/list.js +31 -0
  25. package/src/cli/commands/sprite/remove.js +51 -0
  26. package/src/cli/commands/vendor/add.js +98 -0
  27. package/src/cli/commands/vendor/helpers.js +50 -0
  28. package/src/cli/commands/vendor/index.js +39 -0
  29. package/src/cli/commands/vendor/install.js +64 -0
  30. package/src/cli/commands/vendor/list.js +45 -0
  31. package/src/cli/commands/vendor/migrate.js +47 -0
  32. package/src/cli/commands/vendor/outdated.js +51 -0
  33. package/src/cli/commands/vendor/remove.js +34 -0
  34. package/src/cli/commands/vendor/update.js +49 -0
  35. package/src/cli/commands/vendor/verify.js +44 -0
  36. package/src/cli/completion/bash.js +10 -0
  37. package/src/cli/completion/fish.js +9 -0
  38. package/src/cli/completion/index.js +97 -0
  39. package/src/cli/completion/zsh.js +11 -0
  40. package/src/cli/context.js +270 -0
  41. package/src/cli/define-command.js +147 -0
  42. package/src/cli/errors.js +111 -0
  43. package/src/cli/format.js +31 -0
  44. package/src/cli/prompts.js +183 -0
  45. package/src/cli/reporter.js +203 -0
  46. package/src/cli/run.js +248 -0
  47. package/src/cli/templates/index.js +112 -0
  48. package/src/cli/theme.js +84 -0
  49. package/src/compiler.js +133 -0
  50. package/src/config-edit.js +399 -1
  51. package/src/config.js +174 -4
  52. package/src/decompiler.js +45 -0
  53. package/src/extensions.js +72 -19
  54. package/src/features.js +339 -0
  55. package/src/formatter.js +499 -0
  56. package/src/index.js +31 -0
  57. package/src/lexer.js +45 -4
  58. package/src/lint/analyze.js +1414 -0
  59. package/src/lint/config-file.js +97 -0
  60. package/src/lint/config.js +174 -0
  61. package/src/lint/rules.js +461 -0
  62. package/src/linter.js +220 -0
  63. package/src/merge.js +109 -0
  64. package/src/parser.js +53 -13
  65. package/src/project.js +7 -87
  66. package/src/vendor.js +1323 -0
  67. package/vendor/pm-packager-dist/.fx-build.json +1 -1
  68. package/vendor/tempvars/fx-tempvars.js +321 -0
@@ -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,400 @@ 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
+ }
618
+
619
+ // -------------------------------------------------------------- feature edits
620
+
621
+ /**
622
+ * Add or replace a `features:` entry without reformatting the rest of fx.yml.
623
+ *
624
+ * @param {string} configPath
625
+ * @param {string} name
626
+ * @param {string} file
627
+ * @returns {{added: boolean, updated: boolean}}
628
+ */
629
+ export function addFeatureEntry(configPath, name, file) {
630
+ const text = fs.readFileSync(configPath, "utf8");
631
+ const lines = splitLines(text);
632
+ const eol = eolOf(text);
633
+ const key = String(name);
634
+ let added = false;
635
+ let updated = false;
636
+ const entryLine = (indent) =>
637
+ `${indent}${quoteKey(key)}: ${quote(file)}${eol}`;
638
+
639
+ const bounds = topLevelBounds(lines, "features");
640
+ if (!bounds) {
641
+ if (lines.length > 0 && !lines[lines.length - 1].endsWith("\n"))
642
+ lines[lines.length - 1] += eol;
643
+ lines.push(`features:${eol}`, entryLine(" "));
644
+ added = true;
645
+ } else if (bounds.inline) {
646
+ const mapping = parseValue(bounds.inline) || {};
647
+ updated = Object.hasOwn(mapping, key);
648
+ added = !updated;
649
+ mapping[key] = file;
650
+ const dumped = yaml
651
+ .dump({ features: mapping }, { lineWidth: -1, noRefs: true })
652
+ .split("\n")
653
+ .filter((line, index, all) => !(index === all.length - 1 && line === ""))
654
+ .map((line) => `${line}${eol}`);
655
+ if (bounds.comment) dumped[0] = `features: ${bounds.comment}${eol}`;
656
+ replaceRange(lines, bounds.index, bounds.index + 1, dumped);
657
+ } else {
658
+ const spans = mappingEntrySpans(lines, bounds.index, bounds.end);
659
+ const existing = spans.find((span) => span.key === key);
660
+ if (existing) {
661
+ replaceRange(lines, existing.index, existing.end, [
662
+ entryLine(existing.indent),
663
+ ]);
664
+ updated = true;
665
+ } else {
666
+ const indent = childIndent(lines, bounds.index, bounds.end);
667
+ insertAt(lines, bounds.end, [entryLine(indent)], eol);
668
+ added = true;
669
+ }
670
+ }
671
+
672
+ if (added || updated) writeDocument(configPath, lines.join(""));
673
+ return { added, updated };
674
+ }
675
+
676
+ /**
677
+ * Remove a `features:` entry.
678
+ * @param {string} configPath
679
+ * @param {string} name
680
+ * @returns {{removed: string|null}}
681
+ */
682
+ export function removeFeatureEntry(configPath, name) {
683
+ const text = fs.readFileSync(configPath, "utf8");
684
+ const lines = splitLines(text);
685
+ const eol = eolOf(text);
686
+ const key = String(name);
687
+ const bounds = topLevelBounds(lines, "features");
688
+ if (!bounds) return { removed: null };
689
+
690
+ if (bounds.inline) {
691
+ const mapping = parseValue(bounds.inline);
692
+ if (!mapping || typeof mapping !== "object" || Array.isArray(mapping))
693
+ return { removed: null };
694
+ if (!Object.hasOwn(mapping, key)) return { removed: null };
695
+ const source = mapping[key];
696
+ delete mapping[key];
697
+ const rest = Object.keys(mapping).length
698
+ ? ` ${JSON.stringify(mapping)}`
699
+ : " {}";
700
+ const comment = bounds.comment ? ` ${bounds.comment}` : "";
701
+ const line = lines[bounds.index];
702
+ lines[bounds.index] = `features:${rest}${comment}${
703
+ /\r?\n$/.test(line) ? ending(line) : eol
704
+ }`;
705
+ writeDocument(configPath, lines.join(""));
706
+ return { removed: source == null ? null : String(source) };
707
+ }
708
+
709
+ const spans = mappingEntrySpans(lines, bounds.index, bounds.end);
710
+ const existing = spans.find((span) => span.key === key);
711
+ if (!existing) return { removed: null };
712
+
713
+ let source = null;
714
+ try {
715
+ const parsed = yaml.load(
716
+ [
717
+ content(lines[bounds.index]),
718
+ ...lines.slice(existing.index, existing.end),
719
+ ].join("\n"),
720
+ );
721
+ const entry = parsed?.features?.[key];
722
+ source =
723
+ typeof entry === "string" ? entry : (entry?.file ?? entry?.path ?? null);
724
+ } catch {
725
+ // The entry is removed either way.
726
+ }
727
+
728
+ replaceRange(lines, existing.index, existing.end, []);
729
+ const end = bounds.end - (existing.end - existing.index);
730
+ const remaining = mappingEntrySpans(lines, bounds.index, end);
731
+ if (remaining.length === 0) {
732
+ replaceRange(lines, bounds.index, end, [`features: {}${eol}`]);
733
+ }
734
+ writeDocument(configPath, lines.join(""));
735
+ return { removed: source ?? key };
736
+ }
package/src/config.js CHANGED
@@ -11,6 +11,7 @@ import fs from "node:fs";
11
11
  import path from "node:path";
12
12
  import * as yaml from "js-yaml";
13
13
  import { configFilename, configFilenames, productName } from "./brand.js";
14
+ import { DEFAULT_LINT, normalizeLintConfig } from "./lint/config.js";
14
15
 
15
16
  export const DEFAULT_PROJECT = {
16
17
  name: `${productName()} Project`,
@@ -31,8 +32,139 @@ export const DEFAULT_PROJECT = {
31
32
  */
32
33
  export const DEFAULT_FX = {
33
34
  partialSprites: false,
35
+ vendorDir: "extensions",
36
+ lint: { ...DEFAULT_LINT, rules: { ...DEFAULT_LINT.rules } },
34
37
  };
35
38
 
39
+ /** Default directory that receives vendored extension sources. */
40
+ export const DEFAULT_VENDOR_DIR = "extensions";
41
+
42
+ /** Source schemes that identify a remote extension. */
43
+ const REMOTE_SOURCE = /^https?:\/\//i;
44
+ const DATA_SOURCE = /^data:/i;
45
+
46
+ /** True for http(s) extension sources. */
47
+ function isRemoteSource(source) {
48
+ return REMOTE_SOURCE.test(String(source));
49
+ }
50
+
51
+ /** True for embedded data-URI extension sources. */
52
+ function isDataSource(source) {
53
+ return DATA_SOURCE.test(String(source));
54
+ }
55
+
56
+ /** Localhost/loopback URLs can point at a development server that changes. */
57
+ export function isLocalhostSource(source) {
58
+ if (!isRemoteSource(source)) return false;
59
+ try {
60
+ const url = new URL(String(source));
61
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
62
+ return (
63
+ host === "localhost" ||
64
+ host.endsWith(".localhost") ||
65
+ host === "0.0.0.0" ||
66
+ host === "::1" ||
67
+ /^127\./.test(host)
68
+ );
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Normalize one `vendor:` entry. A string is shorthand for `{ url }`; a
76
+ * mapping may add `unsandboxed`, `volatile` and any future fields.
77
+ *
78
+ * @param {string} name
79
+ * @param {unknown} raw
80
+ * @returns {object}
81
+ */
82
+ export function normalizeVendorEntry(name, raw) {
83
+ const key = String(name);
84
+ let source;
85
+ let options = {};
86
+ if (typeof raw === "string" || typeof raw === "number") {
87
+ source = String(raw);
88
+ } else if (isPlainObject(raw)) {
89
+ source = raw.url ?? raw.source ?? raw.path ?? raw.file;
90
+ options = { ...raw };
91
+ } else {
92
+ throw new Error(
93
+ `${configFilename()}: vendor.${key} must be a URL, path, or mapping`,
94
+ );
95
+ }
96
+ if (typeof source !== "string" || source.trim() === "")
97
+ throw new Error(
98
+ `${configFilename()}: vendor.${key} needs a "url" (or "source"/"path")`,
99
+ );
100
+ source = source.trim();
101
+
102
+ const remote = isRemoteSource(source);
103
+ const local = !remote && !isDataSource(source);
104
+ const unsandboxed =
105
+ options.unsandboxed === true ||
106
+ (typeof options.unsandboxed === "string" &&
107
+ options.unsandboxed.trim() !== "");
108
+ const volatile =
109
+ options.volatile == null
110
+ ? remote && isLocalhostSource(source)
111
+ : Boolean(options.volatile);
112
+
113
+ return {
114
+ ...options,
115
+ name: key,
116
+ source,
117
+ url: source,
118
+ remote,
119
+ local,
120
+ data: isDataSource(source),
121
+ volatile,
122
+ unsandboxed,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Normalize the top-level `features:` mapping. Values are file paths (or
128
+ * `{ file }` mappings), relative to the config directory.
129
+ *
130
+ * @param {unknown} raw
131
+ * @returns {Array<{name: string, file: string}>}
132
+ */
133
+ export function normalizeFeatures(raw = {}) {
134
+ if (raw == null) return [];
135
+ if (!isPlainObject(raw))
136
+ throw new Error(`${configFilename()}: "features" must be a mapping`);
137
+ return Object.entries(raw).map(([name, value]) => {
138
+ const file =
139
+ typeof value === "string"
140
+ ? value
141
+ : isPlainObject(value) && typeof value.file === "string"
142
+ ? value.file
143
+ : null;
144
+ if (!file || file.trim() === "")
145
+ throw new Error(
146
+ `${configFilename()}: features.${name} must be a file path or { file: "<path>" }`,
147
+ );
148
+ return { name: String(name), file: file.trim() };
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Normalize the top-level `vendor:` mapping. Order is load order, so this is an
154
+ * array rather than a plain object.
155
+ *
156
+ * @param {unknown} raw
157
+ * @returns {object[]}
158
+ */
159
+ export function normalizeVendor(raw = {}) {
160
+ if (raw == null) return [];
161
+ if (!isPlainObject(raw))
162
+ throw new Error(`${configFilename()}: "vendor" must be a mapping`);
163
+ return Object.entries(raw).map(([name, entry]) =>
164
+ normalizeVendorEntry(name, entry),
165
+ );
166
+ }
167
+
36
168
  /**
37
169
  * Packaging defaults. These never reach PenguinMod itself; they are handed to
38
170
  * the PenguinMod Packager when `fx package` or `fx dev` runs.
@@ -277,10 +409,19 @@ export function normalizeProject(raw = {}) {
277
409
  * @returns {typeof DEFAULT_FX}
278
410
  */
279
411
  export function normalizeFx(raw = {}) {
280
- const fx = { ...DEFAULT_FX };
412
+ const fx = { ...DEFAULT_FX, lint: { ...DEFAULT_LINT, rules: {} } };
281
413
  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
282
414
  if (raw.partialSprites != null)
283
415
  fx.partialSprites = Boolean(raw.partialSprites);
416
+ if (raw.vendorDir != null) {
417
+ if (typeof raw.vendorDir !== "string" || raw.vendorDir.trim() === "")
418
+ throw new Error(`${configFilename()}: fx.vendorDir must be a path`);
419
+ fx.vendorDir = raw.vendorDir.trim();
420
+ }
421
+ fx.lint = normalizeLintConfig(
422
+ raw.lint,
423
+ (message) => new Error(`${configFilename()}: ${message}`),
424
+ );
284
425
  }
285
426
  return fx;
286
427
  }
@@ -288,7 +429,7 @@ export function normalizeFx(raw = {}) {
288
429
  /**
289
430
  * Parse a YAML config string.
290
431
  * @param {string} text
291
- * @returns {{project: typeof DEFAULT_PROJECT, fx: typeof DEFAULT_FX, raw: object}}
432
+ * @returns {{project: typeof DEFAULT_PROJECT, fx: typeof DEFAULT_FX, package: typeof DEFAULT_PACKAGE, vendor: object[], vendorDir: string, raw: object}}
292
433
  */
293
434
  export function parseConfig(text) {
294
435
  const raw = yaml.load(text) || {};
@@ -298,10 +439,21 @@ export function parseConfig(text) {
298
439
  );
299
440
  if (raw.fx != null && (typeof raw.fx !== "object" || Array.isArray(raw.fx)))
300
441
  throw new Error(`${configFilename()} must contain an "fx" mapping`);
442
+ const fx = normalizeFx(raw.fx || {});
443
+ // `vendorDir` is documented at the top level, but accepting it under `fx:`
444
+ // keeps older drafts and long configs working.
445
+ if (raw.vendorDir != null) {
446
+ if (typeof raw.vendorDir !== "string" || raw.vendorDir.trim() === "")
447
+ throw new Error(`${configFilename()}: vendorDir must be a path`);
448
+ fx.vendorDir = raw.vendorDir.trim();
449
+ }
301
450
  return {
302
451
  project: normalizeProject(raw.project || {}),
303
- fx: normalizeFx(raw.fx || {}),
452
+ fx,
304
453
  package: normalizePackage(raw.package || {}),
454
+ vendor: normalizeVendor(raw.vendor || {}),
455
+ features: normalizeFeatures(raw.features || {}),
456
+ vendorDir: fx.vendorDir,
305
457
  raw,
306
458
  };
307
459
  }
@@ -339,14 +491,29 @@ export function loadConfigFrom(dir) {
339
491
  project,
340
492
  fx,
341
493
  package: pkg,
494
+ vendor,
495
+ features,
496
+ vendorDir,
342
497
  raw,
343
498
  } = parseConfig(fs.readFileSync(found, "utf8"));
344
- return { project, fx, package: pkg, raw, path: found };
499
+ return {
500
+ project,
501
+ fx,
502
+ package: pkg,
503
+ vendor,
504
+ features,
505
+ vendorDir,
506
+ raw,
507
+ path: found,
508
+ };
345
509
  }
346
510
  return {
347
511
  project: { ...DEFAULT_PROJECT },
348
512
  fx: { ...DEFAULT_FX },
349
513
  package: normalizePackage({}),
514
+ vendor: [],
515
+ features: [],
516
+ vendorDir: DEFAULT_VENDOR_DIR,
350
517
  raw: {},
351
518
  path: null,
352
519
  };
@@ -394,7 +561,10 @@ export function configComment(project) {
394
561
 
395
562
  export {
396
563
  addProjectExtension,
564
+ addVendorEntry,
397
565
  extensionKey,
398
566
  readProjectExtensions,
399
567
  removeProjectExtension,
568
+ removeProjectExtensions,
569
+ removeVendorEntry,
400
570
  } from "./config-edit.js";