@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.
- package/README.md +59 -0
- package/bin/fx.js +4 -1162
- package/package.json +8 -4
- package/product.yml +1 -1
- package/src/brand.js +9 -3
- package/src/catalog.js +103 -0
- 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/feature.js +114 -0
- package/src/cli/commands/fmt.js +173 -0
- package/src/cli/commands/index.js +45 -0
- package/src/cli/commands/init.js +169 -0
- package/src/cli/commands/lint.js +270 -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 +270 -0
- package/src/cli/define-command.js +147 -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 +203 -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 +133 -0
- package/src/config-edit.js +399 -1
- package/src/config.js +174 -4
- package/src/decompiler.js +45 -0
- package/src/extensions.js +72 -19
- package/src/features.js +339 -0
- package/src/formatter.js +499 -0
- package/src/index.js +31 -0
- package/src/lexer.js +45 -4
- package/src/lint/analyze.js +1414 -0
- package/src/lint/config-file.js +97 -0
- package/src/lint/config.js +174 -0
- package/src/lint/rules.js +461 -0
- package/src/linter.js +220 -0
- package/src/merge.js +109 -0
- package/src/parser.js +53 -13
- package/src/project.js +7 -87
- package/src/vendor.js +1323 -0
- package/vendor/pm-packager-dist/.fx-build.json +1 -1
- package/vendor/tempvars/fx-tempvars.js +321 -0
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,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
|
|
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 {
|
|
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";
|