@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.
package/CONTENT.md CHANGED
@@ -138,7 +138,6 @@ publish:
138
138
  manifests: { publish: true, consume: true }
139
139
  address:
140
140
  prefix: kb/
141
- landing: readme
142
141
 
143
142
  # How this repository frames the website `content-build site` publishes.
144
143
  # Framing only: addresses come from `publish.address` above.
@@ -456,9 +455,9 @@ error: pack "characters" (Actor) reads the compiled output of the Item pack
456
455
 
457
456
  ### An item type's default art
458
457
 
459
- A note that carries no `img:` gets its type's **default art**, and a type
460
- declares that art in the same place it declares its builder. An `itemBuilders`
461
- 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:
462
461
 
463
462
  ```js
464
463
  itemBuilders: {
@@ -479,8 +478,60 @@ same `resolveImg` rule as a note's `img:`, so `icons/relic.svg` means _this_
479
478
  repository's asset root — `modules/sohl-relics/assets/icons/relic.svg` — and an
480
479
  already-served path (`systems/sohl/assets/icons/…`) passes through untouched.
481
480
 
482
- **A type with neither is a build error, deliberately.** When a note sets no
483
- `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
484
535
  item with a mismatched icon:
485
536
 
486
537
  ```
@@ -571,6 +622,47 @@ system `S` is:
571
622
  `sohl:`", which is the degenerate case where source and destination happen to
572
623
  share a name.
573
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
+
574
666
  **`<system>.system` is written through verbatim**, at the DataModel's own paths,
575
667
  with no renaming layer. A key the system's published `schema.json` does not
576
668
  declare for the subtype the note compiles into is an **error naming the note**,
@@ -806,15 +898,15 @@ rule is checked ahead of the closed-set check, which is what makes it reach a
806
898
  type whose values are declared but not yet enumerated:
807
899
 
808
900
  ```text
809
- assets/content/Beings/Folk.md:3:1: error: `subType` "common-folk" is not an address segment — a subType is letters and digits only (^[A-Za-z0-9]+$), the same charset a shortcode is held to. …
901
+ assets/content/Beings/Folk.md:3:1: error: `subType` "common-folk" is not a well-formed subType — a subType is letters and digits only (^[A-Za-z0-9]+$), the same charset a type, a shortcode and a contentPackage are held to. …
810
902
  ```
811
903
 
812
904
  One declared value broke that rule: a `doc`'s `user-guide`, now **`userguide`**.
813
- The old spelling is accepted for one transitional release and reported as a
814
- **warning** naming the replacement an error would red every tree that took the
815
- release before it had a chance to sweep, and the note still compiles to the
816
- correct page. A later release removes the acceptance, and the old spelling then
817
- falls through to the ordinary undeclared-value error.
905
+ The old spelling was accepted for one transitional release, as a warning naming
906
+ the replacement, because an error would have redded every tree that took the
907
+ release before it had a chance to sweep. Every tree has swept, so the acceptance
908
+ is gone (#210): `user-guide` is refused by the charset check like any other
909
+ hyphenated value, and nothing retirement-specific was left to remove.
818
910
 
819
911
  The vocabulary lives in `engine/note-vocabulary.mjs`, one entry per note type,
820
912
  taken from the content-format specification. It is note-format knowledge rather
@@ -1235,6 +1327,21 @@ order to satisfy a rendering engine's directory semantics. The file is now
1235
1327
  `<mount>/<type>-<shortcode>.md` and the front-matter `url:` still publishes it at
1236
1328
  the package root, one level above.
1237
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
+
1238
1345
  **There is no landing page.** A `README.md` was its section's landing and
1239
1346
  addressed the section itself; that is retired with the section. A page that
1240
1347
  introduces the notes of a type is an ordinary note addressed `doc-<type>`, with
@@ -1247,9 +1354,8 @@ per section, and that is now the _only_ thing that makes one exist — see
1247
1354
 
1248
1355
  ### The address scheme
1249
1356
 
1250
- Where the content tree mounts _inside the package_, and which note addresses a
1251
- whole section rather than a page within one, differ between repositories and are
1252
- both load-bearing. They are one setting, read by this command **and** by the page
1357
+ Where the content tree mounts _inside the package_ differs between repositories
1358
+ and is load-bearing. It is one setting, read by this command **and** by the page
1253
1359
  emitter, so the address a manifest publishes is the address a page is emitted at
1254
1360
  — stating it twice is how a manifest comes to assert a URL that resolves at build
1255
1361
  time and 404s for the reader.
@@ -1260,7 +1366,6 @@ publish:
1260
1366
  manifests: { publish: true, consume: true }
1261
1367
  address:
1262
1368
  prefix: kb/ # default: "" — the package root
1263
- landing: readme # inert since #204; still accepted
1264
1369
  ```
1265
1370
 
1266
1371
  - **`prefix`** — the content tree's mount within the package: the Hugo directory
@@ -1270,14 +1375,9 @@ publish:
1270
1375
  content, so it has no prefix. It must end in a slash and must not begin with
