@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.
@@ -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,
@@ -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,
@@ -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": "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] : [],
@@ -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, and an
121
- * empty path yields `""`.
122
- *
123
- * This is translation only: the per-type default for an empty result is
124
- * domain-specific (actors default differently from items, and gear differently
125
- * again), so each compiler owns its own default and applies it to the result —
126
- * `resolveImg(fm.img) || <default>`. For items that default is the art paired
127
- * with the type's builder, reached through `itemArt()`, which runs the path
128
- * back through this function so a registry entry and a note's `img:` are
129
- * spelled the same way (#7).
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, or `""` when `raw` is empty.
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, base }: {
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, { base }?: {
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