@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.
@@ -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
  );
@@ -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 { resolveFieldValue, SYSTEM_BLOCK_KEYS, unknownBlockKeys } from "./system-block.mjs";
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 ?? []),
@@ -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, and an
238
- * empty path yields `""`.
239
- *
240
- * This is translation only: the per-type default for an empty result is
241
- * domain-specific (actors default differently from items, and gear differently
242
- * again), so each compiler owns its own default and applies it to the result —
243
- * `resolveImg(fm.img) || <default>`. For items that default is the art paired
244
- * with the type's builder, reached through `itemArt()`, which runs the path
245
- * back through this function so a registry entry and a note's `img:` are
246
- * spelled the same way (#7).
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, or `""` when `raw` is empty.
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
- if (!raw) return "";
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
  }
@@ -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, base }) {
393
+ export function homepageFrontmatter(fm, { contentPackage, title }) {
389
394
  const slug = addressSlug(fm);
390
- const data = { ...fm, package: contentPackage, title, slug, url: `${base}${slug}/` };
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
 
@@ -188,5 +188,8 @@ export function itemArt(type, system) {
188
188
  `note an \`img:\` of its own.`,
189
189
  );
190
190
  }
191
- return resolveImg(art);
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
- img: resolveImg(fm.img) || DEFAULT_MACRO_IMG,
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,
@@ -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, { base } = {}) {
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. `slug` is written beside it because it is
575
- // the last segment of that address and Hugo's own key for one; it
576
- // decides nothing while `url` is present, but a page that carried
577
- // only `url` would report a slug Hugo had inferred from the
578
- // filename.
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: page.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, { base }),
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, { base });
1087
+ const homepagesWritten = writeHomepages(homeRoot, homepages, resolved);
1082
1088
 
1083
1089
  return {
1084
1090
  gates,
@@ -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
- const shared = getFrontmatter(fm, field.name, undefined);
352
- if (shared !== undefined) return { value: shared, from: "shared" };
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": "14.0.0",
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
- portrait: resolveImg(blockProperty(fm, SYSTEM, "portrait")) || defaultImg,
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
- img: resolveImg(blockProperty(fm, SYSTEM, "img")) || defaultImg,
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")) || defaultImg },
829
+ texture: { src: resolveImg(blockProperty(fm, SYSTEM, "img")) ?? defaultImg },
827
830
  width: 1,
828
831
  height: 1,
829
832
  sight: { enabled: false },
@@ -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
- img: resolveImg(blockProperty(fm, SYSTEM, "img")) || itemArt(type, SYSTEM),
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;