@heroiclands/package-build 13.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.
@@ -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
  }
@@ -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,
@@ -210,8 +210,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
210
210
  * exist.
211
211
  *
212
212
  * @param {string} contentBase - Absolute path to the content tree.
213
- * @param {object} ctx - `{ contentPackage, foundryPackageId, packRouter,
214
- * scheme }`.
213
+ * @param {object} ctx - `{ contentPackage, foundryPackageId, packRouter }`.
215
214
  * @returns {{entries: Array<object>, notes: number,
216
215
  * skipped: Array<{file: string, reason: string}>}}
217
216
  */
@@ -249,7 +248,7 @@ export function collectManifestEntries(contentBase, ctx) {
249
248
 
250
249
  let address;
251
250
  try {
252
- address = packageAddress(fm, { scheme: ctx.scheme });
251
+ address = packageAddress(fm);
253
252
  } catch (err) {
254
253
  skipped.push({ file: rel, reason: err.message });
255
254
  continue;
@@ -261,7 +260,7 @@ export function collectManifestEntries(contentBase, ctx) {
261
260
  }
262
261
 
263
262
  /**
264
- * The identities and scheme an emission runs against, from configuration.
263
+ * The identities an emission runs against, from configuration.
265
264
  *
266
265
  * Resolved in one place and passed down, rather than read at each use, so the
267
266
  * pass itself is a pure function of its context and a test can drive it without
@@ -269,15 +268,13 @@ export function collectManifestEntries(contentBase, ctx) {
269
268
  *
270
269
  * @param {object} [config] - A resolved configuration; loaded when omitted.
271
270
  * @returns {{contentPackage: string, foundryPackageId: string, packRouter: object,
272
- * scheme: {prefix: string, landing: string}, web: boolean,
273
- * skipDirectories: readonly string[]}}
271
+ * web: boolean, skipDirectories: readonly string[]}}
274
272
  */
275
273
  export function manifestContext(config = loadPackConfig()) {
276
274
  return {
277
275
  contentPackage: config.contentPackage,
278
276
  foundryPackageId: config.foundryPackage,
279
277
  packRouter: routerFor(config),
280
- scheme: config.publish.address,
281
278
  web: publishesContentPages(config),
282
279
  // The walk's own configuration, threaded through rather than left to
283
280
  // its default, so a caller that passes a config drives every read.
@@ -663,11 +663,11 @@ export const NOTE_VOCABULARY = Object.freeze({
663
663
  /* ----- core documents ------------------------------------------- */
664
664
 
665
665
  doc: Object.freeze({
666
- // `userguide`, not `user-guide`: a `doc` routes by its subType, so the
667
- // value is a path segment, and a segment carries no hyphen (#206). The
668
- // old spelling is accepted transitionally see {@link RETIRED_SUBTYPES}
669
- // but it is not declared here, because this list is what the format
670
- // says a note *should* write.
666
+ // `userguide`, not `user-guide`: a subType is held to the address
667
+ // charset, and a segment carries no hyphen (#206). The old spelling was
668
+ // accepted transitionally for one release so the consumer trees could
669
+ // sweep; they have, so it is refused by the charset check now, with no
670
+ // retirement-specific code left over (#210).
671
671
  subTypes: Object.freeze(["rules", "userguide", "reference"]),
672
672
  data: Object.freeze([]),
673
673
  }),
@@ -732,83 +732,32 @@ export const NOTE_VOCABULARY = Object.freeze({
732
732
  });
733
733
 
734
734
  /**
735
- * The retired spelling of a subType a type declares what to write now (#206).
736
- *
737
- * Keyed by type, because a retirement is a statement about *that type's*
738
- * vocabulary: `user-guide` on a `doc` is the old spelling of `userguide`, while
739
- * the same string on any other type is nothing but a charset violation, and
740
- * saying "did you mean userguide" there would be a guess dressed as a fact.
741
- *
742
- * **Recorded here rather than left in `subTypes`** so the declared list stays
743
- * the list of values a note *should* write. A retired value is accepted, not
744
- * declared — the difference is exactly what makes the finding possible.
745
- *
746
- * **Deliberately not the shape of a type rename** ({@link
747
- * import("./ids.mjs").RETIRED_TYPES}), which is an error: a retired type routes
748
- * a note to the wrong pack, whereas a retired subType still compiles to the
749
- * correct page. The sweep is the consumer's, and the ordering is the reverse of
750
- * the usual — the acceptance ships *first*, because declaring only the new
751
- * spelling while 43 `sohl` notes still author the old one would invalidate all
752
- * 43 with a release they had no chance to sweep ahead of. A later change
753
- * removes this map, and the old spelling then falls through to the ordinary
754
- * undeclared-value error with no code left to remove.
755
- *
756
- * @type {Readonly<Record<string, Readonly<Record<string, string>>>>}
757
- */
758
- export const RETIRED_SUBTYPES = Object.freeze({
759
- doc: Object.freeze({ "user-guide": "userguide" }),
760
- });
761
-
762
- /**
763
- * What to write in place of a retired subType value, if it is one.
735
+ * What a note carrying a subType outside the address charset is told.
764
736
  *
765
- * @param {string} type - The note's `type`.
766
- * @param {string} value - The authored `subType`.
767
- * @param {Readonly<Record<string, Readonly<Record<string, string>>>>} [retired]
768
- * The map to read, defaulting to {@link RETIRED_SUBTYPES}.
769
- * @returns {string|undefined} The current spelling, or `undefined` when the
770
- * value is not a retired one which is not the same as it being valid.
771
- */
772
- export function retiredSubType(type, value, retired = RETIRED_SUBTYPES) {
773
- const forType = retired?.[type];
774
- if (!forType || !Object.hasOwn(forType, value)) return undefined;
775
- return forType[value];
776
- }
777
-
778
- /**
779
- * What a note carrying a retired subType is told.
737
+ * **Why the charset holds for a subType, which reaches no address.** #206 said
738
+ * "the hyphen separates the segments of an address", and that was true of a
739
+ * subType when it shipped: `sectionOf` returned a `doc`'s subType, so the value
740
+ * was a URL path segment. #204 retired sections and it is not one now. The rule
741
+ * stays, on its own footing: a subType is a vocabulary term the whole toolchain
742
+ * keys on, and it is one closed set away from being an address segment again
743
+ * so the reason to spell it in the address charset is that a charset holding
744
+ * for a type, a shortcode and a `contentPackage` but not for a subType is a
745
+ * rule nobody can state in a sentence.
780
746
  *
781
- * One message, so the lint and any later refusal cannot describe the same
782
- * retirement differently.
783
- *
784
- * @param {string} type - The note's `type`.
785
- * @param {string} value - The retired spelling the note carries.
786
- * @param {string} replacement - What to write instead.
787
- * @returns {string} The message.
788
- */
789
- export function retiredSubTypeMessage(type, value, replacement) {
790
- return (
791
- `\`subType\` "${value}" is a retired spelling of "${replacement}" on a ` +
792
- `${type}; write "${replacement}". A subType is an address segment, and ` +
793
- `a segment is ${ADDRESS_SEGMENT_PATTERN.source} — the hyphen separates ` +
794
- `segments, so it can never occur inside one. The old spelling is still ` +
795
- `accepted, and will stop being accepted once the trees have swept`
796
- );
797
- }
798
-
799
- /**
800
- * What a note carrying a subType outside the address charset is told.
747
+ * Contrast {@link typeCharsetMessage}, which keeps the address reasoning
748
+ * because a type genuinely is the first segment of every address.
801
749
  *
802
750
  * @param {string} value - The authored `subType`.
803
751
  * @returns {string} The message.
804
752
  */
805
753
  export function subTypeCharsetMessage(value) {
806
754
  return (
807
- `\`subType\` "${value}" is not an address segment — a subType is ` +
755
+ `\`subType\` "${value}" is not a well-formed subType — a subType is ` +
808
756
  `letters and digits only (${ADDRESS_SEGMENT_PATTERN.source}), the same ` +
809
- `charset a shortcode is held to. The hyphen separates the segments of ` +
810
- `an address, so a value containing one is read back as two segments ` +
811
- `and resolves to nothing`
757
+ `charset a type, a shortcode and a contentPackage are held to. It is a ` +
758
+ `vocabulary term the whole toolchain keys on, and one closed set away ` +
759
+ `from being an address segment again, so a charset that held for every ` +
760
+ `term but this one would be a rule nobody could state in a sentence`
812
761
  );
813
762
  }
814
763
 
@@ -836,6 +785,11 @@ export function typeCharsetMessage(type) {
836
785
  * a note's bad value is one author's mistake and belongs in a report, while a
837
786
  * bad *declaration* would tell every author to write something unaddressable.
838
787
  *
788
+ * The message states the reason **per key**, as {@link typeCharsetMessage} and
789
+ * {@link subTypeCharsetMessage} do: a type is an address segment, and a subType
790
+ * has not been one since #204 retired sections, so a single claim covering both
791
+ * would be half wrong (#210).
792
+ *
839
793
  * @param {Readonly<Record<string, TypeVocabulary>>} vocabulary - The registry.
840
794
  * @param {string} [where] - What declares it, for the message.
841
795
  * @throws {Error} Naming every offending type and subType at once, rather than
@@ -854,9 +808,13 @@ export function assertVocabularyCharset(vocabulary, where = "the note vocabulary
854
808
  if (!bad.length) return;
855
809
  throw new Error(
856
810
  `${where} declares ${bad.join(", ")}, which ${bad.length === 1 ? "is" : "are"} ` +
857
- `not ${ADDRESS_SEGMENT_PATTERN.source}. A type and a subType are both ` +
858
- `address segments, and the hyphen separates segments rather than ` +
859
- `occurring inside one.`,
811
+ `not ${ADDRESS_SEGMENT_PATTERN.source}. A type is an address segment, ` +
812
+ `and the hyphen separates segments rather than occurring inside one. ` +
813
+ `A subType reaches no address since #204 retired sections, and is ` +
814
+ `held to the same charset anyway: it is a vocabulary term the whole ` +
815
+ `toolchain keys on, one closed set away from being a segment again, ` +
816
+ `and a charset holding for every term but that one would be a rule ` +
817
+ `nobody could state in a sentence.`,
860
818
  );
861
819
  }
862
820
 
@@ -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": "13.0.0",
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
- 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] : [],
@@ -56,36 +56,31 @@ export namespace DEFAULT_PATHS {
56
56
  */
57
57
  export const PACK_DOCUMENT_TYPES: readonly ["Actor", "Adventure", "Item", "JournalEntry", "Macro", "Scene"];
58
58
  /**
59
- * The landing-page rules a repository may route by. **Inert since #204.**
60
- *
61
- * A *landing page* was a note that addressed a whole section rather than a page
62
- * within one, so it had no slug of its own. There are no sections in the note
63
- * format any more a section is a Hugo content directory, and a page's address
64
- * names no directory so there are no landings and this selects nothing.
65
- *
66
- * The key survives its own mechanism on purpose. Both publishing consumers
67
- * declare `landing: readme`, which stated something true when they wrote it;
68
- * refusing it now would break them over a correct statement, and silently
69
- * ignoring an unknown value would be worse. So `readme` stays accepted, the
70
- * retired `collection` stays refused by name (below), and the key is deleted
71
- * once no configuration writes it `content-config.mjs` has no warning channel
72
- * with which to say "accepted, and does nothing" in between.
73
- *
74
- * @type {readonly string[]}
75
- */
76
- export const LANDING_RULES: readonly string[];
77
- /**
78
- * What a configuration naming the retired `collection` landing rule is told.
79
- *
80
- * A retired *value* is refused the way a retired *field* is (see
81
- * `engine/retired-fields.mjs`): left merely unrecognized it would be reported
82
- * as a bad value, which names something to correct and leaves the author to
83
- * work out for themselves that the mechanism is gone. The message says the rule
84
- * is retired, what lands a section instead, and what to do with the key.
59
+ * Address-scheme keys a configuration may no longer declare.
60
+ *
61
+ * A retired key has exactly two possible fates, and only one of them is honest
62
+ * the same reasoning `engine/retired-fields.mjs` applies to a retired
63
+ * frontmatter field. Left honoured, it keeps doing whatever it did, which is
64
+ * why it was retired. Left *ignored*, it reads to its author as though it still
65
+ * works: the configuration says one thing and the build does another, and
66
+ * nothing says so. This module has no third option, because it has no warning
67
+ * channel every finding goes through `fail()`, which throws. So a retired
68
+ * key is **refused**, at the line it was written on, with a message that says
69
+ * the mechanism is gone rather than naming a value to correct.
70
+ *
71
+ * **What `landing` did (#204).** It named which note addressed a whole section
72
+ * rather than a page within one a *landing page*, which therefore had no slug
73
+ * of its own. #203 retired the second of its two rules and #204 retired the
74
+ * concept both rules chose between: a section is a Hugo content directory that
75
+ * the note format does not carry, a page's address names no directory, and so
76
+ * no note lands anything. The key outlived its mechanism by one release only
77
+ * because both publishing consumers still declared the then-true
78
+ * `landing: readme`, and neither breaking them over a correct statement nor
79
+ * accepting the key in silence was acceptable. Neither declares it now.
85
80
  *
86
81
  * @type {Readonly<Record<string, string>>}
87
82
  */
88
- export const RETIRED_LANDING_RULES: Readonly<Record<string, string>>;
83
+ export const RETIRED_ADDRESS_KEYS: Readonly<Record<string, string>>;
89
84
  /**
90
85
  * A repository's address scheme, with the defaults an unconfigured one gets.
91
86
  *
@@ -96,11 +91,12 @@ export const RETIRED_LANDING_RULES: Readonly<Record<string, string>>;
96
91
  * knowledge, held in `PACKAGE_BASE` (`engine/kb-manifest.mjs`) and prefixed at
97
92
  * resolve time, so it is never recorded here (#1465).
98
93
  *
99
- * `landing` is inert see {@link LANDING_RULES}.
94
+ * It is the whole scheme: `landing`, the key that named which note addressed a
95
+ * whole section, is retired with the sections themselves — see
96
+ * {@link RETIRED_ADDRESS_KEYS}.
100
97
  */
101
98
  export const DEFAULT_ADDRESS_SCHEME: Readonly<{
102
99
  prefix: "";
103
- landing: "readme";
104
100
  }>;
105
101
  /**
106
102
  * How much of a package reaches the web.
@@ -515,12 +511,6 @@ export type AddressSchemeInput = {
515
511
  * Where the content tree mounts inside the package.
516
512
  */
517
513
  prefix?: string | undefined;
518
- /**
519
- * Which note addressed a whole section. Inert
520
- * since #204 retired sections from the note format — see
521
- * {@link LANDING_RULES}.
522
- */
523
- landing?: string | undefined;
524
514
  };
525
515
  /**
526
516
  * One entry of a consumer's `itemBuilders` registry.
@@ -55,27 +55,20 @@ export function contentAddress(fm: object): string;
55
55
  * `/<package>/api/` for generated API docs, neither of which contains a hyphen
56
56
  * or names a type.
57
57
  *
58
+ * **It takes no address scheme.** It took one until #215, to validate the
59
+ * `landing` rule it then discarded; with that key retired, `prefix` was the
60
+ * only thing left in the scheme and the paragraph above is the reason it never
61
+ * applied. A parameter read by nothing is the defect this deletion is about.
62
+ *
58
63
  * @param {object} fm - Parsed frontmatter.
59
- * @param {object} [options] - Options.
60
- * @param {{prefix?: string, landing?: string}} [options.scheme] - The
61
- * repository's address scheme; defaults to {@link DEFAULT_ADDRESS_SCHEME}.
62
- * `landing` is validated against {@link LANDING_RULES} and selects nothing —
63
- * it is accepted so a configuration declaring the still-true `landing: readme`
64
- * keeps loading, and is removed once none does.
65
64
  * @returns {string} The package-relative address, with a trailing slash and no
66
65
  * leading one.
67
66
  * @throws {Error} When the note has no type or no shortcode to be addressed by.
68
67
  * Such a note is not published, and inventing an address for one would put a
69
68
  * dead entry in the manifest.
70
69
  */
71
- export function packageAddress(fm: object, { scheme }?: {
72
- scheme?: {
73
- prefix?: string;
74
- landing?: string;
75
- } | undefined;
76
- }): string;
70
+ export function packageAddress(fm: object): string;
71
+ export { DEFAULT_ADDRESS_SCHEME };
77
72
  /** The knowledgebase's mount within this package's site (#1470). */
78
73
  export const KB_PREFIX: "kb/";
79
74
  import { DEFAULT_ADDRESS_SCHEME } from "../content-config.mjs";
80
- import { LANDING_RULES } from "../content-config.mjs";
81
- export { DEFAULT_ADDRESS_SCHEME, LANDING_RULES };