@heroiclands/package-build 14.0.0 → 15.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 +184 -0
- package/CONTENT.md +128 -19
- package/MIGRATING.md +185 -0
- package/docs/content-format.md +21 -0
- package/engine/field-reference.mjs +29 -0
- package/engine/field-spec.mjs +25 -0
- package/engine/frontmatter-lint.mjs +85 -0
- package/engine/helpers.mjs +38 -12
- package/engine/homepage.mjs +9 -4
- 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/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/site-build.d.mts +11 -6
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] -
|
|
@@ -461,6 +461,44 @@ function checkTags(note, { type }) {
|
|
|
461
461
|
return findings;
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
+
/**
|
|
465
|
+
* The frontmatter fields that name artwork, and so resolve through
|
|
466
|
+
* {@link module:engine/helpers.resolveImg}.
|
|
467
|
+
*
|
|
468
|
+
* Both, always: a being carries `img` and `portrait` independently — the token
|
|
469
|
+
* art and the sheet portrait — and a rule about how the translator reads an
|
|
470
|
+
* empty value belongs to the translator, not to whichever key happens to be
|
|
471
|
+
* more common. Eleven `sohl-kethira-basic` beings write `portrait: ""` and no
|
|
472
|
+
* note in any tree writes `img: ""` on a being; a check keyed on `img` alone
|
|
473
|
+
* would have called that tree clean (#218).
|
|
474
|
+
*
|
|
475
|
+
* @type {readonly string[]}
|
|
476
|
+
*/
|
|
477
|
+
const ART_FIELDS = Object.freeze(["img", "portrait"]);
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Read a shared top-level field the way the compiler reads one: the `sohl:`
|
|
481
|
+
* block first, then the note's top level.
|
|
482
|
+
*
|
|
483
|
+
* The same order {@link module:engine/helpers.sohlField} uses, restated here
|
|
484
|
+
* rather than imported so this module stays a leaf the linter can load without
|
|
485
|
+
* a resolved build configuration. Unlike `sohlField` it distinguishes the two
|
|
486
|
+
* empties — an authored `""` comes back as `""` and an authored `null` as
|
|
487
|
+
* `null` — which is the whole point of the caller below (#218).
|
|
488
|
+
*
|
|
489
|
+
* @param {object|null|undefined} fm - Parsed frontmatter.
|
|
490
|
+
* @param {string} key - The field name.
|
|
491
|
+
* @returns {any} The authored value, or `undefined` where neither position
|
|
492
|
+
* declares one.
|
|
493
|
+
*/
|
|
494
|
+
function authoredValue(fm, key) {
|
|
495
|
+
const block = fm?.sohl;
|
|
496
|
+
if (block && typeof block === "object" && !Array.isArray(block) && Object.hasOwn(block, key)) {
|
|
497
|
+
return block[key];
|
|
498
|
+
}
|
|
499
|
+
return fm && Object.hasOwn(fm, key) ? fm[key] : undefined;
|
|
500
|
+
}
|
|
501
|
+
|
|
464
502
|
/**
|
|
465
503
|
* Check one note against its type's schema.
|
|
466
504
|
*
|
|
@@ -503,6 +541,53 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
|
|
|
503
541
|
"note in the tree belongs to it",
|
|
504
542
|
});
|
|
505
543
|
}
|
|
544
|
+
// `img: ""` was how a note said "I name no art" while `resolveImg`
|
|
545
|
+
// conflated the two empties and every caller defaulted with `||`. It now
|
|
546
|
+
// says the opposite — "ship no art, and do not default me" (#218) — so a
|
|
547
|
+
// note carrying the old spelling has quietly changed meaning. Forty-five
|
|
548
|
+
// `sohl-thalorna` notes were written under the old reading and would have
|
|
549
|
+
// lost their default art with no error and no warning; this is the guard
|
|
550
|
+
// that would have caught them.
|
|
551
|
+
//
|
|
552
|
+
// **Both art fields, because both go through `resolveImg`.** `portrait` is
|
|
553
|
+
// not a variant spelling of `img` — a being carries the two independently —
|
|
554
|
+
// and checking only the more common one is how the sweep that prompted this
|
|
555
|
+
// guard missed eleven `sohl-kethira-basic` beings that write
|
|
556
|
+
// `portrait: ""`. Whatever the rule is, it belongs to the function, not to
|
|
557
|
+
// one of the keys that reaches it.
|
|
558
|
+
//
|
|
559
|
+
// A **warning**, on the pattern the `package:` and retired-alias sweeps
|
|
560
|
+
// set: the note still compiles, to a document that is merely iconless, so
|
|
561
|
+
// reddening a tree over it would refuse before the sweep rather than after
|
|
562
|
+
// it. It is transitional in the same sense — `""` is a legal thing to mean,
|
|
563
|
+
// and the message says so, but nothing in any tree means it yet.
|
|
564
|
+
//
|
|
565
|
+
// **These two only, never `title`.** The rule reads as a general one about
|
|
566
|
+
// optional strings, and it is not — it belongs to `resolveImg`, and `title`
|
|
567
|
+
// never goes through it.
|
|
568
|
+
//
|
|
569
|
+
// It once had a sharper reason, recorded here because it was load-bearing
|
|
570
|
+
// and is now false: a note's top-level `title` was simultaneously the shared
|
|
571
|
+
// source for an `affiliation` item's `system.title`, so asking an author for
|
|
572
|
+
// `title: null` would have compiled the literal string `"null"` into the
|
|
573
|
+
// document. That collision is gone — the field declares `topLevelMeans` and
|
|
574
|
+
// the top-level key is no longer a source for it — so `title: null` is now
|
|
575
|
+
// harmless. Whether `title: ""` deserves a warning of its own is a separate
|
|
576
|
+
// question about the *page's* heading, still open on #218, and not settled
|
|
577
|
+
// by extending an art-path check to it.
|
|
578
|
+
for (const key of ART_FIELDS) {
|
|
579
|
+
if (authoredValue(fm, key) !== "") continue;
|
|
580
|
+
findings.push({
|
|
581
|
+
file: note.file,
|
|
582
|
+
...at(key),
|
|
583
|
+
severity: "warning",
|
|
584
|
+
message:
|
|
585
|
+
`\`${key}: ""\` means "ship no art at all" — it no longer falls ` +
|
|
586
|
+
`back to this type's default. Write \`${key}: null\` for a note ` +
|
|
587
|
+
'that simply names none; keep `""` only where the document is ' +
|
|
588
|
+
"meant to have no image",
|
|
589
|
+
});
|
|
590
|
+
}
|
|
506
591
|
if (Object.hasOwn(fm, "draft")) {
|
|
507
592
|
findings.push({
|
|
508
593
|
file: note.file,
|
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/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": "15.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] : [],
|
|
@@ -63,6 +63,31 @@ export { setPath };
|
|
|
63
63
|
* still read, second, until #126 moves the corpus off it.
|
|
64
64
|
*
|
|
65
65
|
* Absent means the value is not authored at all — see `value`.
|
|
66
|
+
* @property {string} [topLevelMeans] - **What the note's top-level key of this
|
|
67
|
+
* name means instead** — declared only where it means something else, and
|
|
68
|
+
* stating it removes the shared top-level position from this field's
|
|
69
|
+
* resolution order (#218).
|
|
70
|
+
*
|
|
71
|
+
* A field's `name` doubles as its identity and as the shared property it
|
|
72
|
+
* draws from, which is right wherever the two levels state the same quantity
|
|
73
|
+
* — `data.weight` is the weight, whoever reads it. It is wrong wherever a
|
|
74
|
+
* spelling collides across the two vocabularies. An `affiliation` item's
|
|
75
|
+
* `system.title` is the style of address an office carries; a note's
|
|
76
|
+
* top-level `title` is the note's own heading. Nothing relates them, and
|
|
77
|
+
* before this key one silently fed the other, stringifying an authored
|
|
78
|
+
* `title: null` into fifteen documents.
|
|
79
|
+
*
|
|
80
|
+
* **The value is the reason**, not a flag with a comment beside it. A boolean
|
|
81
|
+
* would record the decision and lose the case for it, and the next person
|
|
82
|
+
* adding a field needs to know the question exists — this package's own rule
|
|
83
|
+
* that the declaration *is* the statement, never a description of one. The
|
|
84
|
+
* author-facing reference renders it, so an author reading the field table
|
|
85
|
+
* learns that the top-level key will not fill this field, and why.
|
|
86
|
+
*
|
|
87
|
+
* The exempted field is still authorable, at both of the positions that
|
|
88
|
+
* describe the *document* rather than the note: `<system>.system.<to>` and
|
|
89
|
+
* the legacy in-block `<system>.<name>`. Absent means the ordinary case —
|
|
90
|
+
* the top level is read, as the third step.
|
|
66
91
|
* @property {string} [shape] - Human-readable shape, for documentation. Comes
|
|
67
92
|
* paired with `read` from one of the coercion constants below.
|
|
68
93
|
* @property {(raw: any, ctx: {fm: object, field: FieldSpec}) => any} [read] -
|
|
@@ -170,6 +195,34 @@ export type FieldSpec = {
|
|
|
170
195
|
* Absent means the value is not authored at all — see `value`.
|
|
171
196
|
*/
|
|
172
197
|
name?: string | undefined;
|
|
198
|
+
/**
|
|
199
|
+
* - **What the note's top-level key of this
|
|
200
|
+
* name means instead** — declared only where it means something else, and
|
|
201
|
+
* stating it removes the shared top-level position from this field's
|
|
202
|
+
* resolution order (#218).
|
|
203
|
+
*
|
|
204
|
+
* A field's `name` doubles as its identity and as the shared property it
|
|
205
|
+
* draws from, which is right wherever the two levels state the same quantity
|
|
206
|
+
* — `data.weight` is the weight, whoever reads it. It is wrong wherever a
|
|
207
|
+
* spelling collides across the two vocabularies. An `affiliation` item's
|
|
208
|
+
* `system.title` is the style of address an office carries; a note's
|
|
209
|
+
* top-level `title` is the note's own heading. Nothing relates them, and
|
|
210
|
+
* before this key one silently fed the other, stringifying an authored
|
|
211
|
+
* `title: null` into fifteen documents.
|
|
212
|
+
*
|
|
213
|
+
* **The value is the reason**, not a flag with a comment beside it. A boolean
|
|
214
|
+
* would record the decision and lose the case for it, and the next person
|
|
215
|
+
* adding a field needs to know the question exists — this package's own rule
|
|
216
|
+
* that the declaration *is* the statement, never a description of one. The
|
|
217
|
+
* author-facing reference renders it, so an author reading the field table
|
|
218
|
+
* learns that the top-level key will not fill this field, and why.
|
|
219
|
+
*
|
|
220
|
+
* The exempted field is still authorable, at both of the positions that
|
|
221
|
+
* describe the *document* rather than the note: `<system>.system.<to>` and
|
|
222
|
+
* the legacy in-block `<system>.<name>`. Absent means the ordinary case —
|
|
223
|
+
* the top level is read, as the third step.
|
|
224
|
+
*/
|
|
225
|
+
topLevelMeans?: string | undefined;
|
|
173
226
|
/**
|
|
174
227
|
* - Human-readable shape, for documentation. Comes
|
|
175
228
|
* paired with `read` from one of the coercion constants below.
|
|
@@ -117,25 +117,47 @@ export function makeFilename(name: any, id: any): string;
|
|
|
117
117
|
* asset roots — `icons/...` and `images/...` — are served from the package
|
|
118
118
|
* directory, so they are rewritten to `<assetRoot>/<path>` — `systems/sohl/assets`
|
|
119
119
|
* for this repository, `modules/<id>/assets` for a module (#1508). Any other
|
|
120
|
-
* path (already package-rooted, an absolute URL) is returned unchanged
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* `
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
120
|
+
* path (already package-rooted, an absolute URL) is returned unchanged.
|
|
121
|
+
*
|
|
122
|
+
* **Two empties, and they mean opposite things (#218).** `null` — or an absent
|
|
123
|
+
* key, which reaches here as `undefined` — means _unset_: the note names no art
|
|
124
|
+
* and the caller's default applies. `""` means _blank on purpose_: the note
|
|
125
|
+
* names no art **and wants none**, so no default may replace it. Both come back
|
|
126
|
+
* distinguishable, `null` and `""` respectively, and neither is invented from
|
|
127
|
+
* the other.
|
|
128
|
+
*
|
|
129
|
+
* This used to open `if (!raw) return ""`, which made the two one case: every
|
|
130
|
+
* caller then applied its default with `||`, so a deliberate blank was
|
|
131
|
+
* unspellable and an unset key and an empty string compiled identically. That
|
|
132
|
+
* is the convention the project already rejects for an optional "not specified"
|
|
133
|
+
* DataModel string, where `nullable, initial: null` keeps "unset" a single
|
|
134
|
+
* honest value rather than two.
|
|
135
|
+
*
|
|
136
|
+
* **`title` does not follow this rule**, and must not be made to. On a
|
|
137
|
+
* `type: affiliation` note `title` is *also* a declared item field whose default
|
|
138
|
+
* is `""` (`sohl/item-fields.mjs`), resolved from the very same shared top-level
|
|
139
|
+
* key the site emitter reads as the page title — so `title: null` stringifies
|
|
140
|
+
* into the compiled document as the literal `"null"`. One key, two destinations
|
|
141
|
+
* that disagree about what empty means; see #218.
|
|
142
|
+
*
|
|
143
|
+
* This is translation only: the default for an unset path is domain-specific
|
|
144
|
+
* (actors default differently from items, and gear differently again), so each
|
|
145
|
+
* compiler owns its own default and applies it to the result with **nullish**
|
|
146
|
+
* coalescing — `resolveImg(fm.img) ?? <default>`. Not `||`: that would collapse
|
|
147
|
+
* a deliberate blank back into the default and undo the distinction. For items
|
|
148
|
+
* that default is the art paired with the type's builder, reached through
|
|
149
|
+
* `itemArt()`, which runs the path back through this function so a registry
|
|
150
|
+
* entry and a note's `img:` are spelled the same way (#7).
|
|
130
151
|
*
|
|
131
152
|
* @param {string | null | undefined} raw - content-relative path from frontmatter.
|
|
132
153
|
* @param {{assetRoot: string}} [config] - The resolved build configuration.
|
|
133
154
|
* Defaults to this repository's.
|
|
134
|
-
* @returns {string} the Foundry-relative path
|
|
155
|
+
* @returns {string | null} the Foundry-relative path; `""` for a deliberate
|
|
156
|
+
* blank, and `null` when the note names no art at all.
|
|
135
157
|
*/
|
|
136
158
|
export function resolveImg(raw: string | null | undefined, config?: {
|
|
137
159
|
assetRoot: string;
|
|
138
|
-
}): string;
|
|
160
|
+
}): string | null;
|
|
139
161
|
/**
|
|
140
162
|
* Resolves the display name from frontmatter, preferring `name.full`,
|
|
141
163
|
* falling back to `name` (if string), then `defaultValue`.
|
|
@@ -149,6 +149,13 @@ export function homepageTitle(fm: object | null | undefined, config: object): st
|
|
|
149
149
|
* for one; it decides nothing while `url` is present, but a page carrying only
|
|
150
150
|
* `url` would report a slug Hugo had inferred from the filename.
|
|
151
151
|
*
|
|
152
|
+
* **Site-root relative, and so carrying no package base** (#217), exactly as
|
|
153
|
+
* `pageFrontmatter` states a content page's: Hugo resolves a `url`
|
|
154
|
+
* against `baseURL`, whose path is already where the package is served, so a
|
|
155
|
+
* stated base was written twice and published the landing at
|
|
156
|
+
* `/<package>/<package>/homepage-root/`. Where the package is served is what
|
|
157
|
+
* every *href* is composed from and it reaches this page's address not at all.
|
|
158
|
+
*
|
|
152
159
|
* An authored `aliases` is dropped for the same reason it is on every other
|
|
153
160
|
* page: Hugo reads it as URL redirects, so passing it through would publish a
|
|
154
161
|
* redirect stub at each one. The field is retired (#180) and refused before a
|
|
@@ -158,15 +165,12 @@ export function homepageTitle(fm: object | null | undefined, config: object): st
|
|
|
158
165
|
* @param {object} options - Options.
|
|
159
166
|
* @param {string} options.contentPackage - The package this build publishes.
|
|
160
167
|
* @param {string} options.title - The resolved title.
|
|
161
|
-
* @param {string} options.base - Where the package is served, with both
|
|
162
|
-
* slashes — `/<package>/`.
|
|
163
168
|
* @returns {object} The frontmatter to write.
|
|
164
169
|
* @throws {Error} When the note declares no shortcode, and so has no address.
|
|
165
170
|
*/
|
|
166
|
-
export function homepageFrontmatter(fm: object, { contentPackage, title
|
|
171
|
+
export function homepageFrontmatter(fm: object, { contentPackage, title }: {
|
|
167
172
|
contentPackage: string;
|
|
168
173
|
title: string;
|
|
169
|
-
base: string;
|
|
170
174
|
}): object;
|
|
171
175
|
/**
|
|
172
176
|
* Every address a homepage carries, wherever it is written.
|
|
@@ -102,14 +102,9 @@ export function collectHomepages(contentBase: string, ctx: object): {
|
|
|
102
102
|
* @param {readonly object[]} pages - From {@link collectHomepages}.
|
|
103
103
|
* @param {object} config - The resolved configuration, for the package name and
|
|
104
104
|
* the default title.
|
|
105
|
-
* @param {object} [options] - Options.
|
|
106
|
-
* @param {string} [options.base] - Where the package is served; defaults to the
|
|
107
|
-
* configured `site.base`, and to `/<contentPackage>/` below that.
|
|
108
105
|
* @returns {number} How many pages were written.
|
|
109
106
|
*/
|
|
110
|
-
export function writeHomepages(outRoot: string, pages: readonly object[], config: object
|
|
111
|
-
base?: string | undefined;
|
|
112
|
-
}): number;
|
|
107
|
+
export function writeHomepages(outRoot: string, pages: readonly object[], config: object): number;
|
|
113
108
|
/**
|
|
114
109
|
* The integrity gates a site build runs before it writes anything.
|
|
115
110
|
*
|
|
@@ -201,6 +196,16 @@ export function sectionFrontmatter(meta: object): object;
|
|
|
201
196
|
* package-wide address the link manifest records — the same address, one
|
|
202
197
|
* segment too deep. So the address is stated and the mount does not reach it.
|
|
203
198
|
*
|
|
199
|
+
* **It is stated relative to the site root, and so carries no package base**
|
|
200
|
+
* (#217). Hugo resolves a `url` against `baseURL`, whose path is already where
|
|
201
|
+
* the package is served — a consumer's Hugo site *is* its package — so writing
|
|
202
|
+
* `page.url`, which carries the base for every href this build renders, wrote
|
|
203
|
+
* that base a second time and published every content page a segment too deep
|
|
204
|
+
* (`/sohl/sohl/doc-rulesintro/`). The two are separate quantities: the page
|
|
205
|
+
* states `/<slug>/`, and everything that points *at* the page — the address
|
|
206
|
+
* index a wikilink resolves through, and the link manifest — composes
|
|
207
|
+
* `<base><slug>/`.
|
|
208
|
+
*
|
|
204
209
|
* A content page carries the package the build **derived** (#65). No note
|
|
205
210
|
* declares one — `package:` is retired (#56) — so the note's frontmatter alone
|
|
206
211
|
* would publish a page that does not say which package it belongs to. The
|