@heroiclands/package-build 14.0.0 → 16.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 +327 -0
- package/CONTENT.md +331 -19
- package/MIGRATING.md +185 -0
- package/bin/content-build.mjs +54 -0
- package/content-config.mjs +10 -0
- package/docs/content-format.md +21 -0
- package/engine/content-address.mjs +21 -36
- package/engine/content-index.mjs +439 -0
- package/engine/field-reference.mjs +29 -0
- package/engine/field-spec.mjs +25 -0
- package/engine/frontmatter-lint.mjs +171 -1
- package/engine/helpers.mjs +38 -12
- package/engine/homepage.mjs +9 -4
- package/engine/index.mjs +3 -0
- package/engine/item-registry.mjs +4 -1
- package/engine/macros.mjs +3 -1
- package/engine/site-build.mjs +20 -14
- package/engine/system-block.mjs +29 -3
- package/package.json +1 -1
- package/sohl/actors.mjs +6 -3
- package/sohl/item-fields.mjs +6 -0
- package/sohl/items.mjs +4 -1
- package/types/content-config.d.mts +9 -0
- package/types/engine/content-address.d.mts +22 -34
- package/types/engine/content-index.d.mts +194 -0
- package/types/engine/field-spec.d.mts +53 -0
- package/types/engine/helpers.d.mts +34 -12
- package/types/engine/homepage.d.mts +8 -4
- package/types/engine/index.d.mts +1 -0
- package/types/engine/site-build.d.mts +11 -6
|
@@ -111,6 +111,34 @@ function fieldTable(fields) {
|
|
|
111
111
|
return padTable(rows);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The fields of one type that are **not** filled from the note's top level.
|
|
116
|
+
*
|
|
117
|
+
* A field ordinarily falls back to the top-level property spelled like its
|
|
118
|
+
* name, so an author who writes `weight: 3` at the top of a note reasonably
|
|
119
|
+
* expects it to reach the document. Where that spelling means something else at
|
|
120
|
+
* the note level the fallback is off, and an author has no way to tell from the
|
|
121
|
+
* table — the field is there, the value is written, and the document ships the
|
|
122
|
+
* default. So the reason each such field declares is rendered beside its table
|
|
123
|
+
* rather than left in the source (#218).
|
|
124
|
+
*
|
|
125
|
+
* Below the table, not inside it: the reason is a sentence or two, and
|
|
126
|
+
* {@link padTable} pads every column to its widest cell, so a cell holding it
|
|
127
|
+
* would stretch the whole type's table past legibility.
|
|
128
|
+
*
|
|
129
|
+
* @param {readonly object[]} fields - The type's declaration.
|
|
130
|
+
* @returns {string[]} Markdown lines, empty when the type exempts nothing.
|
|
131
|
+
*/
|
|
132
|
+
function sharedExemptions(fields) {
|
|
133
|
+
const exempt = authoredFields(fields).filter((field) => field.topLevelMeans);
|
|
134
|
+
if (!exempt.length) return [];
|
|
135
|
+
return exempt.flatMap((field) => [
|
|
136
|
+
`**\`${field.name}\` is not read from the note's top level.** There it means ` +
|
|
137
|
+
`${field.topLevelMeans}`,
|
|
138
|
+
"",
|
|
139
|
+
]);
|
|
140
|
+
}
|
|
141
|
+
|
|
114
142
|
/**
|
|
115
143
|
* A minimal note for one type: the frontmatter envelope every note carries,
|
|
116
144
|
* plus exactly the `sohl:` fields the type requires.
|
|
@@ -204,6 +232,7 @@ export function renderItemFieldReference({
|
|
|
204
232
|
"",
|
|
205
233
|
...fieldTable(declared[type]),
|
|
206
234
|
"",
|
|
235
|
+
...sharedExemptions(declared[type]),
|
|
207
236
|
...workedExample(type, declared[type]),
|
|
208
237
|
"",
|
|
209
238
|
);
|
package/engine/field-spec.mjs
CHANGED
|
@@ -65,6 +65,31 @@ export { setPath };
|
|
|
65
65
|
* still read, second, until #126 moves the corpus off it.
|
|
66
66
|
*
|
|
67
67
|
* Absent means the value is not authored at all — see `value`.
|
|
68
|
+
* @property {string} [topLevelMeans] - **What the note's top-level key of this
|
|
69
|
+
* name means instead** — declared only where it means something else, and
|
|
70
|
+
* stating it removes the shared top-level position from this field's
|
|
71
|
+
* resolution order (#218).
|
|
72
|
+
*
|
|
73
|
+
* A field's `name` doubles as its identity and as the shared property it
|
|
74
|
+
* draws from, which is right wherever the two levels state the same quantity
|
|
75
|
+
* — `data.weight` is the weight, whoever reads it. It is wrong wherever a
|
|
76
|
+
* spelling collides across the two vocabularies. An `affiliation` item's
|
|
77
|
+
* `system.title` is the style of address an office carries; a note's
|
|
78
|
+
* top-level `title` is the note's own heading. Nothing relates them, and
|
|
79
|
+
* before this key one silently fed the other, stringifying an authored
|
|
80
|
+
* `title: null` into fifteen documents.
|
|
81
|
+
*
|
|
82
|
+
* **The value is the reason**, not a flag with a comment beside it. A boolean
|
|
83
|
+
* would record the decision and lose the case for it, and the next person
|
|
84
|
+
* adding a field needs to know the question exists — this package's own rule
|
|
85
|
+
* that the declaration *is* the statement, never a description of one. The
|
|
86
|
+
* author-facing reference renders it, so an author reading the field table
|
|
87
|
+
* learns that the top-level key will not fill this field, and why.
|
|
88
|
+
*
|
|
89
|
+
* The exempted field is still authorable, at both of the positions that
|
|
90
|
+
* describe the *document* rather than the note: `<system>.system.<to>` and
|
|
91
|
+
* the legacy in-block `<system>.<name>`. Absent means the ordinary case —
|
|
92
|
+
* the top level is read, as the third step.
|
|
68
93
|
* @property {string} [shape] - Human-readable shape, for documentation. Comes
|
|
69
94
|
* paired with `read` from one of the coercion constants below.
|
|
70
95
|
* @property {(raw: any, ctx: {fm: object, field: FieldSpec}) => any} [read] -
|
|
@@ -56,7 +56,12 @@
|
|
|
56
56
|
*/
|
|
57
57
|
|
|
58
58
|
import { authoredFields } from "./field-spec.mjs";
|
|
59
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
resolveFieldValue,
|
|
61
|
+
systemBlock,
|
|
62
|
+
SYSTEM_BLOCK_KEYS,
|
|
63
|
+
unknownBlockKeys,
|
|
64
|
+
} from "./system-block.mjs";
|
|
60
65
|
import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
61
66
|
import { checkHomepageAddressFields } from "./homepage.mjs";
|
|
62
67
|
import { RETIRED_TYPES } from "./ids.mjs";
|
|
@@ -461,6 +466,121 @@ function checkTags(note, { type }) {
|
|
|
461
466
|
return findings;
|
|
462
467
|
}
|
|
463
468
|
|
|
469
|
+
/**
|
|
470
|
+
* The frontmatter fields that name artwork, and so resolve through
|
|
471
|
+
* {@link module:engine/helpers.resolveImg}.
|
|
472
|
+
*
|
|
473
|
+
* Both, always: a being carries `img` and `portrait` independently — the token
|
|
474
|
+
* art and the sheet portrait — and a rule about how the translator reads an
|
|
475
|
+
* empty value belongs to the translator, not to whichever key happens to be
|
|
476
|
+
* more common. Eleven `sohl-kethira-basic` beings write `portrait: ""` and no
|
|
477
|
+
* note in any tree writes `img: ""` on a being; a check keyed on `img` alone
|
|
478
|
+
* would have called that tree clean (#218).
|
|
479
|
+
*
|
|
480
|
+
* @type {readonly string[]}
|
|
481
|
+
*/
|
|
482
|
+
const ART_FIELDS = Object.freeze(["img", "portrait"]);
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Read a shared top-level field the way the compiler reads one: the `sohl:`
|
|
486
|
+
* block first, then the note's top level.
|
|
487
|
+
*
|
|
488
|
+
* The same order {@link module:engine/helpers.sohlField} uses, restated here
|
|
489
|
+
* rather than imported so this module stays a leaf the linter can load without
|
|
490
|
+
* a resolved build configuration. Unlike `sohlField` it distinguishes the two
|
|
491
|
+
* empties — an authored `""` comes back as `""` and an authored `null` as
|
|
492
|
+
* `null` — which is the whole point of the caller below (#218).
|
|
493
|
+
*
|
|
494
|
+
* @param {object|null|undefined} fm - Parsed frontmatter.
|
|
495
|
+
* @param {string} key - The field name.
|
|
496
|
+
* @returns {any} The authored value, or `undefined` where neither position
|
|
497
|
+
* declares one.
|
|
498
|
+
*/
|
|
499
|
+
function authoredValue(fm, key) {
|
|
500
|
+
const block = fm?.sohl;
|
|
501
|
+
if (block && typeof block === "object" && !Array.isArray(block) && Object.hasOwn(block, key)) {
|
|
502
|
+
return block[key];
|
|
503
|
+
}
|
|
504
|
+
return fm && Object.hasOwn(fm, key) ? fm[key] : undefined;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Two embedded items on one actor may not share `(type, shortcode)` (#228).
|
|
509
|
+
*
|
|
510
|
+
* SoHL treats `(type, shortcode)` as a **logical identity**, not a lookup
|
|
511
|
+
* convenience: two documents of one type bearing one shortcode denote *the same
|
|
512
|
+
* entity*, whatever their `_id`s or field values. It is unique within four
|
|
513
|
+
* scopes, one of which is an actor's own embedded items — and the invariant
|
|
514
|
+
* exists to keep that identity well-defined. Two colliding entries make "the
|
|
515
|
+
* same thing" ambiguous, and every match resolving by it — compendium↔world
|
|
516
|
+
* reconciliation, archetype shadowing, `fvttFindItemByShortcode`, cohort
|
|
517
|
+
* membership, expression and effect references — becomes unsound.
|
|
518
|
+
*
|
|
519
|
+
* Nothing else catches it. The compiler resolves each entry independently and
|
|
520
|
+
* distinguishes the two only when seeding `_id`, so the collision compiles to
|
|
521
|
+
* two documents with distinct ids and ships unremarked.
|
|
522
|
+
*
|
|
523
|
+
* **Decidable from frontmatter alone**, which is why it belongs here rather
|
|
524
|
+
* than in the compiler. An entry's effective key is
|
|
525
|
+
* `system.shortcode ?? shortcode`: a top-level `shortcode` merely selects the
|
|
526
|
+
* template the entry is written from and is never written to the document,
|
|
527
|
+
* while a template's own `system.shortcode` is its address by construction. So
|
|
528
|
+
* neither the catalogue nor a compile is needed to know what an entry will
|
|
529
|
+
* carry.
|
|
530
|
+
*
|
|
531
|
+
* Only entries naming both a type and a key are compared. One naming neither —
|
|
532
|
+
* a stand-alone entry still missing its `system.shortcode` — is the compiler's
|
|
533
|
+
* finding to make, and reporting it twice helps nobody.
|
|
534
|
+
*
|
|
535
|
+
* @param {object} note - A note from the link index (`{fm, file, raw}`).
|
|
536
|
+
* @param {string} blockName - The system block whose `items` to check.
|
|
537
|
+
* @returns {object[]} One finding per collision, at the later entry.
|
|
538
|
+
*/
|
|
539
|
+
function checkEmbeddedShortcodes(note, blockName) {
|
|
540
|
+
const findings = [];
|
|
541
|
+
const block = systemBlock(note.fm ?? {}, blockName);
|
|
542
|
+
const entries = block?.items;
|
|
543
|
+
if (!Array.isArray(entries)) return findings;
|
|
544
|
+
|
|
545
|
+
/** `type\0key` → the index that claimed it first. */
|
|
546
|
+
const claimed = new Map();
|
|
547
|
+
entries.forEach((entry, index) => {
|
|
548
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return;
|
|
549
|
+
const type = entry.type;
|
|
550
|
+
const system = entry.system;
|
|
551
|
+
const key =
|
|
552
|
+
system && typeof system === "object" && !Array.isArray(system) ?
|
|
553
|
+
(system.shortcode ?? entry.shortcode)
|
|
554
|
+
: entry.shortcode;
|
|
555
|
+
if (!type || !key) return;
|
|
556
|
+
|
|
557
|
+
const address = `${type}${key}`;
|
|
558
|
+
const first = claimed.get(address);
|
|
559
|
+
if (first === undefined) {
|
|
560
|
+
claimed.set(address, index);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
findings.push({
|
|
564
|
+
file: note.file,
|
|
565
|
+
...positionOfFrontmatterPath(note.raw ?? "", [blockName, "items", index]),
|
|
566
|
+
severity: "error",
|
|
567
|
+
message:
|
|
568
|
+
`"${type}:${key}" is already the shortcode of ` +
|
|
569
|
+
`\`${blockName}.items[${first}]\` on this actor; ` +
|
|
570
|
+
`(type, shortcode) identifies *which entity* an item is, and ` +
|
|
571
|
+
`must be unique among an actor's embedded items, so the two ` +
|
|
572
|
+
`denote one thing and every lookup by it is ambiguous. Give ` +
|
|
573
|
+
`this entry its own \`system.shortcode\`` +
|
|
574
|
+
(entry.shortcode && !entry.system?.shortcode ?
|
|
575
|
+
` — a top-level \`shortcode\` only selects the template ` +
|
|
576
|
+
`this entry is written from and never reaches the document`
|
|
577
|
+
: "") +
|
|
578
|
+
`, or delete it if it is a duplicate.`,
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
return findings;
|
|
582
|
+
}
|
|
583
|
+
|
|
464
584
|
/**
|
|
465
585
|
* Check one note against its type's schema.
|
|
466
586
|
*
|
|
@@ -503,6 +623,53 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
|
|
|
503
623
|
"note in the tree belongs to it",
|
|
504
624
|
});
|
|
505
625
|
}
|
|
626
|
+
// `img: ""` was how a note said "I name no art" while `resolveImg`
|
|
627
|
+
// conflated the two empties and every caller defaulted with `||`. It now
|
|
628
|
+
// says the opposite — "ship no art, and do not default me" (#218) — so a
|
|
629
|
+
// note carrying the old spelling has quietly changed meaning. Forty-five
|
|
630
|
+
// `sohl-thalorna` notes were written under the old reading and would have
|
|
631
|
+
// lost their default art with no error and no warning; this is the guard
|
|
632
|
+
// that would have caught them.
|
|
633
|
+
//
|
|
634
|
+
// **Both art fields, because both go through `resolveImg`.** `portrait` is
|
|
635
|
+
// not a variant spelling of `img` — a being carries the two independently —
|
|
636
|
+
// and checking only the more common one is how the sweep that prompted this
|
|
637
|
+
// guard missed eleven `sohl-kethira-basic` beings that write
|
|
638
|
+
// `portrait: ""`. Whatever the rule is, it belongs to the function, not to
|
|
639
|
+
// one of the keys that reaches it.
|
|
640
|
+
//
|
|
641
|
+
// A **warning**, on the pattern the `package:` and retired-alias sweeps
|
|
642
|
+
// set: the note still compiles, to a document that is merely iconless, so
|
|
643
|
+
// reddening a tree over it would refuse before the sweep rather than after
|
|
644
|
+
// it. It is transitional in the same sense — `""` is a legal thing to mean,
|
|
645
|
+
// and the message says so, but nothing in any tree means it yet.
|
|
646
|
+
//
|
|
647
|
+
// **These two only, never `title`.** The rule reads as a general one about
|
|
648
|
+
// optional strings, and it is not — it belongs to `resolveImg`, and `title`
|
|
649
|
+
// never goes through it.
|
|
650
|
+
//
|
|
651
|
+
// It once had a sharper reason, recorded here because it was load-bearing
|
|
652
|
+
// and is now false: a note's top-level `title` was simultaneously the shared
|
|
653
|
+
// source for an `affiliation` item's `system.title`, so asking an author for
|
|
654
|
+
// `title: null` would have compiled the literal string `"null"` into the
|
|
655
|
+
// document. That collision is gone — the field declares `topLevelMeans` and
|
|
656
|
+
// the top-level key is no longer a source for it — so `title: null` is now
|
|
657
|
+
// harmless. Whether `title: ""` deserves a warning of its own is a separate
|
|
658
|
+
// question about the *page's* heading, still open on #218, and not settled
|
|
659
|
+
// by extending an art-path check to it.
|
|
660
|
+
for (const key of ART_FIELDS) {
|
|
661
|
+
if (authoredValue(fm, key) !== "") continue;
|
|
662
|
+
findings.push({
|
|
663
|
+
file: note.file,
|
|
664
|
+
...at(key),
|
|
665
|
+
severity: "warning",
|
|
666
|
+
message:
|
|
667
|
+
`\`${key}: ""\` means "ship no art at all" — it no longer falls ` +
|
|
668
|
+
`back to this type's default. Write \`${key}: null\` for a note ` +
|
|
669
|
+
'that simply names none; keep `""` only where the document is ' +
|
|
670
|
+
"meant to have no image",
|
|
671
|
+
});
|
|
672
|
+
}
|
|
506
673
|
if (Object.hasOwn(fm, "draft")) {
|
|
507
674
|
findings.push({
|
|
508
675
|
file: note.file,
|
|
@@ -647,6 +814,9 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
|
|
|
647
814
|
// for `sohl`, the note type's own field names, which are still the position
|
|
648
815
|
// the corpus authors them at until #126 moves them.
|
|
649
816
|
for (const [blockName, spec] of Object.entries(systems ?? {})) {
|
|
817
|
+
// Two embedded items denoting one entity (#228). Per block, because
|
|
818
|
+
// `items` is a block key and a second system's actor carries its own.
|
|
819
|
+
findings.push(...checkEmbeddedShortcodes(note, blockName));
|
|
650
820
|
const accepted = new Set([
|
|
651
821
|
...UNIVERSAL_KEYS,
|
|
652
822
|
...(spec?.known ?? []),
|
package/engine/helpers.mjs
CHANGED
|
@@ -234,25 +234,51 @@ export function makeFilename(name, id) {
|
|
|
234
234
|
* asset roots — `icons/...` and `images/...` — are served from the package
|
|
235
235
|
* directory, so they are rewritten to `<assetRoot>/<path>` — `systems/sohl/assets`
|
|
236
236
|
* for this repository, `modules/<id>/assets` for a module (#1508). Any other
|
|
237
|
-
* path (already package-rooted, an absolute URL) is returned unchanged
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
* `
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
237
|
+
* path (already package-rooted, an absolute URL) is returned unchanged.
|
|
238
|
+
*
|
|
239
|
+
* **Two empties, and they mean opposite things (#218).** `null` — or an absent
|
|
240
|
+
* key, which reaches here as `undefined` — means _unset_: the note names no art
|
|
241
|
+
* and the caller's default applies. `""` means _blank on purpose_: the note
|
|
242
|
+
* names no art **and wants none**, so no default may replace it. Both come back
|
|
243
|
+
* distinguishable, `null` and `""` respectively, and neither is invented from
|
|
244
|
+
* the other.
|
|
245
|
+
*
|
|
246
|
+
* This used to open `if (!raw) return ""`, which made the two one case: every
|
|
247
|
+
* caller then applied its default with `||`, so a deliberate blank was
|
|
248
|
+
* unspellable and an unset key and an empty string compiled identically. That
|
|
249
|
+
* is the convention the project already rejects for an optional "not specified"
|
|
250
|
+
* DataModel string, where `nullable, initial: null` keeps "unset" a single
|
|
251
|
+
* honest value rather than two.
|
|
252
|
+
*
|
|
253
|
+
* **`title` does not follow this rule**, and must not be made to. On a
|
|
254
|
+
* `type: affiliation` note `title` is *also* a declared item field whose default
|
|
255
|
+
* is `""` (`sohl/item-fields.mjs`), resolved from the very same shared top-level
|
|
256
|
+
* key the site emitter reads as the page title — so `title: null` stringifies
|
|
257
|
+
* into the compiled document as the literal `"null"`. One key, two destinations
|
|
258
|
+
* that disagree about what empty means; see #218.
|
|
259
|
+
*
|
|
260
|
+
* This is translation only: the default for an unset path is domain-specific
|
|
261
|
+
* (actors default differently from items, and gear differently again), so each
|
|
262
|
+
* compiler owns its own default and applies it to the result with **nullish**
|
|
263
|
+
* coalescing — `resolveImg(fm.img) ?? <default>`. Not `||`: that would collapse
|
|
264
|
+
* a deliberate blank back into the default and undo the distinction. For items
|
|
265
|
+
* that default is the art paired with the type's builder, reached through
|
|
266
|
+
* `itemArt()`, which runs the path back through this function so a registry
|
|
267
|
+
* entry and a note's `img:` are spelled the same way (#7).
|
|
247
268
|
*
|
|
248
269
|
* @param {string | null | undefined} raw - content-relative path from frontmatter.
|
|
249
270
|
* @param {{assetRoot: string}} [config] - The resolved build configuration.
|
|
250
271
|
* Defaults to this repository's.
|
|
251
|
-
* @returns {string} the Foundry-relative path
|
|
272
|
+
* @returns {string | null} the Foundry-relative path; `""` for a deliberate
|
|
273
|
+
* blank, and `null` when the note names no art at all.
|
|
252
274
|
*/
|
|
253
275
|
export function resolveImg(raw, config = loadPackConfig()) {
|
|
254
|
-
|
|
276
|
+
// Unset — the caller's default applies. An absent key arrives as
|
|
277
|
+
// `undefined`, an authored one as `null`; they say the same thing.
|
|
278
|
+
if (raw == null) return null;
|
|
255
279
|
const s = String(raw);
|
|
280
|
+
// Blank on purpose — the caller's default must not apply.
|
|
281
|
+
if (s === "") return "";
|
|
256
282
|
if (s.startsWith("icons/") || s.startsWith("images/")) {
|
|
257
283
|
return `${config.assetRoot}/${s}`;
|
|
258
284
|
}
|
package/engine/homepage.mjs
CHANGED
|
@@ -371,6 +371,13 @@ export function homepageTitle(fm, config) {
|
|
|
371
371
|
* for one; it decides nothing while `url` is present, but a page carrying only
|
|
372
372
|
* `url` would report a slug Hugo had inferred from the filename.
|
|
373
373
|
*
|
|
374
|
+
* **Site-root relative, and so carrying no package base** (#217), exactly as
|
|
375
|
+
* `pageFrontmatter` states a content page's: Hugo resolves a `url`
|
|
376
|
+
* against `baseURL`, whose path is already where the package is served, so a
|
|
377
|
+
* stated base was written twice and published the landing at
|
|
378
|
+
* `/<package>/<package>/homepage-root/`. Where the package is served is what
|
|
379
|
+
* every *href* is composed from and it reaches this page's address not at all.
|
|
380
|
+
*
|
|
374
381
|
* An authored `aliases` is dropped for the same reason it is on every other
|
|
375
382
|
* page: Hugo reads it as URL redirects, so passing it through would publish a
|
|
376
383
|
* redirect stub at each one. The field is retired (#180) and refused before a
|
|
@@ -380,14 +387,12 @@ export function homepageTitle(fm, config) {
|
|
|
380
387
|
* @param {object} options - Options.
|
|
381
388
|
* @param {string} options.contentPackage - The package this build publishes.
|
|
382
389
|
* @param {string} options.title - The resolved title.
|
|
383
|
-
* @param {string} options.base - Where the package is served, with both
|
|
384
|
-
* slashes — `/<package>/`.
|
|
385
390
|
* @returns {object} The frontmatter to write.
|
|
386
391
|
* @throws {Error} When the note declares no shortcode, and so has no address.
|
|
387
392
|
*/
|
|
388
|
-
export function homepageFrontmatter(fm, { contentPackage, title
|
|
393
|
+
export function homepageFrontmatter(fm, { contentPackage, title }) {
|
|
389
394
|
const slug = addressSlug(fm);
|
|
390
|
-
const data = { ...fm, package: contentPackage, title, slug, url:
|
|
395
|
+
const data = { ...fm, package: contentPackage, title, slug, url: `/${slug}/` };
|
|
391
396
|
delete data.aliases;
|
|
392
397
|
return data;
|
|
393
398
|
}
|
package/engine/index.mjs
CHANGED
|
@@ -92,6 +92,9 @@ export * as kbManifest from "./kb-manifest.mjs";
|
|
|
92
92
|
/** Deriving this package's own link manifest from its content tree. */
|
|
93
93
|
export * as manifestEmit from "./manifest-emit.mjs";
|
|
94
94
|
|
|
95
|
+
/** Publishing the note tree as a queryable JSON Lines index. */
|
|
96
|
+
export * as contentIndex from "./content-index.mjs";
|
|
97
|
+
|
|
95
98
|
/** Publishing a content tree as a website: the pass, and its integrity gates. */
|
|
96
99
|
export * as siteBuild from "./site-build.mjs";
|
|
97
100
|
|
package/engine/item-registry.mjs
CHANGED
|
@@ -188,5 +188,8 @@ export function itemArt(type, system) {
|
|
|
188
188
|
`note an \`img:\` of its own.`,
|
|
189
189
|
);
|
|
190
190
|
}
|
|
191
|
-
|
|
191
|
+
// `art` is non-empty by the guard above, so the translation never returns
|
|
192
|
+
// the `null` a note's unset `img:` would (#218); the coalesce states that
|
|
193
|
+
// rather than leaving the declared `string` return a half-truth.
|
|
194
|
+
return resolveImg(art) ?? "";
|
|
192
195
|
}
|
package/engine/macros.mjs
CHANGED
|
@@ -261,7 +261,9 @@ export function buildMacroEntry(fm, { command, folder = null, stats = defaultSta
|
|
|
261
261
|
// into chat instead of running.
|
|
262
262
|
type: resolveMacroType(fm, name),
|
|
263
263
|
author: null,
|
|
264
|
-
|
|
264
|
+
// Nullish, not `||` (#218): a macro note that names no art gets the
|
|
265
|
+
// shared default, one that writes `img: ""` ships blank on purpose.
|
|
266
|
+
img: resolveImg(fm.img) ?? DEFAULT_MACRO_IMG,
|
|
265
267
|
scope: resolveMacroScope(fm, name),
|
|
266
268
|
command,
|
|
267
269
|
folder,
|
package/engine/site-build.mjs
CHANGED
|
@@ -343,18 +343,13 @@ export function collectHomepages(contentBase, ctx) {
|
|
|
343
343
|
* @param {readonly object[]} pages - From {@link collectHomepages}.
|
|
344
344
|
* @param {object} config - The resolved configuration, for the package name and
|
|
345
345
|
* the default title.
|
|
346
|
-
* @param {object} [options] - Options.
|
|
347
|
-
* @param {string} [options.base] - Where the package is served; defaults to the
|
|
348
|
-
* configured `site.base`, and to `/<contentPackage>/` below that.
|
|
349
346
|
* @returns {number} How many pages were written.
|
|
350
347
|
*/
|
|
351
|
-
export function writeHomepages(outRoot, pages, config
|
|
352
|
-
const at = base || config.site?.base || `/${config.contentPackage}/`;
|
|
348
|
+
export function writeHomepages(outRoot, pages, config) {
|
|
353
349
|
for (const page of pages) {
|
|
354
350
|
const data = homepageFrontmatter(page.fm, {
|
|
355
351
|
contentPackage: config.contentPackage,
|
|
356
352
|
title: homepageTitle(page.fm, config),
|
|
357
|
-
base: at,
|
|
358
353
|
});
|
|
359
354
|
const dest = path.join(outRoot, homepageDestination(page.fm));
|
|
360
355
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
@@ -544,6 +539,16 @@ export function sectionFrontmatter(meta) {
|
|
|
544
539
|
* package-wide address the link manifest records — the same address, one
|
|
545
540
|
* segment too deep. So the address is stated and the mount does not reach it.
|
|
546
541
|
*
|
|
542
|
+
* **It is stated relative to the site root, and so carries no package base**
|
|
543
|
+
* (#217). Hugo resolves a `url` against `baseURL`, whose path is already where
|
|
544
|
+
* the package is served — a consumer's Hugo site *is* its package — so writing
|
|
545
|
+
* `page.url`, which carries the base for every href this build renders, wrote
|
|
546
|
+
* that base a second time and published every content page a segment too deep
|
|
547
|
+
* (`/sohl/sohl/doc-rulesintro/`). The two are separate quantities: the page
|
|
548
|
+
* states `/<slug>/`, and everything that points *at* the page — the address
|
|
549
|
+
* index a wikilink resolves through, and the link manifest — composes
|
|
550
|
+
* `<base><slug>/`.
|
|
551
|
+
*
|
|
547
552
|
* A content page carries the package the build **derived** (#65). No note
|
|
548
553
|
* declares one — `package:` is retired (#56) — so the note's frontmatter alone
|
|
549
554
|
* would publish a page that does not say which package it belongs to. The
|
|
@@ -571,13 +576,14 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
|
|
|
571
576
|
// Spread after the note's own frontmatter. Guarded because
|
|
572
577
|
// `package: undefined` is not a value YAML can carry.
|
|
573
578
|
...(page.pkg ? { package: page.pkg } : {}),
|
|
574
|
-
// The address, stated
|
|
575
|
-
// the
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
+
// The address, stated — site-root relative, because Hugo prefixes
|
|
580
|
+
// the site's own base to it (#217). `slug` is written beside it
|
|
581
|
+
// because it is the last segment of that address and Hugo's own key
|
|
582
|
+
// for one; it decides nothing while `url` is present, but a page
|
|
583
|
+
// that carried only `url` would report a slug Hugo had inferred
|
|
584
|
+
// from the filename.
|
|
579
585
|
slug,
|
|
580
|
-
url:
|
|
586
|
+
url: `/${slug}/`,
|
|
581
587
|
title: fm.title ?? name,
|
|
582
588
|
kbfolder: page.folder,
|
|
583
589
|
};
|
|
@@ -993,7 +999,7 @@ export function buildSite({ config, outRoot } = {}) {
|
|
|
993
999
|
tableErrors: [],
|
|
994
1000
|
wikiErrors: [],
|
|
995
1001
|
stats: {
|
|
996
|
-
homepages: writeHomepages(homeRoot, homepages, resolved
|
|
1002
|
+
homepages: writeHomepages(homeRoot, homepages, resolved),
|
|
997
1003
|
landings: 0,
|
|
998
1004
|
out: homeRoot,
|
|
999
1005
|
},
|
|
@@ -1078,7 +1084,7 @@ export function buildSite({ config, outRoot } = {}) {
|
|
|
1078
1084
|
|
|
1079
1085
|
// Last, and outside the mount: the package's front page is not part of the
|
|
1080
1086
|
// content tree it introduces.
|
|
1081
|
-
const homepagesWritten = writeHomepages(homeRoot, homepages, resolved
|
|
1087
|
+
const homepagesWritten = writeHomepages(homeRoot, homepages, resolved);
|
|
1082
1088
|
|
|
1083
1089
|
return {
|
|
1084
1090
|
gates,
|
package/engine/system-block.mjs
CHANGED
|
@@ -52,6 +52,28 @@
|
|
|
52
52
|
* may be a dotted path (`data.portrait`) rather than a sibling key;
|
|
53
53
|
* 4. the field's own default.
|
|
54
54
|
*
|
|
55
|
+
* ## A name that collides across the two vocabularies skips step 3
|
|
56
|
+
*
|
|
57
|
+
* A field's `name` is both its identity and the shared property it draws from,
|
|
58
|
+
* and those coincide only while the two vocabularies agree about what the
|
|
59
|
+
* spelling means. They do not always. An `affiliation` item's `system.title` is
|
|
60
|
+
* the style of address an office carries — "Ajaw", "Warden"; a note's top-level
|
|
61
|
+
* `title` is the note's own heading, which the site emitter publishes. Two
|
|
62
|
+
* unrelated quantities, one spelling, and step 3 fed the first from the second.
|
|
63
|
+
*
|
|
64
|
+
* It was not a harmless coincidence either, because step 3 answers **without**
|
|
65
|
+
* applying `field.default` — only step 2 does — so an authored `title: null`
|
|
66
|
+
* reached the field's coercion unguarded and shipped as the literal string
|
|
67
|
+
* `"null"` in fifteen documents (#218).
|
|
68
|
+
*
|
|
69
|
+
* So a field may declare `topLevelMeans`: what the top-level key of that name
|
|
70
|
+
* means *instead*. Declaring it removes step 3 for that field, leaving the two
|
|
71
|
+
* positions that describe the document rather than the note. It is deliberately
|
|
72
|
+
* a per-field opt-out rather than a change to the order — step 3 is right
|
|
73
|
+
* wherever the two levels state the same quantity, which is nearly everywhere —
|
|
74
|
+
* and its value is the reason rather than a bare flag, so the collision is
|
|
75
|
+
* legible where the field is declared. See {@link module:engine/field-spec.FieldSpec}.
|
|
76
|
+
*
|
|
55
77
|
* `sohlField()` — read `fm.sohl[key]`, fall back to `fm[key]` — is the
|
|
56
78
|
* degenerate case where source and destination happen to share a name. It stops
|
|
57
79
|
* being the general rule; {@link blockField} is what remains of it.
|
|
@@ -347,9 +369,13 @@ export function resolveFieldValue(field, fm, { block = "sohl" } = {}) {
|
|
|
347
369
|
if (nested !== undefined) return { value: nested, from: "block" };
|
|
348
370
|
}
|
|
349
371
|
|
|
350
|
-
// 3. The shared property this field declares as its source
|
|
351
|
-
|
|
352
|
-
|
|
372
|
+
// 3. The shared property this field declares as its source — unless the
|
|
373
|
+
// field declares that the top-level key of that name means something
|
|
374
|
+
// else, in which case there is no shared position to read (#218).
|
|
375
|
+
if (field.topLevelMeans === undefined) {
|
|
376
|
+
const shared = getFrontmatter(fm, field.name, undefined);
|
|
377
|
+
if (shared !== undefined) return { value: shared, from: "shared" };
|
|
378
|
+
}
|
|
353
379
|
|
|
354
380
|
// 4. The field's own default.
|
|
355
381
|
return { value: field.default, from: "default" };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "16.0.0",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
package/sohl/actors.mjs
CHANGED
|
@@ -763,7 +763,9 @@ export class Actors extends BasePackCompiler {
|
|
|
763
763
|
// Required nullable number: a priority, or `null` for a being that
|
|
764
764
|
// is not an archetype (#126 / archetype contract #604).
|
|
765
765
|
archetype: systemArchetype(fm, ctx),
|
|
766
|
-
|
|
766
|
+
// Nullish, not `||` (#218): a note that names no portrait gets the
|
|
767
|
+
// subtype's default, one that writes `""` ships blank on purpose.
|
|
768
|
+
portrait: resolveImg(blockProperty(fm, SYSTEM, "portrait")) ?? defaultImg,
|
|
767
769
|
appearance: renderSection(body || "", "appearance"),
|
|
768
770
|
dossier: renderSection(body || "", "dossier"),
|
|
769
771
|
};
|
|
@@ -815,7 +817,8 @@ export class Actors extends BasePackCompiler {
|
|
|
815
817
|
return {
|
|
816
818
|
name,
|
|
817
819
|
type: subType,
|
|
818
|
-
|
|
820
|
+
// Nullish, not `||` — see the portrait above (#218).
|
|
821
|
+
img: resolveImg(blockProperty(fm, SYSTEM, "img")) ?? defaultImg,
|
|
819
822
|
_id: id,
|
|
820
823
|
system,
|
|
821
824
|
items,
|
|
@@ -823,7 +826,7 @@ export class Actors extends BasePackCompiler {
|
|
|
823
826
|
name,
|
|
824
827
|
displayName: 0,
|
|
825
828
|
actorLink: false,
|
|
826
|
-
texture: { src: resolveImg(blockProperty(fm, SYSTEM, "img"))
|
|
829
|
+
texture: { src: resolveImg(blockProperty(fm, SYSTEM, "img")) ?? defaultImg },
|
|
827
830
|
width: 1,
|
|
828
831
|
height: 1,
|
|
829
832
|
sight: { enabled: false },
|
package/sohl/item-fields.mjs
CHANGED
|
@@ -265,6 +265,12 @@ export const ITEM_FIELDS = Object.freeze({
|
|
|
265
265
|
to: "title",
|
|
266
266
|
...STRING,
|
|
267
267
|
default: "",
|
|
268
|
+
topLevelMeans:
|
|
269
|
+
"the note's own title — the heading its page is published under, " +
|
|
270
|
+
"which has nothing to do with the style of address an office " +
|
|
271
|
+
"carries. Author this on the membership instead: `sohl.system.title` " +
|
|
272
|
+
"on the item, or the `system.title` of the entry in a being's " +
|
|
273
|
+
"`sohl.items`.",
|
|
268
274
|
describe: "The style of address the office carries.",
|
|
269
275
|
},
|
|
270
276
|
{
|
package/sohl/items.mjs
CHANGED
|
@@ -255,7 +255,10 @@ export class Items extends BasePackCompiler {
|
|
|
255
255
|
// both registries are keyed by content type — while the document's
|
|
256
256
|
// own subtype comes from the system's map (#79).
|
|
257
257
|
type: subType,
|
|
258
|
-
|
|
258
|
+
// Nullish, not `||` (#218): `resolveImg` returns `null` for a note
|
|
259
|
+
// that names no art and `""` for one that wants none, and only the
|
|
260
|
+
// first may be replaced by the type's default.
|
|
261
|
+
img: resolveImg(blockProperty(fm, SYSTEM, "img")) ?? itemArt(type, SYSTEM),
|
|
259
262
|
_id: id,
|
|
260
263
|
system,
|
|
261
264
|
effects: Array.isArray(effects) ? [...effects] : [],
|
|
@@ -41,6 +41,7 @@ export namespace DEFAULT_PATHS {
|
|
|
41
41
|
let content: "assets/content";
|
|
42
42
|
let manifests: "assets/manifests";
|
|
43
43
|
let manifestOut: "build/manifests";
|
|
44
|
+
let contentIndex: "build/content-index";
|
|
44
45
|
let packJson: "build/packs-json";
|
|
45
46
|
let stage: "build/stage/packs";
|
|
46
47
|
let unpack: "build/tmp/packs";
|
|
@@ -296,6 +297,13 @@ export type PathsInput = {
|
|
|
296
297
|
* `manifests` directory.
|
|
297
298
|
*/
|
|
298
299
|
manifestOut?: string | undefined;
|
|
300
|
+
/**
|
|
301
|
+
* Where `content-index` writes this
|
|
302
|
+
* package's note index. Outbound, and a
|
|
303
|
+
* derived artifact — never a source, and
|
|
304
|
+
* never inside `stage`.
|
|
305
|
+
*/
|
|
306
|
+
contentIndex?: string | undefined;
|
|
299
307
|
/**
|
|
300
308
|
* Build-only per-entry JSON intermediate.
|
|
301
309
|
*/
|
|
@@ -316,6 +324,7 @@ export type ResolvedPaths = {
|
|
|
316
324
|
content: string;
|
|
317
325
|
manifests: string;
|
|
318
326
|
manifestOut: string;
|
|
327
|
+
contentIndex: string;
|
|
319
328
|
packJson: string;
|
|
320
329
|
stage: string;
|
|
321
330
|
unpack: string;
|