@heroiclands/package-build 14.0.0 → 15.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,189 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 15.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 5c16c02: Let a note's art path say "unset" and "blank on purpose" with two different values.
8
+
9
+ `resolveImg` opened with `if (!raw) return ""`, and every caller then applied its
10
+ own default to the result with `||` — `resolveImg(fm.img) || itemArt(type)`. So
11
+ `""`, `null` and an absent key were one case: all three compiled to the type's
12
+ default art, and a note had no way to say _ship no image_ at all.
13
+
14
+ They are now three values with two meanings, the convention the project already
15
+ holds for an optional "not specified" DataModel string (`nullable, initial: null`,
16
+ so "unset" is one honest value rather than two):
17
+
18
+ | a note writes | it means | it compiles with |
19
+ | -------------- | ------------------------------------ | ---------------- |
20
+ | nothing at all | _unset_ — name me no art | the type default |
21
+ | `img: null` | the same thing, said out loud | the type default |
22
+ | `img: ""` | _blank on purpose_ — I want no image | no image |
23
+
24
+ **`resolveImg` returns `string | null`.** `null` for an unset path, `""` for a
25
+ deliberate blank, the translation half unchanged. Every caller pairs its default
26
+ with **nullish** coalescing: `sohl/items.mjs`, three in `sohl/actors.mjs` (`img`,
27
+ `portrait`, and the prototype token's `texture.src`), and `engine/macros.mjs`.
28
+ Not `||` — that collapses a deliberate blank back into the default and takes the
29
+ distinction away again, and it does so silently, because `""` is falsy.
30
+ `itemArt()` is unaffected: a registry entry with no art throws before the
31
+ translation, so its result is never the unset case.
32
+
33
+ **The default-art seam is a documented extension point, so this is the substance
34
+ of the change, not a detail.** A consuming repository that pairs art with its own
35
+ `itemBuilders` entry, or calls `resolveImg` from a builder of its own, gets the
36
+ new reading of `""` whether or not it asked for it — which is why the sweeps have
37
+ to come first. `sohl-thalorna` swept forty-five `img: ""` notes ahead of this
38
+ release (HeroicLands/sohl-thalorna#134) and `sohl-kethira-basic` eleven
39
+ `portrait: ""` beings (HeroicLands/sohl-kethira-basic#81); `sohl` authors neither,
40
+ and its 3,125 compiled documents are byte-identical across the change.
41
+
42
+ **Both art fields, because both go through `resolveImg`.** A being carries `img`
43
+ (its token art) and `portrait` (its sheet portrait) independently; `portrait` is
44
+ not a variant spelling of `img`, and the rule belongs to the translator rather
45
+ than to one of the keys reaching it. This is not theoretical: `sohl-kethira-basic`
46
+ writes `portrait: ""` on eleven beings and `img: ""` on none, so a change — or a
47
+ guard — keyed on `img` alone would have called that tree clean and dropped every
48
+ one of those portraits.
49
+
50
+ **A warning for the old spelling.** The frontmatter lint reports `img: ""` and
51
+ `portrait: ""` — in either authoring position — as the meaning-change they are, on
52
+ the pattern the `package:` and retired-alias sweeps set: a warning, because the
53
+ note still compiles, to a document that is merely iconless.
54
+
55
+ **`title` is deliberately not on this rule.** It reads as a general rule about
56
+ optional strings and it is not: on a `type: affiliation` note `title` is
57
+ _simultaneously_ a declared item field whose default is `""`
58
+ (`sohl/item-fields.mjs`), resolved from the very same shared top-level key the
59
+ site emitter reads as the page title. `title: null` therefore does not fall back —
60
+ it stringifies, and the compiled document ships the literal `"null"`. A `title` a
61
+ note does not want is written by omitting the key; the emitter's `fm.title ?? name`
62
+ is already correct and is untouched. The lint guard is `img`'s alone for the same
63
+ reason.
64
+
65
+ Delivers the `img` half of #218. The `title` half — the collision above — stays
66
+ open.
67
+ - 63dfcae: **A note's own `title` no longer fills an affiliation's `system.title`** (#218).
68
+
69
+ The two were never the same quantity. A note's top-level `title` is _the title
70
+ of the note_ — the heading its page is published under, which the site emitter
71
+ reads. An `affiliation` item's `system.title` is _the style of address the office
72
+ carries_ — Ajaw, Warden, a person's style within the body. They collided only in
73
+ spelling, and field resolution's third step, the shared top-level property, fed
74
+ the second from the first.
75
+
76
+ That step also answers **without** applying the field's default — only the
77
+ in-block step does — so an authored `title: null` reached the `String()` coercion
78
+ unguarded and compiled to the literal string `"null"`. Fifteen `sohl-thalorna`
79
+ notes shipped `"system": { "title": "null" }` that way.
80
+
81
+ **Nothing in any content tree relied on the fallback.** Across all three
82
+ consumers — 295 `affiliation` notes in `sohl-thalorna`, 28 in
83
+ `sohl-kethira-basic`, none in `sohl` — not one carries a non-empty top-level
84
+ `title`, and `content-build package compile` emits byte-identical
85
+ `build/packs-json` for all three. It is a major because the rule that decides a
86
+ consumer's compiled documents changed with no configuration to restore it, and
87
+ because the generated item-field reference moves (below).
88
+
89
+ **The field is still authorable**, at the two positions that describe the
90
+ document rather than the note: `sohl.system.title`, and the legacy in-block
91
+ `sohl.title` that most trees already write. A membership's title belongs on the
92
+ entry in a being's `sohl.items`, as its `system.title`. `data.title` is neither —
93
+ `title` is not a `data:` property any note type declares, so `content-build lint`
94
+ refuses it.
95
+
96
+ **Declaring the exemption:** a field in an `itemBuilders` `fields:` declaration
97
+ may now carry `topLevelMeans`, whose value is _what the note's top-level key of
98
+ that name means instead_. Declaring it removes the shared top-level position from
99
+ that field's resolution order, and the generated item-field reference prints the
100
+ reason beneath the type's table, so an author reading it learns that the
101
+ top-level key will not fill the field. A repository that commits that page should
102
+ regenerate it.
103
+
104
+ The value is the reason rather than a bare flag deliberately: a boolean would
105
+ record the decision and lose the case for it, and the next person adding a field
106
+ needs to know the question exists.
107
+ - a41b066: **A page states its address relative to the site root.** `site.base` is no
108
+ longer written into a page's Hugo `url:` front matter — only into the `href`s
109
+ this build renders and the base a link-manifest `path` is measured against:
110
+
111
+ ```yaml
112
+ # emitted page
113
+ url: /doc-rulesintro/ # was: /sohl/doc-rulesintro/
114
+ ```
115
+
116
+ `site.base` was two quantities wearing one name. It is correctly _where the
117
+ package is served_ — the prefix on every rendered `href`, and the base a
118
+ manifest `path` is stripped against — and `/<contentPackage>/` is the right
119
+ default for that. It was also written verbatim into each page's `url:`, and that
120
+ is a different quantity: Hugo resolves `url` against `baseURL`, whose path for
121
+ every consumer that exists **already is** the package base. So the prefix was
122
+ written twice, and every content page — plus the homepage — published one
123
+ package segment too deep:
124
+
125
+ ```text
126
+ /sohl/doc-rulesintro/ 404 /sohl/sohl/doc-rulesintro/ 200
127
+ /thalorna/being-afzndhprnzr/ 404 /thalorna/thalorna/being-afzndhprnzr/ 200
128
+ ```
129
+
130
+ Not one address a link manifest advertised resolved: **0 of `sohl`'s 2,988
131
+ entries**, and 0 of `thalorna`'s 2,585.
132
+
133
+ **One value fed two readers that need opposite framings**, which is why no
134
+ setting could fix it from a consumer: `site.base: "/"` bought the addresses and
135
+ short-changed the hrefs, and the default did the reverse. They are now separate,
136
+ and each reader gets the form it needs:
137
+
138
+ | Reader | Gets | Because |
139
+ | --------------------------------------------------- | --------------------------- | ------------------------------------------------------------------- |
140
+ | A page's own `url:` front matter | `/<type>-<shortcode>/` | Hugo prefixes the site's `baseURL` path to it |
141
+ | The address index a `[[wikilink]]` resolves through | `<base><type>-<shortcode>/` | A browser resolves the rendered `href` against nothing |
142
+ | A link-manifest `path` | `<type>-<shortcode>/` | Measured against `site.base` and stripped; unchanged, byte for byte |
143
+
144
+ The homepage moves with them: `homepageFrontmatter` states `/homepage-root/`,
145
+ and no longer takes a `base` (nor does `writeHomepages`' fourth argument, which
146
+ existed only to supply it). `trees` pages and section landings never stated a
147
+ `url:` and are untouched — they take their address from their path.
148
+
149
+ **Take this release and drop your `site.base`.** Both publishing consumers set
150
+ `site.base: "/"` as a stopgap
151
+ (HeroicLands/Song-of-Heroic-Lands-FoundryVTT#1813, HeroicLands/sohl-thalorna#131),
152
+ which bought the correct addresses at the cost of same-package body links
153
+ rendering `/doc-x/` rather than `/sohl/doc-x/` — dead either way, so nothing
154
+ regressed. With this release the stopgap is no longer needed and no longer
155
+ harmless: keeping it leaves those hrefs short. Delete the `base:` line and the
156
+ default is right for both halves.
157
+
158
+ **Verified against pristine `git archive origin/main` extractions of all three
159
+ consumers**, in both configurations, through `content-build site` **and** Hugo
160
+ 0.165:
161
+
162
+ | Consumer | `site.base` | Page `url:` | Rendered path | Manifest entries resolving |
163
+ | ---------- | ----------- | --------------------- | ------------------------------ | -------------------------- |
164
+ | `sohl` | unset | `/doc-rulesintro/` | `/sohl/doc-rulesintro/` | 2,988 of 2,988 (was 0) |
165
+ | `sohl` | `"/"` | `/doc-rulesintro/` | `/sohl/doc-rulesintro/` | 2,988 of 2,988 |
166
+ | `thalorna` | unset | `/being-afzndhprnzr/` | `/thalorna/being-afzndhprnzr/` | 2,585 of 2,585 (was 0) |
167
+ | `thalorna` | `"/"` | `/being-afzndhprnzr/` | `/thalorna/being-afzndhprnzr/` | 2,585 of 2,585 |
168
+
169
+ No `/sohl/sohl/` or `/thalorna/thalorna/` path exists in either built tree, the
170
+ section landings, the content mount and the `dev-docs` tree pages all still
171
+ resolve, and with the stopgap dropped a same-package body link renders
172
+ `<a href=/sohl/doc-skills/>` again. `lint` and `links` are identical line for
173
+ line in every combination — `sohl` green, `sohl-thalorna` exactly as red as its
174
+ own content gap leaves it (1,983 lint findings, 122 link findings).
175
+
176
+ **Exactly one line per page changes, and nothing else does.** Of `sohl`'s 1,671
177
+ emitted files, 1,606 differ — the 1,605 content pages and the homepage — each by
178
+ its `url:` alone; the 46 tree pages and 19 landings are byte-identical, and
179
+ `build/manifests/sohl.json` is byte-identical. `sohl-kethira-basic` publishes a
180
+ homepage and nothing else (`publish.site: homepage`) and declares no
181
+ `site.base`: its console output is identical, its one emitted page moves from
182
+ `/kethira/kethira/homepage-root/` to `/kethira/homepage-root/`, and its landing
183
+ at `/kethira/` is unaffected.
184
+
185
+ Closes #217
186
+
3
187
  ## 14.0.0
4
188
 
5
189
  ### Major Changes
package/CONTENT.md CHANGED
@@ -455,9 +455,9 @@ error: pack "characters" (Actor) reads the compiled output of the Item pack
455
455
 
456
456
  ### An item type's default art
457
457
 
458
- A note that carries no `img:` gets its type's **default art**, and a type
459
- declares that art in the same place it declares its builder. An `itemBuilders`
460
- entry may be written two ways:
458
+ A note that names no art gets its type's **default art**, and a type declares
459
+ that art in the same place it declares its builder. An `itemBuilders` entry may
460
+ be written two ways:
461
461
 
462
462
  ```js
463
463
  itemBuilders: {
@@ -478,8 +478,60 @@ same `resolveImg` rule as a note's `img:`, so `icons/relic.svg` means _this_
478
478
  repository's asset root — `modules/sohl-relics/assets/icons/relic.svg` — and an
479
479
  already-served path (`systems/sohl/assets/icons/…`) passes through untouched.
480
480
 
481
- **A type with neither is a build error, deliberately.** When a note sets no
482
- `img:` and its type pairs none, the pack build aborts rather than shipping an
481
+ #### "Names no art" and "wants no art" are different (#218)
482
+
483
+ A note has two ways to leave `img:` empty, and they mean opposite things:
484
+
485
+ | a note writes | it means | it compiles with |
486
+ | -------------- | ------------------------------------ | ---------------- |
487
+ | nothing at all | _unset_ — name me no art | the type default |
488
+ | `img: null` | the same thing, said out loud | the type default |
489
+ | `img: ""` | _blank on purpose_ — I want no image | no image |
490
+ | `img: <path>` | this art | that path |
491
+
492
+ `resolveImg` returns `null` for the first two and `""` for the third, and every
493
+ caller pairs its default with **nullish** coalescing — `resolveImg(fm.img) ?? itemArt(type)`.
494
+ Never `||`: that collapses a deliberate blank back into the default and takes the
495
+ distinction away again, which is exactly what the function used to do.
496
+
497
+ **`portrait` is the same field twice over.** A being carries `img` (its token
498
+ art) and `portrait` (its sheet portrait) independently, and both resolve through
499
+ `resolveImg`, so the rule above is the rule for both.
500
+
501
+ This is the convention the project already holds for an optional "not specified"
502
+ DataModel string — `nullable, initial: null`, so "unset" is one honest value
503
+ rather than two.
504
+
505
+ > **The rule is `img`'s, and does not extend to `title`.** `title` is not art
506
+ > and never reaches `resolveImg`, so nothing here applies to it.
507
+ >
508
+ > The reason used to be sharper, and is no longer true: a note's top-level
509
+ > `title` was _also_ the shared source for an `affiliation` item's `system.title`
510
+ > (`sohl/item-fields.mjs`, "the style of address the office carries"), so one
511
+ > authored key fed two unrelated destinations that disagreed about what empty
512
+ > means — and `title: null` stringified into the compiled document as the literal
513
+ > `"null"`. That collision is gone: the field declares `topLevelMeans`, and the
514
+ > top-level key is no longer a source for it (#218).
515
+ >
516
+ > So `title: null` is now a note declining to state a heading, and the site
517
+ > emitter's `fm.title ?? name` falls back to `name.full`. `title: ""` still
518
+ > publishes a deliberately blank heading, and nothing warns about that yet.
519
+
520
+ Because `""` used to mean "unset", a note still carrying that spelling has
521
+ quietly changed meaning, and the frontmatter lint says so — for either art
522
+ field:
523
+
524
+ ```
525
+ Note.md:9:1: warning: `img: ""` means "ship no art at all" — it no longer falls
526
+ back to this type's default. Write `img: null` for a note that simply names
527
+ none; keep `""` only where the document is meant to have no image
528
+ ```
529
+
530
+ A warning, not an error: the note still compiles, to a document that is merely
531
+ iconless.
532
+
533
+ **A type with neither is a build error, deliberately.** When a note names no
534
+ art and its type pairs none, the pack build aborts rather than shipping an
483
535
  item with a mismatched icon:
484
536
 
485
537
  ```
@@ -570,6 +622,47 @@ system `S` is:
570
622
  `sohl:`", which is the degenerate case where source and destination happen to
571
623
  share a name.
572
624
 
625
+ **A spelling that means two different things skips step 3.** Because a field's
626
+ `name` doubles as its identity and as the shared property it draws from, the two
627
+ coincide only while the note vocabulary and the system vocabulary agree about
628
+ what a spelling means. `title` is where they do not. A note's top-level `title`
629
+ is _the title of the note_ — the heading its page publishes under, which the
630
+ site emitter reads. An `affiliation` item's `system.title` is _the style of
631
+ address the office carries_ — Ajaw, Warden, a person's style within the body.
632
+ They are unrelated quantities, and step 3 used to feed the second from the first
633
+ (#218).
634
+
635
+ That was not merely untidy, because **step 3 answers without applying
636
+ `field.default`** — only step 2 does — so an authored `title: null` reached the
637
+ field's `String()` coercion unguarded and shipped as the literal string `"null"`.
638
+
639
+ So a field may declare `topLevelMeans`: what the top-level key of that name means
640
+ _instead_. Declaring it removes step 3 for that field, and the value is the
641
+ reason rather than a bare flag, so the collision is legible where the field is
642
+ declared and the generated field reference can print it. It is a per-field
643
+ opt-out, not a change to the order — step 3 is right wherever the two levels
644
+ state the same quantity, which is nearly everywhere: `subType` is the other
645
+ declared item field spelled like a note-level key, and there the two agree by
646
+ design.
647
+
648
+ **An exempted field is still authorable**, at the two positions that describe the
649
+ document rather than the note:
650
+
651
+ ```yaml
652
+ title: The Order of the Silver Hand # the note's own heading — reaches the page
653
+ type: affiliation
654
+ subType: order
655
+ sohl:
656
+ system:
657
+ title: Warden # → document.system.title, the style of address
658
+ ```
659
+
660
+ `sohl.title`, the legacy in-block position, works the same way. A membership — a
661
+ `title` a particular being holds — is authored on the entry in that being's
662
+ `sohl.items`, whose `system` is overlaid on the catalogue document directly.
663
+ `data.title` is neither position: `title` is not a `data:` property any note type
664
+ declares, so the frontmatter lint refuses it.
665
+
573
666
  **`<system>.system` is written through verbatim**, at the DataModel's own paths,
574
667
  with no renaming layer. A key the system's published `schema.json` does not
575
668
  declare for the subtype the note compiles into is an **error naming the note**,
@@ -1234,6 +1327,21 @@ order to satisfy a rendering engine's directory semantics. The file is now
1234
1327
  `<mount>/<type>-<shortcode>.md` and the front-matter `url:` still publishes it at
1235
1328
  the package root, one level above.
1236
1329
 
1330
+ **A page states its address without the package base; everything pointing _at_
1331
+ it composes one** (#217). They read as one quantity and are two:
1332
+
1333
+ | Written | Form | Because |
1334
+ | ------------------------------------------- | --------------------------- | --------------------------------------------------------------------------------------- |
1335
+ | A page's own `url:` front matter | `/<type>-<shortcode>/` | Hugo resolves it against `baseURL`, whose path already _is_ where the package is served |
1336
+ | Every `href` this build renders into a body | `<base><type>-<shortcode>/` | A browser resolves it against nothing |
1337
+ | A link-manifest `path` | `<type>-<shortcode>/` | Measured against `site.base` and stripped; a consumer prefixes its own |
1338
+
1339
+ `site.base` is the second and third of those and reaches the first not at all.
1340
+ It used to be written into the `url:` as well, so every consumer's Hugo prefixed
1341
+ its own base to a value that already carried one and published every content
1342
+ page a segment too deep — `/sohl/sohl/doc-rulesintro/`, 404 at the address the
1343
+ manifest, the sitemap and every inbound link named.
1344
+
1237
1345
  **There is no landing page.** A `README.md` was its section's landing and
1238
1346
  addressed the section itself; that is retired with the section. A page that
1239
1347
  introduces the notes of a type is an ordinary note addressed `doc-<type>`, with
@@ -1363,7 +1471,8 @@ The homepage's file is written at the root of `site.out` — the package's own
1363
1471
  site root, one level above the content mount, which is where
1364
1472
  `publish.address.prefix` puts everything else — under the name its address gives
1365
1473
  it, `homepage-root.md`. As with every other page, the front matter's `url`
1366
- decides where it publishes.
1474
+ decides where it publishes, and states it relative to the site root — `site.base`
1475
+ does not reach it (#217).
1367
1476
 
1368
1477
  **What it does not do is decide addresses.** Those come from `publish.address`,
1369
1478
  the same setting the link manifest reads, so a page and its manifest entry cannot
@@ -1374,7 +1483,7 @@ published beside the content:
1374
1483
  ```yaml
1375
1484
  site:
1376
1485
  out: kb/content # required; wiped on every run
1377
- base: /sohl/ # default: /<contentPackage>/
1486
+ base: /sohl/ # default: /<contentPackage>/ — hrefs only, never a page's `url:`
1378
1487
  packages: [sohl, thalorna] # default: just contentPackage
1379
1488
  backfillSections: true
1380
1489
  landing: { title: Knowledgebase, type: knowledgebase }
@@ -1394,18 +1503,18 @@ site:
1394
1503
  dev-docs: { title: Developer Documentation, banner: banners/dev-docs.webp }
1395
1504
  ```
1396
1505
 
1397
- | Key | What it decides |
1398
- | ------------------ | ------------------------------------------------------------------------------------------------ |
1399
- | `out` | The Hugo content root. **Required** in both modes, and wiped on every run — see below. |
1400
- | `base` | Where the package is served. Defaults to `/<contentPackage>/`. |
1401
- | `packages` | Which content packages this site renders. Defaults to its own. |
1402
- | `sections` | The Hugo sections this site declares, and what each says about itself — see below. |
1403
- | `readmeSections` | The same, for a `trees` entry, whose landing comes from its own `README`. |
1404
- | `landing` | Frontmatter for the mount's own `_index.md`. Passed through — the vocabulary is the theme's. |
1405
- | `backfillSections` | Write a bare `_index.md` for any other directory directly under the mount. |
1406
- | `trees` | Extra source trees published beside the content, preserving their source layout below a section. |
1407
- | `pass` | A named bundle of this repository's own body rewrites. |
1408
- | `passOptions` | That bundle's options. |
1506
+ | Key | What it decides |
1507
+ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1508
+ | `out` | The Hugo content root. **Required** in both modes, and wiped on every run — see below. |
1509
+ | `base` | Where the package is served: the prefix on every rendered `href`, and what a manifest `path` is measured against. It reaches no page's own `url:` — see [A page's URL is its address](#a-pages-url-is-its-address). Defaults to `/<contentPackage>/`. |
1510
+ | `packages` | Which content packages this site renders. Defaults to its own. |
1511
+ | `sections` | The Hugo sections this site declares, and what each says about itself — see below. |
1512
+ | `readmeSections` | The same, for a `trees` entry, whose landing comes from its own `README`. |
1513
+ | `landing` | Frontmatter for the mount's own `_index.md`. Passed through — the vocabulary is the theme's. |
1514
+ | `backfillSections` | Write a bare `_index.md` for any other directory directly under the mount. |
1515
+ | `trees` | Extra source trees published beside the content, preserving their source layout below a section. |
1516
+ | `pass` | A named bundle of this repository's own body rewrites. |
1517
+ | `passOptions` | That bundle's options. |
1409
1518
 
1410
1519
  ### What a section may declare
1411
1520
 
package/MIGRATING.md CHANGED
@@ -1,3 +1,188 @@
1
+ # Migrating to `@heroiclands/package-build` 15.0.0
2
+
3
+ **Three unrelated changes ship in this major, and they ask different
4
+ repositories for different things.** A page's `url:` front matter is now stated
5
+ relative to the **site root** (#217), which a site-publishing repository answers
6
+ by deleting one line; an empty art path now means the opposite of an absent one
7
+ (#218), which a content tree answers by sweeping `img: ""` and `portrait: ""` to
8
+ `null`; and a note's own `title` no longer reaches an `affiliation` item's
9
+ `system.title` (#218), which asks nothing of any repository that exists today.
10
+
11
+ Route yourself by what you have. A repository that publishes no site skips
12
+ §1–§2. One that authors no empty art path skips §3–§4. One with no
13
+ `type: affiliation` notes skips §6 — and §5, which is only the seam between the
14
+ two halves of #218.
15
+
16
+ ## 1. Drop the `site.base: "/"` stopgap (#217)
17
+
18
+ If your repository set it to stop every page publishing at
19
+ `/<package>/<package>/<address>/`, delete the line — the default,
20
+ `/<contentPackage>/`, is now right for both halves:
21
+
22
+ ```yaml
23
+ site:
24
+ out: kb/content
25
+ # base: "/" ← delete this
26
+ ```
27
+
28
+ Keeping it is no longer harmless: the addresses stay correct either way, but
29
+ every same-package link this build renders into a page body stays short
30
+ (`/doc-skills/` rather than `/sohl/doc-skills/`) and 404s.
31
+
32
+ A repository that is genuinely served somewhere other than
33
+ `/<contentPackage>/` still says so here, and that value still reaches every
34
+ `href`.
35
+
36
+ ## 2. What the `url:` change does _not_ touch
37
+
38
+ - No note edits, and no configuration key added or removed.
39
+ - Every link-manifest entry, including each entry's `path`, is byte-identical.
40
+ - Every compiled compendium document is unchanged.
41
+ - `trees` pages and section landings state no `url:` and do not move.
42
+
43
+ The published address of every content page **does** move — from
44
+ `/<package>/<package>/<address>/`, where nothing linked, to
45
+ `/<package>/<address>/`, which is what the link manifest, the sitemap and every
46
+ inbound link already named.
47
+
48
+ ## 3. Sweep `img: ""` and `portrait: ""` to `null` (#218)
49
+
50
+ `resolveImg` opened with `if (!raw) return ""`, and every caller applied its own
51
+ default to the result with `||`. So `""`, `null` and an absent key were one
52
+ case: all three compiled to the type's default art, and a note had no way to say
53
+ "ship no image" at all.
54
+
55
+ They are now three values with two meanings:
56
+
57
+ | a note writes | it means | it compiles with |
58
+ | -------------- | ------------------------------------ | ---------------- |
59
+ | nothing at all | _unset_ — name me no art | the type default |
60
+ | `img: null` | the same thing, said out loud | the type default |
61
+ | `img: ""` | _blank on purpose_ — I want no image | no image |
62
+
63
+ So a note still carrying `img: ""` **loses its default art**. The same holds for
64
+ `portrait:`, which a being carries independently of `img` and which resolves
65
+ through the same function. Find them both:
66
+
67
+ ```bash
68
+ grep -rnE '^[[:space:]]*(img|portrait):[[:space:]]*""[[:space:]]*$' assets/content --include='*.md'
69
+ ```
70
+
71
+ and write `null` in each — unless the document really is meant to have no image,
72
+ which is what `""` now says. Before this release `sohl-thalorna` swept forty-five
73
+ `img: ""` notes and `sohl-kethira-basic` eleven `portrait: ""` beings; `sohl`
74
+ authors neither.
75
+
76
+ The frontmatter lint reports every one that is left, as a warning:
77
+
78
+ ```
79
+ Note.md:9:1: warning: `img: ""` means "ship no art at all" — it no longer falls
80
+ back to this type's default. Write `img: null` for a note that simply names
81
+ none; keep `""` only where the document is meant to have no image
82
+ ```
83
+
84
+ ## 4. Custom callers pair their default with `??`, not `||`
85
+
86
+ `resolveImg` returns `string | null` now: `null` for an unset path, `""` for a
87
+ deliberate blank. A consumer that calls it directly — a custom item builder, a
88
+ compiler of its own — must switch:
89
+
90
+ ```diff
91
+ -img: resolveImg(fm.img) || MY_DEFAULT,
92
+ +img: resolveImg(fm.img) ?? MY_DEFAULT,
93
+ ```
94
+
95
+ `||` still compiles and still looks right; it silently reinstates the old
96
+ conflation, because `""` is falsy. Every caller in this package moved:
97
+ `sohl/items.mjs`, three in `sohl/actors.mjs` (`img`, `portrait`, and the
98
+ prototype token's `texture.src`), and `engine/macros.mjs`.
99
+
100
+ `itemArt()` is unaffected — a registry entry with no art throws before the
101
+ translation, so its result is never the unset case.
102
+
103
+ ## 5. `title` is **not** on the art rule
104
+
105
+ `resolveImg`'s rule reads as a general one about optional strings, and it is
106
+ not — it belongs to the function, and `title` never goes through it. So do not
107
+ extend §3's sweep to `title`, and do not read the table in §3 as saying anything
108
+ about it.
109
+
110
+ **The reason has changed since this section was first written, and the earlier
111
+ one is no longer true.** It used to be that a note's top-level `title` was
112
+ _simultaneously_ the page's heading and the shared source for an `affiliation`
113
+ item's `system.title`, so `title: null` did not fall back — it stringified, and
114
+ the compiled document shipped the literal string `"null"`. That collision is what
115
+ §6 removes: the top-level key is no longer a source for the item field at all.
116
+
117
+ So `title: null` is now simply a note declining to state a heading, and the site
118
+ emitter's `fm.title ?? name` falls back to `name.full` as it always did. It
119
+ reaches no document field and stringifies nothing.
120
+
121
+ `title: ""` still publishes a **deliberately blank heading** — which is what cost
122
+ fifteen `sohl-thalorna` notes their names (HeroicLands/sohl-thalorna#129) — and
123
+ nothing warns about it yet. Whether the frontmatter lint should is #218's, still
124
+ open, and deliberately not settled by the art-field warning in §3.
125
+
126
+ ## 6. A note's own `title` no longer fills `system.title` (#218)
127
+
128
+ **No note edit, no URL change, and no compiled document moves.** An
129
+ `affiliation` item's `system.title` stops falling back to the note's own
130
+ top-level `title`. The two were never the same quantity — a note's `title` is
131
+ the heading its page publishes under, while `system.title` is the style of
132
+ address an office carries, Ajaw or Warden — and no note in any content tree
133
+ relied on the fallback, so `content-build package compile` emits byte-identical
134
+ `build/packs-json` for every consumer.
135
+
136
+ Only a `type: affiliation` note is affected, and only if it carries a top-level
137
+ `title` it meant as the item's field rather than as the page's heading:
138
+
139
+ ```bash
140
+ grep -rl '^type: affiliation' assets/content --include='*.md' \
141
+ | xargs grep -l '^title:'
142
+ ```
143
+
144
+ Anything that turns up wanted one of the two positions that describe the
145
+ _document_ rather than the note — `sohl.system.title`, or `sohl.title`, the
146
+ legacy in-block key most trees already write. A membership's title belongs on
147
+ the entry in the being's `sohl.items`, as `system.title`.
148
+
149
+ `data: { title: ... }` is not a position and never was: `title` is not a `data:`
150
+ property any note type declares, so `content-build lint` refuses it.
151
+
152
+ ### Regenerate the item field reference
153
+
154
+ The generated page now prints, under each affected type's table, what the
155
+ top-level key of a non-shared field means instead — so an author reading the
156
+ table learns that writing `title:` at the top of a note will not fill this
157
+ field. Re-run the generator and commit the result, or a repository that checks
158
+ the page for staleness reports it stale:
159
+
160
+ ```bash
161
+ npx content-build docs item-fields --out <the path your repo uses>
162
+ ```
163
+
164
+ ### Declaring your own non-shared field
165
+
166
+ A field in an `itemBuilders` `fields:` declaration may now carry
167
+ `topLevelMeans`, whose value is _what the note's top-level key of that name
168
+ means instead_. Declaring it removes the shared top-level position from that
169
+ field's resolution order:
170
+
171
+ ```js
172
+ {
173
+ name: "title",
174
+ to: "title",
175
+ ...STRING,
176
+ default: "",
177
+ topLevelMeans: "the note's own title — the heading its page is published under",
178
+ describe: "The style of address the office carries.",
179
+ }
180
+ ```
181
+
182
+ The value is the reason rather than a bare flag on purpose: the next person
183
+ adding a field needs to know the question exists, and a boolean with a comment
184
+ beside it is two statements of one rule.
185
+
1
186
  # Migrating to `@heroiclands/package-build` 12.0.0
2
187
 
3
188
  **No note edit, no URL change, and one thing to check in the Hugo layer.** A
@@ -138,6 +138,17 @@ it _there_, and a system that disagrees is not in error. A weapon weighs what
138
138
  This is the same rule as `hm3.type` overriding a derived document type, applied
139
139
  to fields: derive from the shared source, and let the system state the exception.
140
140
 
141
+ **A field whose spelling means something else at the note level has no shared
142
+ source.** The fallback assumes the two vocabularies agree about what a name
143
+ means, and they do not always: a note's top-level `title` is the heading its page
144
+ publishes under, while an `affiliation` item's `system.title` is the style of
145
+ address an office carries. Where they diverge, the field declares what the
146
+ top-level key means instead, and the top level stops being read for it — leaving
147
+ `<system>.system.<field>` and the legacy in-block position, which describe the
148
+ document rather than the note. `title` is the one field this applies to; `subType`
149
+ is the other declared item field spelled like a note-level key, and there the two
150
+ levels mean the same thing by design.
151
+
141
152
  **A `WikiLink` becomes a shortcode where the target field expects one.** SoHL
142
153
  stores cross-references as shortcode strings, which is what the `Code` suffix
143
154
  marks: `data.assocSkill` is a link to a skill note, and `system.assocSkillCode`
@@ -785,6 +796,16 @@ mapping for a field no schema declares is the drift these tables exist to catch.
785
796
  likewise absent here: they are filled on an embedded membership, never from a
786
797
  catalogue note's `data:`.
787
798
 
799
+ **`system.title` is not the note's `title`.** The two are unrelated quantities
800
+ that share a spelling. A note's top-level `title` is _the title of the note_ —
801
+ the heading its page is published under; an affiliation's `system.title` is _the
802
+ style of address the office carries_, Ajaw or Warden, which a being holds by
803
+ virtue of its rank. So the top-level key is **not** a shared source for this
804
+ field, and a note that writes one is stating its own heading and nothing else
805
+ (#218). Author the style of address on the membership — the `system.title` of the
806
+ entry in a being's `sohl.items` — or, on a catalogue note that genuinely carries
807
+ one, at `sohl.system.title`.
808
+
788
809
  ### type: affliction
789
810
 
790
811
  Represents an affliction.
@@ -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
  );