1271
1376
  one — where the _package_ is mounted is the consuming build's knowledge and is
1272
1377
  never recorded here.
1273
- - **`landing`** — **inert, and accepted only so it keeps loading.** It named
1274
- which note addressed a whole section rather than a page within one. There are
1275
- no sections in the note format (#204) and so no landings, so it selects
1276
- nothing; it is still accepted because both publishing consumers declare
1277
- `landing: readme` and refusing a correct statement would break them. It is
1278
- removed once no configuration writes it. The retired second rule, `collection`,
1279
- is still refused by name (#202), along with the `collection` subtype and the
1280
- top-level `section:` key.
1378
+
1379
+ `prefix` is the whole scheme. The `collection` subtype and the top-level
1380
+ `section:` key are refused by name (#202).
1281
1381
 
1282
1382
  A note's `subType` is checked against the values its type declares, and only
1283
1383
  those. It briefly had a second reading — a `README` landing's `subType` was the
@@ -1371,7 +1471,8 @@ The homepage's file is written at the root of `site.out` — the package's own
1371
1471
  site root, one level above the content mount, which is where
1372
1472
  `publish.address.prefix` puts everything else — under the name its address gives
1373
1473
  it, `homepage-root.md`. As with every other page, the front matter's `url`
1374
- 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).
1375
1476
 
1376
1477
  **What it does not do is decide addresses.** Those come from `publish.address`,
1377
1478
  the same setting the link manifest reads, so a page and its manifest entry cannot
@@ -1382,7 +1483,7 @@ published beside the content:
1382
1483
  ```yaml
1383
1484
  site:
1384
1485
  out: kb/content # required; wiped on every run
1385
- base: /sohl/ # default: /<contentPackage>/
1486
+ base: /sohl/ # default: /<contentPackage>/ — hrefs only, never a page's `url:`
1386
1487
  packages: [sohl, thalorna] # default: just contentPackage
1387
1488
  backfillSections: true
1388
1489
  landing: { title: Knowledgebase, type: knowledgebase }
@@ -1402,18 +1503,18 @@ site:
1402
1503
  dev-docs: { title: Developer Documentation, banner: banners/dev-docs.webp }
1403
1504
  ```
1404
1505
 
1405
- | Key | What it decides |
1406
- | ------------------ | ------------------------------------------------------------------------------------------------ |
1407
- | `out` | The Hugo content root. **Required** in both modes, and wiped on every run — see below. |
1408
- | `base` | Where the package is served. Defaults to `/<contentPackage>/`. |
1409
- | `packages` | Which content packages this site renders. Defaults to its own. |
1410
- | `sections` | The Hugo sections this site declares, and what each says about itself — see below. |
1411
- | `readmeSections` | The same, for a `trees` entry, whose landing comes from its own `README`. |
1412
- | `landing` | Frontmatter for the mount's own `_index.md`. Passed through — the vocabulary is the theme's. |
1413
- | `backfillSections` | Write a bare `_index.md` for any other directory directly under the mount. |
1414
- | `trees` | Extra source trees published beside the content, preserving their source layout below a section. |
1415
- | `pass` | A named bundle of this repository's own body rewrites. |
1416
- | `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. |
1417
1518
 
1418
1519
  ### What a section may declare
1419
1520
 
@@ -1431,7 +1532,8 @@ Two consequences follow, and neither is optional:
1431
1532
  `_index.md`, so a layout reading `.Pages` finds nothing. A layout that queries
1432
1533
  `site.RegularPages` by `Params.type` is unaffected, and that is the shape a
1433
1534
  content catalog wants anyway — it groups by what a page _is_, not by where its
1434
- file happened to be written.
1535
+ file happened to be written. A section that wants that query run for it says
1536
+ so with `listType` — see below.
1435
1537
 
1436
1538
  Whatever the entry may carry is the whole of what the section can say.
1437
1539
 
@@ -1440,18 +1542,63 @@ Whatever the entry may carry is the whole of what the section can say.
1440
1542
  | `title` | yes | The landing's heading, so it matches the card that links to it. |
1441
1543
  | `banner` | no | The hero image, resolved as a CDN asset like any other `banner:`. |
1442
1544
  | `description` | no | The hero standfirst under the heading, and the blurb a landing card uses. |
1545
+ | `listType` | no | The content **type** whose pages this section lists. |
1546
+ | `listSubType` | no | Narrows that to one **subType**. Only with a `listType`. |
1443
1547
 
1444
- `readmeSections` takes the same three, for a **`trees`** entry: those pages keep
1548
+ `readmeSections` takes the same keys, for a **`trees`** entry: those pages keep
1445
1549
  their source layout below a named section, so the tree's own `README.md` is that
1446
1550
  section's landing. What the section declares wins over what the `README` happens
1447
1551
  to carry — the landing has to match the card that links to it. No content note
1448
1552
  reaches this map any more.
1449
1553
 
1554
+ #### Saying what a section lists
1555
+
1556
+ A generic list layout has nothing to render, because the membership a section
1557
+ landing used to get free from Hugo's page tree no longer exists anywhere the
1558
+ theme can read: not on the page, not on the landing, not in any URL — only here.
1559
+ So a section states its own query, and a layout substitutes it when `.Pages` is
1560
+ empty:
1561
+
1562
+ ```yaml
1563
+ sections:
1564
+ being: { title: Beings, listType: being }
1565
+ rules: { title: Rules, listType: doc, listSubType: rules }
1566
+ user-guide: { title: User Guide, listType: doc, listSubType: userguide }
1567
+ ```
1568
+
1569
+ emits, for the last of those, `user-guide/_index.md`:
1570
+
1571
+ ```yaml
1572
+ ---
1573
+ title: User Guide
1574
+ listType: doc
1575
+ listSubType: userguide
1576
+ ---
1577
+ ```
1578
+
1579
+ Three things about the spelling, each of them load-bearing:
1580
+
1581
+ - **Not `type`.** On an `_index.md` that is Hugo's own layout selector — a
1582
+ landing carrying `type: doc` is rendered by `layouts/doc/list.html` rather
1583
+ than the default list template — so writing the content type there would
1584
+ silently change which template serves the landing. This build already relies
1585
+ on that behaviour for the mount's own `landing`.
1586
+ - **Not inferred from the section's name.** A section is named for a URL the
1587
+ site chose; a type and a subType are addresses. They need not agree, and on
1588
+ `sohl` they do not: the section is `user-guide`, because that is a published
1589
+ URL, while the subType is `userguide` because an address segment is
1590
+ alphanumeric (#207). Both values are checked against that charset here, so
1591
+ copying the section's name in is refused rather than quietly matching nothing.
1592
+ - **`listSubType` needs a `listType`.** A subType only tells pages apart within
1593
+ a type — `rules`, `userguide` and `reference` are all `doc` — so alone it
1594
+ names no query.
1595
+
1450
1596
  **The vocabulary is closed, and a key outside it is refused by name:**
1451
1597
 
1452
1598
  ```text
1453
1599
  package-build config: `site.sections.affliction.descrption` is not a
1454
- recognized option (expected one of: title, banner, description).
1600
+ recognized option (expected one of: title, banner, description, listType,
1601
+ listSubType).
1455
1602
  ```
1456
1603
 
1457
1604
  That refusal is the point. `landing` is passed through unvalidated because it is
@@ -1460,7 +1607,10 @@ entry is written fourteen to twenty times per build against a contract every
1460
1607
  package and every section shares, so an unbounded one would let a mistyped
1461
1608
  `descrption:` publish into front matter, be read by nobody, and say nothing to
1462
1609
  anyone. Refusing it costs one line here when the vocabulary genuinely grows, and
1463
- buys a build that cannot quietly emit a key no theme reads.
1610
+ buys a build that cannot quietly emit a key no theme reads. `listType` and
1611
+ `listSubType` are that growth: two named keys, checked, rather than an open
1612
+ passthrough in which `listTpye:` would publish and no landing would list
1613
+ anything.
1464
1614
 
1465
1615
  The **writers** name no keys: a section's `_index.md` is whatever the entry
1466
1616
  resolved to, `title` first. So extending the vocabulary is a change to the
@@ -1624,7 +1774,7 @@ path now rides on the error, and the loader that read the file resolves it
1624
1774
  against the YAML, so all of them come out located:
1625
1775
 
1626
1776
  ```text
1627
- package-build.config.yaml:382:64: error: package-build config: `site.sections.being.descrption` is not a recognized option (expected one of: title, banner).
1777
+ package-build.config.yaml:382:64: error: package-build config: `site.sections.being.descrption` is not a recognized option (expected one of: title, banner, description, listType, listSubType).
1628
1778
  ```
1629
1779
 
1630
1780
  The same two rules apply. A key the file never declares — a required one that is
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
@@ -49,12 +234,18 @@ That is the shape a content catalog wants regardless — it groups by what a pag
49
234
  _is_ rather than by where its file happened to be written — and it is what
50
235
  `sohl`'s catalog layouts already do.
51
236
 
52
- ## 4. `publish.address.landing` is inert, and still accepted
237
+ A site rendering through a **shared theme** has no layout of its own to edit. As
238
+ of 13.1.0 the section declares its query instead, and the theme runs it:
239
+
240
+ ```yaml
241
+ sections:
242
+ weapongear: { title: Weapons, listType: weapongear }
243
+ user-guide: { title: User Guide, listType: doc, listSubType: userguide }
244
+ ```
53
245
 
54
- It named which note addressed a whole section. There are no landings, so it
55
- selects nothing; `landing: readme` keeps loading because it stated something true
56
- when it was written, and the key is removed once no configuration declares it.
57
- `landing: collection` is still refused by name (#202).
246
+ `listType` is the content type, not the section's name the two need not agree,
247
+ and `listSubType` narrows a type whose genres share it. See
248
+ [What a section may declare](CONTENT.md#what-a-section-may-declare).
58
249
 
59
250
  ## What did not change
60
251