@heroiclands/package-build 18.2.0 → 20.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/CHANGELOG.md +692 -0
- package/CONTENT.md +81 -10
- package/bin/content-build.mjs +7 -1
- package/ci/ci-docker.mjs +21 -0
- package/content-config.mjs +26 -24
- package/docs/content-format.md +408 -85
- package/engine/actor-compiler.mjs +197 -7
- package/engine/address-charset.mjs +23 -5
- package/engine/base-compiler.mjs +65 -2
- package/engine/bundles.mjs +9 -0
- package/engine/content-address.mjs +92 -1
- package/engine/content-format.mjs +102 -0
- package/engine/content-index.mjs +11 -8
- package/engine/content-links.mjs +37 -21
- package/engine/field-reference.mjs +57 -5
- package/engine/field-spec.mjs +214 -7
- package/engine/folder-notes.mjs +88 -1
- package/engine/foreign-catalog.mjs +4 -1
- package/engine/foundry-entries.mjs +16 -0
- package/engine/frontmatter-lint.mjs +215 -28
- package/engine/frontmatter.mjs +12 -12
- package/engine/generate.mjs +78 -46
- package/engine/helpers.mjs +87 -128
- package/engine/index.mjs +3 -0
- package/engine/item-compiler.mjs +44 -9
- package/engine/journals.mjs +27 -16
- package/engine/macros.mjs +8 -0
- package/engine/map-notes.mjs +7 -7
- package/engine/note-ids.mjs +25 -1
- package/engine/note-vocabulary.mjs +76 -9
- package/engine/retired-fields.mjs +57 -16
- package/engine/runtime-only-fields.mjs +204 -0
- package/engine/scenes.mjs +19 -28
- package/engine/schema-check.mjs +23 -1
- package/engine/site-index.mjs +17 -0
- package/engine/subtype-registry.mjs +30 -0
- package/engine/system-block.mjs +81 -3
- package/engine/web-wikilinks.mjs +33 -27
- package/engine/wikilink-syntax.mjs +7 -0
- package/engine/wikilinks.mjs +74 -16
- package/hm3/actors.mjs +70 -23
- package/package.json +2 -2
- package/sohl/actors.mjs +106 -7
- package/sohl/item-fields.mjs +203 -0
- package/sohl/note-schemas.mjs +6 -3
- package/types/content-config.d.mts +0 -7
- package/types/engine/actor-compiler.d.mts +83 -3
- package/types/engine/address-charset.d.mts +22 -4
- package/types/engine/base-compiler.d.mts +54 -3
- package/types/engine/content-address.d.mts +64 -0
- package/types/engine/content-format.d.mts +9 -0
- package/types/engine/field-spec.d.mts +271 -3
- package/types/engine/folder-notes.d.mts +59 -0
- package/types/engine/foundry-entries.d.mts +6 -0
- package/types/engine/frontmatter-lint.d.mts +18 -2
- package/types/engine/frontmatter.d.mts +11 -11
- package/types/engine/generate.d.mts +27 -0
- package/types/engine/helpers.d.mts +37 -38
- package/types/engine/index.d.mts +1 -0
- package/types/engine/map-notes.d.mts +2 -2
- package/types/engine/note-ids.d.mts +14 -0
- package/types/engine/retired-fields.d.mts +29 -13
- package/types/engine/runtime-only-fields.d.mts +102 -0
- package/types/engine/schema-check.d.mts +10 -1
- package/types/engine/subtype-registry.d.mts +21 -0
- package/types/engine/system-block.d.mts +28 -2
- package/types/sohl/actors.d.mts +3 -3
|
@@ -71,6 +71,7 @@ import { compendiumUuid, currentType, packForType, pageUuid } from "./ids.mjs";
|
|
|
71
71
|
import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
|
|
72
72
|
import { isHomepage } from "./homepage.mjs";
|
|
73
73
|
import { assertNoDeclaredPackage } from "./note-package.mjs";
|
|
74
|
+
import { assertNoDeclaredFolder } from "./folder-notes.mjs";
|
|
74
75
|
import {
|
|
75
76
|
assertNoAliasesField,
|
|
76
77
|
assertNoDraftField,
|
|
@@ -132,6 +133,12 @@ export function anchorsOf(entryUuid, entryId, body, name) {
|
|
|
132
133
|
* @param {string} name - The note's display name.
|
|
133
134
|
* @param {string} address - The note's package-relative address.
|
|
134
135
|
* @param {string} body - The note's markdown body.
|
|
136
|
+
* **Each entry carries the `id` of the document it addresses**, not only its
|
|
137
|
+
* UUID. The two are one fact — a UUID ends in the id — but only the entry knows
|
|
138
|
+
* which derivation produced it: an item's is its note's `fm.id`, and its
|
|
139
|
+
* documentation journal's is {@link itemDocEntryId} of that. Stating it here is
|
|
140
|
+
* what lets the content index publish an identity it did not re-derive (#310).
|
|
141
|
+
*
|
|
135
142
|
* @param {object} ctx - Resolved identities: `{ contentPackage,
|
|
136
143
|
* foundryPackageId, packRouter }`.
|
|
137
144
|
* @returns {Array<object>} One or two entries, in {@link buildManifest}'s shape.
|
|
@@ -200,6 +207,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
|
|
|
200
207
|
fm,
|
|
201
208
|
name,
|
|
202
209
|
url,
|
|
210
|
+
id: fm.id,
|
|
203
211
|
uuid: uuidFor(fm.type, fm.id, fm),
|
|
204
212
|
doc: docKey,
|
|
205
213
|
},
|
|
@@ -210,6 +218,12 @@ export function entriesForNote(fm, name, address, body, ctx) {
|
|
|
210
218
|
// On the web the item note renders as one page which *is* its
|
|
211
219
|
// documentation, so both addresses resolve to the same URL.
|
|
212
220
|
url,
|
|
221
|
+
// The journal's **own** id, which is not the item's: the
|
|
222
|
+
// content index publishes it beside the UUID, so an entry the
|
|
223
|
+
// index gives an identity to states both halves of it rather
|
|
224
|
+
// than leaving a consumer to parse the id back out of the
|
|
225
|
+
// UUID's last segment (#310).
|
|
226
|
+
id: docEntryId,
|
|
213
227
|
uuid: docUuid,
|
|
214
228
|
anchors: docUuid ? anchorsOf(docUuid, docEntryId, body ?? "", name) : undefined,
|
|
215
229
|
},
|
|
@@ -225,6 +239,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
|
|
|
225
239
|
fm,
|
|
226
240
|
name,
|
|
227
241
|
url,
|
|
242
|
+
id: fm.id,
|
|
228
243
|
uuid: own,
|
|
229
244
|
anchors: own && fm.type === "doc" ? anchorsOf(own, fm.id, body ?? "", name) : undefined,
|
|
230
245
|
},
|
|
@@ -273,6 +288,7 @@ export function collectFoundryEntries(contentBase, ctx) {
|
|
|
273
288
|
absPath,
|
|
274
289
|
configured: ctx.contentPackage,
|
|
275
290
|
});
|
|
291
|
+
assertNoDeclaredFolder(fm, { file: rel, absPath });
|
|
276
292
|
assertNoDraftField(fm, { file: rel, absPath });
|
|
277
293
|
assertNoAliasesField(fm, { file: rel, absPath });
|
|
278
294
|
assertNoSectionField(fm, { file: rel, absPath });
|
|
@@ -114,7 +114,7 @@ import {
|
|
|
114
114
|
* @type {ReadonlySet<string>}
|
|
115
115
|
*/
|
|
116
116
|
export const UNIVERSAL_KEYS = Object.freeze(
|
|
117
|
-
new Set(["
|
|
117
|
+
new Set(["packFolder", "pack", "archetype", "templatePriority", "kbcat"]),
|
|
118
118
|
);
|
|
119
119
|
|
|
120
120
|
/**
|
|
@@ -591,30 +591,93 @@ function checkTags(note, { type }) {
|
|
|
591
591
|
* note in any tree writes `img: ""` on a being; a check keyed on `img` alone
|
|
592
592
|
* would have called that tree clean (#218).
|
|
593
593
|
*
|
|
594
|
-
*
|
|
594
|
+
* **Each carries where it is authored**, because the two no longer agree. The
|
|
595
|
+
* specification puts an actor's portrait under `data:` and leaves its token art
|
|
596
|
+
* at the note's top level, so `portrait` has a third position to read and `img`
|
|
597
|
+
* does not — and a check that read only the two they share would pass a
|
|
598
|
+
* `data.portrait: ""` it could not see (#332).
|
|
599
|
+
*
|
|
600
|
+
* @type {readonly {key: string, inData: boolean}[]}
|
|
601
|
+
*/
|
|
602
|
+
const ART_FIELDS = Object.freeze([
|
|
603
|
+
Object.freeze({ key: "img", inData: false }),
|
|
604
|
+
Object.freeze({ key: "portrait", inData: true }),
|
|
605
|
+
]);
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* The in-block keys a type's own declarations claim for a *different* quantity.
|
|
609
|
+
*
|
|
610
|
+
* {@link module:engine/field-spec.FieldSpec.topLevelMeans} read from the other
|
|
611
|
+
* side. That property says the note's top-level key of a field's name means
|
|
612
|
+
* something else, and `resolveFieldValue` honours it by refusing to read the
|
|
613
|
+
* shared position *for that field*. The statement is symmetric: if the two
|
|
614
|
+
* positions hold unrelated quantities, then the **block** position is not the
|
|
615
|
+
* note-level field either, and a check about the note-level field must not read
|
|
616
|
+
* it.
|
|
617
|
+
*
|
|
618
|
+
* `affiliation`'s `title` is the case that named this. A note's top-level
|
|
619
|
+
* `title` is its page heading, which the site emitter publishes as
|
|
620
|
+
* `fm.title ?? name`; `sohl.title` is the style of address an office carries —
|
|
621
|
+
* "Ajaw", "Warden". Twenty-eight `sohl-kethira-basic` affiliations author
|
|
622
|
+
* `sohl.title: ""` — an office with no style of address, which is ordinary —
|
|
623
|
+
* and every one of them was reported as publishing a page with no heading. None
|
|
624
|
+
* of them does; their pages take `name.full` exactly as intended (#312).
|
|
625
|
+
*
|
|
626
|
+
* Keyed on the **in-block** key — `legacyKey` where a field declares one, and
|
|
627
|
+
* its first segment where that is dotted — because that is the position a note
|
|
628
|
+
* authors, and so the position a note-level check would otherwise read.
|
|
629
|
+
*
|
|
630
|
+
* @param {readonly object[]|null|undefined} schema - The type's declarations.
|
|
631
|
+
* @returns {Set<string>} The in-block keys that are not the note-level field of
|
|
632
|
+
* the same name.
|
|
595
633
|
*/
|
|
596
|
-
|
|
634
|
+
function collidingBlockKeys(schema) {
|
|
635
|
+
const keys = new Set();
|
|
636
|
+
if (!Array.isArray(schema)) return keys;
|
|
637
|
+
for (const field of authoredFields(schema)) {
|
|
638
|
+
if (field.topLevelMeans === undefined) continue;
|
|
639
|
+
keys.add(String(legacyKeyOf(field)).split(".")[0]);
|
|
640
|
+
}
|
|
641
|
+
return keys;
|
|
642
|
+
}
|
|
597
643
|
|
|
598
644
|
/**
|
|
599
|
-
* Read a shared
|
|
600
|
-
*
|
|
645
|
+
* Read a shared field the way the compiler reads one: the `sohl:` block first,
|
|
646
|
+
* then `data:` where the field lives there, then the note's top level.
|
|
601
647
|
*
|
|
602
|
-
* The same order {@link module:engine/
|
|
603
|
-
* rather than imported so this module stays a leaf the linter can
|
|
604
|
-
* a resolved build configuration. Unlike
|
|
605
|
-
* empties — an authored `""` comes back as `""` and an
|
|
606
|
-
* `null` — which is the whole point of the caller below
|
|
648
|
+
* The same order {@link module:engine/system-block.resolveFieldValue} uses,
|
|
649
|
+
* restated here rather than imported so this module stays a leaf the linter can
|
|
650
|
+
* load without a resolved build configuration. Unlike that resolver it
|
|
651
|
+
* distinguishes the two empties — an authored `""` comes back as `""` and an
|
|
652
|
+
* authored `null` as `null` — which is the whole point of the caller below
|
|
653
|
+
* (#218).
|
|
654
|
+
*
|
|
655
|
+
* **`blockCollides` drops the first position**, where the note's type declares
|
|
656
|
+
* a system field of that name meaning something else — the resolver's
|
|
657
|
+
* `topLevelMeans` exemption, applied from the note-level side (#312). See
|
|
658
|
+
* {@link collidingBlockKeys}. The caller decides per key rather than this
|
|
659
|
+
* function deciding for itself, because this module knows no type's vocabulary:
|
|
660
|
+
* the declarations arrive from the caller, as `schemas` and `vocabulary` do.
|
|
607
661
|
*
|
|
608
662
|
* @param {object|null|undefined} fm - Parsed frontmatter.
|
|
609
663
|
* @param {string} key - The field name.
|
|
610
|
-
* @
|
|
611
|
-
*
|
|
664
|
+
* @param {object} [options] - Options.
|
|
665
|
+
* @param {boolean} [options.inData=false] - Whether the field's shared source
|
|
666
|
+
* is `data.<key>` rather than the top-level key.
|
|
667
|
+
* @param {boolean} [options.blockCollides=false] - Whether `sohl.<key>` is a
|
|
668
|
+
* system field that merely shares this name, and so answers for nothing here.
|
|
669
|
+
* @returns {any} The authored value, or `undefined` where no position declares
|
|
670
|
+
* one.
|
|
612
671
|
*/
|
|
613
|
-
function authoredValue(fm, key) {
|
|
614
|
-
const block = fm?.sohl;
|
|
672
|
+
function authoredValue(fm, key, { inData = false, blockCollides = false } = {}) {
|
|
673
|
+
const block = blockCollides ? undefined : fm?.sohl;
|
|
615
674
|
if (block && typeof block === "object" && !Array.isArray(block) && Object.hasOwn(block, key)) {
|
|
616
675
|
return block[key];
|
|
617
676
|
}
|
|
677
|
+
const data = inData ? fm?.data : undefined;
|
|
678
|
+
if (data && typeof data === "object" && !Array.isArray(data) && Object.hasOwn(data, key)) {
|
|
679
|
+
return data[key];
|
|
680
|
+
}
|
|
618
681
|
return fm && Object.hasOwn(fm, key) ? fm[key] : undefined;
|
|
619
682
|
}
|
|
620
683
|
|
|
@@ -709,6 +772,12 @@ function checkEmbeddedShortcodes(note, blockName) {
|
|
|
709
772
|
* note against whatever its type declares and knows no type names of its
|
|
710
773
|
* own. Its absence skips the `data:` and `subType` checks rather than
|
|
711
774
|
* reporting every key as unknown.
|
|
775
|
+
* @param {(type: string) => {document: string|null, art: readonly string[]}|null} [opts.emittedArt]
|
|
776
|
+
* What art a note of one type reaches its document through — the passes' own
|
|
777
|
+
* declaration, asked through `engine/generate.mjs`'s `emittedArtFor`.
|
|
778
|
+
* Supplied by the caller like `schemas`, so this module states no list of
|
|
779
|
+
* iconless types of its own; absent it, an inert `img:` goes unreported
|
|
780
|
+
* rather than every note's being (#349).
|
|
712
781
|
* @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
|
|
713
782
|
* The system blocks to check, and what each accepts. See
|
|
714
783
|
* {@link DEFAULT_SYSTEM_BLOCKS}.
|
|
@@ -720,13 +789,21 @@ function checkEmbeddedShortcodes(note, blockName) {
|
|
|
720
789
|
*/
|
|
721
790
|
export function lintNote(
|
|
722
791
|
note,
|
|
723
|
-
{ schemas, index, vocabulary, packs, systems = DEFAULT_SYSTEM_BLOCKS },
|
|
792
|
+
{ schemas, index, vocabulary, packs, emittedArt, systems = DEFAULT_SYSTEM_BLOCKS },
|
|
724
793
|
) {
|
|
725
794
|
const findings = [];
|
|
726
795
|
const fm = note.fm ?? {};
|
|
727
796
|
const type = String(fm.type ?? "");
|
|
728
797
|
const raw = () => note.raw ?? "";
|
|
729
798
|
const at = (key, literal) => positionInFrontmatter(raw(), key, literal ?? undefined);
|
|
799
|
+
/**
|
|
800
|
+
* The in-block keys this note's own type claims for something other than
|
|
801
|
+
* the note-level field of that name, which every note-level check below
|
|
802
|
+
* reads past (#312). Resolved once: the type is fixed for the note, and
|
|
803
|
+
* each check would otherwise ask the same question of the same
|
|
804
|
+
* declarations.
|
|
805
|
+
*/
|
|
806
|
+
const blockCollisions = collidingBlockKeys(schemas?.[currentType(type)]);
|
|
730
807
|
|
|
731
808
|
// The retired top-level fields, checked before the type: a note may carry
|
|
732
809
|
// one whatever its type is, and each finding stands on its own. Reported
|
|
@@ -744,6 +821,34 @@ export function lintNote(
|
|
|
744
821
|
"note in the tree belongs to it",
|
|
745
822
|
});
|
|
746
823
|
}
|
|
824
|
+
// `folder:` named a compendium folder by the raw Foundry id declared in a
|
|
825
|
+
// per-pack `*-folders.yaml`. Both halves are retired together (#260): the
|
|
826
|
+
// id spelling has nothing left to resolve against once the YAML is gone.
|
|
827
|
+
//
|
|
828
|
+
// Checked here as well as refused at compile because this is where an
|
|
829
|
+
// author meets every one of them in the tree at once — which is what a
|
|
830
|
+
// tree still to sweep needs, the whole corpus rather than the first note
|
|
831
|
+
// the compile happens to reach.
|
|
832
|
+
//
|
|
833
|
+
// **Both positions**, because notes wrote it both ways: top-level, and
|
|
834
|
+
// inside the `sohl:` block. The block spelling is no longer a universal
|
|
835
|
+
// key, so it would otherwise be reported as merely unrecognized, which
|
|
836
|
+
// says nothing about what to write instead.
|
|
837
|
+
const sohlBlock = fm.sohl;
|
|
838
|
+
const folderInBlock =
|
|
839
|
+
!!sohlBlock && typeof sohlBlock === "object" && Object.hasOwn(sohlBlock, "folder");
|
|
840
|
+
if (Object.hasOwn(fm, "folder") || folderInBlock) {
|
|
841
|
+
findings.push({
|
|
842
|
+
file: note.file,
|
|
843
|
+
...at("folder"),
|
|
844
|
+
severity: "error",
|
|
845
|
+
message:
|
|
846
|
+
"`folder:` is a retired frontmatter field — write `packFolder` " +
|
|
847
|
+
"instead. A folder is a note (`type: folder`) now, and " +
|
|
848
|
+
"`packFolder` names it by its address, not by the Foundry id a " +
|
|
849
|
+
"retired `*-folders.yaml` used to declare",
|
|
850
|
+
});
|
|
851
|
+
}
|
|
747
852
|
// `img: ""` was how a note said "I name no art" while `resolveImg`
|
|
748
853
|
// conflated the two empties and every caller defaulted with `||`. It now
|
|
749
854
|
// says the opposite — "ship no art, and do not default me" (#218) — so a
|
|
@@ -769,15 +874,23 @@ export function lintNote(
|
|
|
769
874
|
// optional strings, and it is not — it belongs to `resolveImg`, and `title`
|
|
770
875
|
// never goes through it.
|
|
771
876
|
//
|
|
772
|
-
// It once had a sharper reason
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
//
|
|
877
|
+
// It once had a sharper reason: a note's top-level `title` was
|
|
878
|
+
// simultaneously the shared source for an `affiliation` item's
|
|
879
|
+
// `system.title`, so asking an author for `title: null` would have compiled
|
|
880
|
+
// the literal string `"null"` into the document. The field declares
|
|
881
|
+
// `topLevelMeans` now, so the top-level key is no longer a source for it and
|
|
882
|
+
// `title: null` is harmless. `title: ""` is warned about on its own account
|
|
883
|
+
// below, as the *page's* heading rather than as an art path.
|
|
884
|
+
//
|
|
885
|
+
// **The collision itself did not go away, and this was where that was
|
|
886
|
+
// misread.** `topLevelMeans` settles which position the *emitted field*
|
|
887
|
+
// reads; it says nothing about which position a *check* reads, and
|
|
888
|
+
// `authoredValue` went on resolving through the block regardless — so an
|
|
889
|
+
// office with no style of address answered for its note's heading, in
|
|
890
|
+
// twenty-eight `sohl-kethira-basic` affiliations (#312). Hence
|
|
891
|
+
// `blockCollisions`: a note-level check reads past a block key its type
|
|
892
|
+
// claims for something else.
|
|
893
|
+
|
|
781
894
|
// The template priority is a *shared source* — the specification states it
|
|
782
895
|
// once for every type, as it does `pack` — so its retirement is reported
|
|
783
896
|
// here rather than by the per-type loop below, which only reaches a field
|
|
@@ -801,8 +914,70 @@ export function lintNote(
|
|
|
801
914
|
});
|
|
802
915
|
}
|
|
803
916
|
|
|
804
|
-
|
|
805
|
-
|
|
917
|
+
// An art field a note's own type never emits (#349). `img` is a *shared
|
|
918
|
+
// top-level* field — `BLOCK_DOCUMENT_PROPERTIES` maps it onto
|
|
919
|
+
// `document.img`, so it is legal on every note whatever the type — and a
|
|
920
|
+
// note whose document has no such property authors it, validates, compiles,
|
|
921
|
+
// and loses the value with nothing said. `Parrot` in `sohl-thalorna` had
|
|
922
|
+
// declared `img: images/mystery/parrot.webp` since long before the art rule
|
|
923
|
+
// was written and compiled `img: null`, exactly as a note declaring nothing
|
|
924
|
+
// does. The author's only evidence was the absence of an icon somewhere
|
|
925
|
+
// they were probably not looking.
|
|
926
|
+
//
|
|
927
|
+
// **Which types those are is not stated here.** It is asked of the passes,
|
|
928
|
+
// through the `emittedArt` the caller supplies — a note's type routes to a
|
|
929
|
+
// document, a document to the pass that compiles it, and the pass declares
|
|
930
|
+
// its own art. A list of iconless types kept in the linter would be a list
|
|
931
|
+
// free to drift from what is actually emitted, which is the defect rather
|
|
932
|
+
// than the check. Absent the option no claim is made, on the pattern
|
|
933
|
+
// `index` and `vocabulary` set.
|
|
934
|
+
//
|
|
935
|
+
// **Only an authored value, never `null`.** `null` is the blessed spelling
|
|
936
|
+
// for "this note names no art" (#218), and on a type with no art that is a
|
|
937
|
+
// true and harmless thing to say — it compiles identically to writing
|
|
938
|
+
// nothing. Twenty-six `sohl-thalorna` place notes are in exactly that
|
|
939
|
+
// state, and telling each of them to delete a key that already means
|
|
940
|
+
// nothing would bury the fifty-seven that name a path they believe ships.
|
|
941
|
+
//
|
|
942
|
+
// A **warning**, as the `package:` and retired-alias sweeps are: the note
|
|
943
|
+
// compiles correctly and the value is merely inert. Nor is it certainly
|
|
944
|
+
// unwanted — a note's top level is the generated page's front matter as
|
|
945
|
+
// well, so a template may read there what no document carries, which is a
|
|
946
|
+
// judgement only the tree's author can make.
|
|
947
|
+
const emitted = emittedArt ? emittedArt(currentType(type)) : null;
|
|
948
|
+
/** The art fields this note's type reaches nothing through. */
|
|
949
|
+
const inertArt = new Set(
|
|
950
|
+
emitted ? ART_FIELDS.filter(({ key }) => !emitted.art.includes(key)).map((f) => f.key) : [],
|
|
951
|
+
);
|
|
952
|
+
for (const { key, inData } of ART_FIELDS) {
|
|
953
|
+
if (!inertArt.has(key)) continue;
|
|
954
|
+
const authored = authoredValue(fm, key, {
|
|
955
|
+
inData,
|
|
956
|
+
blockCollides: blockCollisions.has(key),
|
|
957
|
+
});
|
|
958
|
+
if (typeof authored !== "string") continue;
|
|
959
|
+
findings.push({
|
|
960
|
+
file: note.file,
|
|
961
|
+
...at(key),
|
|
962
|
+
severity: "warning",
|
|
963
|
+
message:
|
|
964
|
+
`\`${key}:\` reaches no document from a \`${type}\` note — ` +
|
|
965
|
+
(emitted?.document ?
|
|
966
|
+
`it compiles into a ${emitted.document}, which carries no artwork`
|
|
967
|
+
: "it compiles into a page rather than a compendium document") +
|
|
968
|
+
", so the path is dropped. Delete the key, or move the art onto " +
|
|
969
|
+
"the note whose document is meant to show it; keep it only where " +
|
|
970
|
+
"a page template reads it as a parameter",
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
for (const { key, inData } of ART_FIELDS) {
|
|
975
|
+
// Reported above, and the distinction this draws does not exist there:
|
|
976
|
+
// where nothing is emitted, `""` and `null` are equally inert and the
|
|
977
|
+
// note has no default art to lose.
|
|
978
|
+
if (inertArt.has(key)) continue;
|
|
979
|
+
if (authoredValue(fm, key, { inData, blockCollides: blockCollisions.has(key) }) !== "")
|
|
980
|
+
continue;
|
|
806
981
|
findings.push({
|
|
807
982
|
file: note.file,
|
|
808
983
|
...at(key),
|
|
@@ -824,7 +999,13 @@ export function lintNote(
|
|
|
824
999
|
//
|
|
825
1000
|
// A warning rather than an error: the value is legal under the rule, and a
|
|
826
1001
|
// note that genuinely wants no heading may keep it — it just has to mean it.
|
|
827
|
-
|
|
1002
|
+
//
|
|
1003
|
+
// **The emitter reads `fm.title`, so this reads the note level.** On an
|
|
1004
|
+
// `affiliation` `sohl.title` is the office's style of address, which the
|
|
1005
|
+
// heading has nothing to do with — and `blockCollisions` is what keeps the
|
|
1006
|
+
// two apart (#312). On every other type nothing claims the block key, so the
|
|
1007
|
+
// resolution is the unchanged one.
|
|
1008
|
+
if (authoredValue(fm, "title", { blockCollides: blockCollisions.has("title") }) === "") {
|
|
828
1009
|
findings.push({
|
|
829
1010
|
file: note.file,
|
|
830
1011
|
...at("title"),
|
|
@@ -1170,10 +1351,15 @@ export function lintNote(
|
|
|
1170
1351
|
* The system blocks to check. See {@link DEFAULT_SYSTEM_BLOCKS}.
|
|
1171
1352
|
* @param {readonly string[]} [opts.packs] - The declared pack names; see
|
|
1172
1353
|
* {@link lintNote}.
|
|
1354
|
+
* @param {(type: string) => {document: string|null, art: readonly string[]}|null} [opts.emittedArt]
|
|
1355
|
+
* What art a type reaches its document through; see {@link lintNote}.
|
|
1173
1356
|
* @returns {{findings: object[], notes: number}} The findings, and how many
|
|
1174
1357
|
* notes were inspected.
|
|
1175
1358
|
*/
|
|
1176
|
-
export function lintFrontmatter(
|
|
1359
|
+
export function lintFrontmatter(
|
|
1360
|
+
index,
|
|
1361
|
+
{ schemas, vocabulary, packs, emittedArt, references = true, systems },
|
|
1362
|
+
) {
|
|
1177
1363
|
const findings = [];
|
|
1178
1364
|
const notes = [...index.notes].sort((a, b) =>
|
|
1179
1365
|
a.file < b.file ? -1
|
|
@@ -1186,6 +1372,7 @@ export function lintFrontmatter(index, { schemas, vocabulary, packs, references
|
|
|
1186
1372
|
schemas,
|
|
1187
1373
|
vocabulary,
|
|
1188
1374
|
packs,
|
|
1375
|
+
emittedArt,
|
|
1189
1376
|
index: references ? index : undefined,
|
|
1190
1377
|
...(systems ? { systems } : {}),
|
|
1191
1378
|
}),
|
package/engine/frontmatter.mjs
CHANGED
|
@@ -301,13 +301,15 @@ export function parseValueDesc(raw) {
|
|
|
301
301
|
}
|
|
302
302
|
|
|
303
303
|
/**
|
|
304
|
-
* The compendium folder a note names
|
|
304
|
+
* The compendium folder a note names.
|
|
305
305
|
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
306
|
+
* **There is one spelling.** `packFolder:` is a folder note's **address**
|
|
307
|
+
* (`folder-poisonsandtoxins`), resolved through the address index the whole
|
|
308
|
+
* build shares. The `folder:` Foundry-id spelling this function once read
|
|
309
|
+
* beside it, and the per-pack `*-folders.yaml` that id was resolved against,
|
|
310
|
+
* are retired together (#260) — a note declaring `folder:` is refused by
|
|
311
|
+
* {@link module:engine/folder-notes.assertNoDeclaredFolder} rather than
|
|
312
|
+
* reaching here, so there is no second source for a value to come from.
|
|
311
313
|
*
|
|
312
314
|
* **`packFolder` was a path for one release** (`Possessions/Misc_Gear/Cooking`)
|
|
313
315
|
* and is an address now (#255). A path encoded the hierarchy *in the value*, so
|
|
@@ -318,16 +320,14 @@ export function parseValueDesc(raw) {
|
|
|
318
320
|
* authors to migrate, which is the whole reason the change was cheap enough to
|
|
319
321
|
* make.
|
|
320
322
|
*
|
|
321
|
-
* `packFolder` wins where both are present. Nothing about `folder` changes: a
|
|
322
|
-
* note that names one is read, resolved and emitted exactly as before, until
|
|
323
|
-
* #260 retires it.
|
|
324
|
-
*
|
|
325
323
|
* @param {object|null|undefined} fm - Parsed frontmatter.
|
|
326
324
|
* @returns {{value: string|null, isAddress: boolean}} The authored value, and
|
|
327
|
-
* whether it is a folder note's address.
|
|
325
|
+
* whether it is a folder note's address. `isAddress` is always `true` and is
|
|
326
|
+
* kept so a caller reads the same shape it always did; it distinguished the
|
|
327
|
+
* two spellings, and there is only one left to be.
|
|
328
328
|
*/
|
|
329
329
|
export function folderField(fm) {
|
|
330
330
|
const asAddress = sohlField(fm, "packFolder", null);
|
|
331
331
|
if (asAddress != null && asAddress !== "") return { value: asAddress, isAddress: true };
|
|
332
|
-
return { value:
|
|
332
|
+
return { value: null, isAddress: true };
|
|
333
333
|
}
|
package/engine/generate.mjs
CHANGED
|
@@ -23,10 +23,12 @@
|
|
|
23
23
|
* Each `*` compiler walks the whole content tree and selects its own entries by
|
|
24
24
|
* the note's `type` — every note in the tree belongs to this repository's
|
|
25
25
|
* `contentPackage` (#56) — so routing is directory-agnostic: a file lands in a
|
|
26
|
-
* pack because of its `type`, not its location. Which packs exist
|
|
27
|
-
* folder hierarchy
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* pack because of its `type`, not its location. Which packs exist is declared
|
|
27
|
+
* in `package-build.config.yaml`; the **folder hierarchy is not declared
|
|
28
|
+
* anywhere**. A folder is a note like any other (#256), named by `packFolder`
|
|
29
|
+
* — its address — and it materialises in every pack that holds a document
|
|
30
|
+
* naming it, its ancestors with it (#257). So no pack loads a folder list, and
|
|
31
|
+
* two packs can no longer disagree about a folder they both hold.
|
|
30
32
|
*
|
|
31
33
|
* **The order the passes run in is derived, not declared** — see
|
|
32
34
|
* {@link orderPassesByDependency}. The declared list is the manifest's `packs`
|
|
@@ -51,14 +53,7 @@ import { Hm3Actors } from "../hm3/actors.mjs";
|
|
|
51
53
|
import { Macros } from "./macros.mjs";
|
|
52
54
|
import { Scenes } from "./scenes.mjs";
|
|
53
55
|
import { Bundles } from "./bundles.mjs";
|
|
54
|
-
import {
|
|
55
|
-
statsForPack,
|
|
56
|
-
loadFolders,
|
|
57
|
-
buildFolderResolver,
|
|
58
|
-
writeFolderDocs,
|
|
59
|
-
parseMarkdownFile,
|
|
60
|
-
folderFilename,
|
|
61
|
-
} from "./helpers.mjs";
|
|
56
|
+
import { statsForPack, parseMarkdownFile, folderFilename } from "./helpers.mjs";
|
|
62
57
|
import {
|
|
63
58
|
buildFolderNoteIndex,
|
|
64
59
|
collectFolderNotes,
|
|
@@ -73,7 +68,10 @@ import { buildCompileCorpus } from "./compile-corpus.mjs";
|
|
|
73
68
|
import { isNoteRecord, noteFile } from "./index-records.mjs";
|
|
74
69
|
import { loadPackConfig } from "./pack-config.mjs";
|
|
75
70
|
import { routerFor } from "./pack-router.mjs";
|
|
76
|
-
import { unclaimedNoteFindings } from "./note-claims.mjs";
|
|
71
|
+
import { NEVER_PACKED_TYPES, unclaimedNoteFindings } from "./note-claims.mjs";
|
|
72
|
+
// Which document a content type compiles into, so the art declaration below is
|
|
73
|
+
// answered from the same routing the compile uses (#349).
|
|
74
|
+
import { RETIRED_TYPES, currentType, packForType } from "./ids.mjs";
|
|
77
75
|
import { contentPackage } from "./content-package.mjs";
|
|
78
76
|
|
|
79
77
|
/**
|
|
@@ -138,6 +136,56 @@ export function compilerFor(docType, system = null) {
|
|
|
138
136
|
return (system && SYSTEM_COMPILERS[system]?.[docType]) || COMPILERS[docType];
|
|
139
137
|
}
|
|
140
138
|
|
|
139
|
+
/**
|
|
140
|
+
* The art fields a note of one content type reaches its document through, and
|
|
141
|
+
* the document it reaches (#349).
|
|
142
|
+
*
|
|
143
|
+
* **Derived, never listed.** A note's type routes to a document type
|
|
144
|
+
* ({@link packForType}), a document type routes to the pass that compiles it
|
|
145
|
+
* ({@link compilerFor}), and the pass declares which art it emits
|
|
146
|
+
* ({@link BasePackCompiler.emitsArt}). So the answer is assembled from the same
|
|
147
|
+
* three statements the compile itself follows, and a pass that starts or stops
|
|
148
|
+
* emitting art changes this by changing its own declaration. A second table of
|
|
149
|
+
* "types with no image" would be a table free to drift from what is emitted,
|
|
150
|
+
* which is the defect this exists to report rather than to reproduce.
|
|
151
|
+
*
|
|
152
|
+
* **The union across systems**, because a note is compiled by whichever pack
|
|
153
|
+
* claims it: a tree feeding both SoHL and HM3 has two Actor passes, and a field
|
|
154
|
+
* either of them emits is live for the note. Only a field *no* pass emits is
|
|
155
|
+
* inert, and that is the finding this supports.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} type - The note's content type.
|
|
158
|
+
* @returns {{document: string|null, art: readonly string[]}|null} What the type
|
|
159
|
+
* compiles into and the art it carries there, or `null` where no claim can be
|
|
160
|
+
* made — a retired type, which is reported as retired instead.
|
|
161
|
+
*/
|
|
162
|
+
export function emittedArtFor(type) {
|
|
163
|
+
const name = String(type ?? "");
|
|
164
|
+
if (!name || Object.hasOwn(RETIRED_TYPES, name)) return null;
|
|
165
|
+
|
|
166
|
+
// A homepage compiles into a *page*, not a compendium document, so nothing
|
|
167
|
+
// it authors reaches one. It is the one type whose absence from every pack
|
|
168
|
+
// is the intended state (`NEVER_PACKED_TYPES`).
|
|
169
|
+
if (NEVER_PACKED_TYPES.has(currentType(name))) return { document: null, art: [] };
|
|
170
|
+
|
|
171
|
+
// A folder reaches a pack by a route of its own — it materialises in every
|
|
172
|
+
// pack holding a document that references it — so `packForType` has no
|
|
173
|
+
// answer for it and no compiler class writes it. `folderDocument` does, and
|
|
174
|
+
// a Foundry `Folder` has no artwork at all.
|
|
175
|
+
if (currentType(name) === FOLDER_TYPE) return { document: "Folder", art: [] };
|
|
176
|
+
|
|
177
|
+
const { docType } = packForType(name);
|
|
178
|
+
const passes = [
|
|
179
|
+
COMPILERS[docType],
|
|
180
|
+
...Object.values(SYSTEM_COMPILERS).map((bySystem) => bySystem[docType]),
|
|
181
|
+
].filter(Boolean);
|
|
182
|
+
if (!passes.length) return null;
|
|
183
|
+
|
|
184
|
+
const art = new Set();
|
|
185
|
+
for (const pass of passes) for (const field of pass.emitsArt ?? []) art.add(field);
|
|
186
|
+
return { document: docType, art: Object.freeze([...art]) };
|
|
187
|
+
}
|
|
188
|
+
|
|
141
189
|
/**
|
|
142
190
|
* Root of the build-only JSON tree for one pack.
|
|
143
191
|
*
|
|
@@ -359,7 +407,7 @@ export function unsatisfiedPassDependencies(running, config) {
|
|
|
359
407
|
* count (0 on success) and the number of entries it wrote.
|
|
360
408
|
*/
|
|
361
409
|
async function generatePack(
|
|
362
|
-
{ name, type,
|
|
410
|
+
{ name, type, companions, system },
|
|
363
411
|
config,
|
|
364
412
|
router,
|
|
365
413
|
routingReporter,
|
|
@@ -380,41 +428,27 @@ async function generatePack(
|
|
|
380
428
|
|
|
381
429
|
log.info(`Pack ${name}: ${contentBase} → ${dest}`);
|
|
382
430
|
|
|
383
|
-
let folderList;
|
|
384
|
-
let yamlResolver;
|
|
385
|
-
try {
|
|
386
|
-
folderList = folders ? loadFolders(path.join(contentBase, folders)) : [];
|
|
387
|
-
({ resolver: yamlResolver } = buildFolderResolver(folderList));
|
|
388
|
-
} catch (err) {
|
|
389
|
-
log.error(`${name} ${folders} validation failed: ${err.message}`);
|
|
390
|
-
return { errors: 1, compiled: 0 };
|
|
391
|
-
}
|
|
392
|
-
|
|
393
431
|
// Which folder notes this pack turned out to hold something for. A folder
|
|
394
432
|
// materialises in every pack holding a document that references it, so the
|
|
395
|
-
// set is not knowable until the pass has compiled
|
|
396
|
-
// documents are written after `compile()`
|
|
397
|
-
// (#257).
|
|
433
|
+
// set is not knowable until the pass has compiled, which is why these
|
|
434
|
+
// documents are written after `compile()` (#257).
|
|
398
435
|
/** @type {Set<import("./folder-notes.mjs").FolderNote>} */
|
|
399
436
|
const referencedFolders = new Set();
|
|
400
437
|
|
|
401
438
|
/**
|
|
402
|
-
* The Foundry folder id a note names, by
|
|
439
|
+
* The Foundry folder id a note names, by its folder note's address.
|
|
403
440
|
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
441
|
+
* There is one spelling. `packFolder` names a folder **note**, resolved
|
|
442
|
+
* through the address index shared by the whole build; the `folder:`
|
|
443
|
+
* Foundry-id spelling and the per-pack `*-folders.yaml` it resolved
|
|
444
|
+
* against are retired together (#260), so there is no second source left
|
|
445
|
+
* for a value to come from.
|
|
409
446
|
*
|
|
410
447
|
* @param {string|null|undefined} value - As authored.
|
|
411
|
-
* @param {object} [opts]
|
|
412
|
-
* @param {boolean} [opts.isAddress] - Whether `value` is a folder address.
|
|
413
448
|
* @returns {string|null} The folder id, or `null` for an absent value.
|
|
414
449
|
*/
|
|
415
|
-
const resolver = (value
|
|
450
|
+
const resolver = (value) => {
|
|
416
451
|
if (value == null || value === "") return null;
|
|
417
|
-
if (!isAddress) return yamlResolver(value);
|
|
418
452
|
const folder = folderNotes.resolve(value);
|
|
419
453
|
// Its ancestors with it: a `Folder` whose parent is absent from the
|
|
420
454
|
// pack is an orphan Foundry renders at the root, so materialising a
|
|
@@ -444,10 +478,6 @@ async function generatePack(
|
|
|
444
478
|
companionDests[companion.name] = companionDest;
|
|
445
479
|
}
|
|
446
480
|
|
|
447
|
-
// A folder document belongs to the pack it is written into, so it carries
|
|
448
|
-
// that pack's system rather than the package-wide one (#48).
|
|
449
|
-
writeFolderDocs(folderList, statsForPack(system, config), dest, type);
|
|
450
|
-
|
|
451
481
|
const pack = new packClass({
|
|
452
482
|
contentBase,
|
|
453
483
|
dest,
|
|
@@ -681,11 +711,13 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
|
|
|
681
711
|
// which wins over the id it derives under the folder namespace.
|
|
682
712
|
// A record's `id` is not that: the index fills it in for every
|
|
683
713
|
// addressable note (#270), so handing records straight over
|
|
684
|
-
// would make every folder look pinned
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
//
|
|
714
|
+
// would make every folder look pinned. Since #310 the *value*
|
|
715
|
+
// would be right either way — the index derives a folder's id
|
|
716
|
+
// under the folder namespace, so the two agree — but `derivedId`
|
|
717
|
+
// would not, and it is what tells an author whether a duplicate
|
|
718
|
+
// id was two pins or two addresses. The index cannot tell a pin
|
|
719
|
+
// from a derivation, so the note is read — and only folder notes
|
|
720
|
+
// are, 79 of `sohl`'s 1,685 rather than all of them.
|
|
689
721
|
corpus.records
|
|
690
722
|
.filter(
|
|
691
723
|
(record) =>
|