@heroiclands/package-build 14.0.0 → 16.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CONTENT.md CHANGED
@@ -73,6 +73,9 @@ paths:
73
73
  manifests: assets/manifests
74
74
  # Where `manifest` writes this package's own. Outbound, and a build artifact.
75
75
  manifestOut: build/manifests
76
+ # Where `content-index` writes this package's note index. Derived and
77
+ # disposable — never a source, and never inside `stage`.
78
+ contentIndex: build/content-index
76
79
  packJson: build/packs-json
77
80
  stage: build/stage/packs
78
81
  unpack: build/tmp/packs
@@ -455,9 +458,9 @@ error: pack "characters" (Actor) reads the compiled output of the Item pack
455
458
 
456
459
  ### An item type's default art
457
460
 
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:
461
+ A note that names no art gets its type's **default art**, and a type declares
462
+ that art in the same place it declares its builder. An `itemBuilders` entry may
463
+ be written two ways:
461
464
 
462
465
  ```js
463
466
  itemBuilders: {
@@ -478,8 +481,60 @@ same `resolveImg` rule as a note's `img:`, so `icons/relic.svg` means _this_
478
481
  repository's asset root — `modules/sohl-relics/assets/icons/relic.svg` — and an
479
482
  already-served path (`systems/sohl/assets/icons/…`) passes through untouched.
480
483
 
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
484
+ #### "Names no art" and "wants no art" are different (#218)
485
+
486
+ A note has two ways to leave `img:` empty, and they mean opposite things:
487
+
488
+ | a note writes | it means | it compiles with |
489
+ | -------------- | ------------------------------------ | ---------------- |
490
+ | nothing at all | _unset_ — name me no art | the type default |
491
+ | `img: null` | the same thing, said out loud | the type default |
492
+ | `img: ""` | _blank on purpose_ — I want no image | no image |
493
+ | `img: <path>` | this art | that path |
494
+
495
+ `resolveImg` returns `null` for the first two and `""` for the third, and every
496
+ caller pairs its default with **nullish** coalescing — `resolveImg(fm.img) ?? itemArt(type)`.
497
+ Never `||`: that collapses a deliberate blank back into the default and takes the
498
+ distinction away again, which is exactly what the function used to do.
499
+
500
+ **`portrait` is the same field twice over.** A being carries `img` (its token
501
+ art) and `portrait` (its sheet portrait) independently, and both resolve through
502
+ `resolveImg`, so the rule above is the rule for both.
503
+
504
+ This is the convention the project already holds for an optional "not specified"
505
+ DataModel string — `nullable, initial: null`, so "unset" is one honest value
506
+ rather than two.
507
+
508
+ > **The rule is `img`'s, and does not extend to `title`.** `title` is not art
509
+ > and never reaches `resolveImg`, so nothing here applies to it.
510
+ >
511
+ > The reason used to be sharper, and is no longer true: a note's top-level
512
+ > `title` was _also_ the shared source for an `affiliation` item's `system.title`
513
+ > (`sohl/item-fields.mjs`, "the style of address the office carries"), so one
514
+ > authored key fed two unrelated destinations that disagreed about what empty
515
+ > means — and `title: null` stringified into the compiled document as the literal
516
+ > `"null"`. That collision is gone: the field declares `topLevelMeans`, and the
517
+ > top-level key is no longer a source for it (#218).
518
+ >
519
+ > So `title: null` is now a note declining to state a heading, and the site
520
+ > emitter's `fm.title ?? name` falls back to `name.full`. `title: ""` still
521
+ > publishes a deliberately blank heading, and nothing warns about that yet.
522
+
523
+ Because `""` used to mean "unset", a note still carrying that spelling has
524
+ quietly changed meaning, and the frontmatter lint says so — for either art
525
+ field:
526
+
527
+ ```
528
+ Note.md:9:1: warning: `img: ""` means "ship no art at all" — it no longer falls
529
+ back to this type's default. Write `img: null` for a note that simply names
530
+ none; keep `""` only where the document is meant to have no image
531
+ ```
532
+
533
+ A warning, not an error: the note still compiles, to a document that is merely
534
+ iconless.
535
+
536
+ **A type with neither is a build error, deliberately.** When a note names no
537
+ art and its type pairs none, the pack build aborts rather than shipping an
483
538
  item with a mismatched icon:
484
539
 
485
540
  ```
@@ -570,6 +625,47 @@ system `S` is:
570
625
  `sohl:`", which is the degenerate case where source and destination happen to
571
626
  share a name.
572
627
 
628
+ **A spelling that means two different things skips step 3.** Because a field's
629
+ `name` doubles as its identity and as the shared property it draws from, the two
630
+ coincide only while the note vocabulary and the system vocabulary agree about
631
+ what a spelling means. `title` is where they do not. A note's top-level `title`
632
+ is _the title of the note_ — the heading its page publishes under, which the
633
+ site emitter reads. An `affiliation` item's `system.title` is _the style of
634
+ address the office carries_ — Ajaw, Warden, a person's style within the body.
635
+ They are unrelated quantities, and step 3 used to feed the second from the first
636
+ (#218).
637
+
638
+ That was not merely untidy, because **step 3 answers without applying
639
+ `field.default`** — only step 2 does — so an authored `title: null` reached the
640
+ field's `String()` coercion unguarded and shipped as the literal string `"null"`.
641
+
642
+ So a field may declare `topLevelMeans`: what the top-level key of that name means
643
+ _instead_. Declaring it removes step 3 for that field, and the value is the
644
+ reason rather than a bare flag, so the collision is legible where the field is
645
+ declared and the generated field reference can print it. It is a per-field
646
+ opt-out, not a change to the order — step 3 is right wherever the two levels
647
+ state the same quantity, which is nearly everywhere: `subType` is the other
648
+ declared item field spelled like a note-level key, and there the two agree by
649
+ design.
650
+
651
+ **An exempted field is still authorable**, at the two positions that describe the
652
+ document rather than the note:
653
+
654
+ ```yaml
655
+ title: The Order of the Silver Hand # the note's own heading — reaches the page
656
+ type: affiliation
657
+ subType: order
658
+ sohl:
659
+ system:
660
+ title: Warden # → document.system.title, the style of address
661
+ ```
662
+
663
+ `sohl.title`, the legacy in-block position, works the same way. A membership — a
664
+ `title` a particular being holds — is authored on the entry in that being's
665
+ `sohl.items`, whose `system` is overlaid on the catalogue document directly.
666
+ `data.title` is neither position: `title` is not a `data:` property any note type
667
+ declares, so the frontmatter lint refuses it.
668
+
573
669
  **`<system>.system` is written through verbatim**, at the DataModel's own paths,
574
670
  with no renaming layer. A key the system's published `schema.json` does not
575
671
  declare for the subtype the note compiles into is an **error naming the note**,
@@ -620,6 +716,7 @@ npx content-build links [root] [--manifests <dir>]
620
716
  npx content-build format [paths..] [--write]
621
717
  npx content-build markdown [paths..] [--fix]
622
718
  npx content-build manifest [root] [--out <dir>]
719
+ npx content-build content-index [root] [--out <dir>]
623
720
  npx content-build site [--out <dir>]
624
721
  npx content-build reachability <dir> [file] [--index <shortcode>]
625
722
  npx content-build addresses diff --from <zip|dir> [--strict]
@@ -635,6 +732,7 @@ npx content-build addresses diff --from <zip|dir> [--strict]
635
732
  | `format` | Prettier, with the shared configuration. See [Prose: formatting and markdown](#prose-formatting-and-markdown). |
636
733
  | `markdown` | markdownlint, with the shared rule set — the structure Prettier is indifferent to. |
637
734
  | `manifest` | Emit this package's cross-package link manifest. See [Publishing a link manifest](#publishing-a-link-manifest). |
735
+ | `content-index` | Emit this package's note index as JSON Lines. See [Publishing a content index](#publishing-a-content-index). |
638
736
  | `site` | Publish the content tree as a website. See [Publishing a website](#publishing-a-website). |
639
737
  | `reachability` | Walk outward from an index note and report what no path reaches, for a tree meant to be navigable from one entry point. |
640
738
  | `addresses` | Report every published item address this build has stopped publishing. See [Diffing published addresses](#diffing-published-addresses). |
@@ -1234,6 +1332,21 @@ order to satisfy a rendering engine's directory semantics. The file is now
1234
1332
  `<mount>/<type>-<shortcode>.md` and the front-matter `url:` still publishes it at
1235
1333
  the package root, one level above.
1236
1334
 
1335
+ **A page states its address without the package base; everything pointing _at_
1336
+ it composes one** (#217). They read as one quantity and are two:
1337
+
1338
+ | Written | Form | Because |
1339
+ | ------------------------------------------- | --------------------------- | --------------------------------------------------------------------------------------- |
1340
+ | A page's own `url:` front matter | `/<type>-<shortcode>/` | Hugo resolves it against `baseURL`, whose path already _is_ where the package is served |
1341
+ | Every `href` this build renders into a body | `<base><type>-<shortcode>/` | A browser resolves it against nothing |
1342
+ | A link-manifest `path` | `<type>-<shortcode>/` | Measured against `site.base` and stripped; a consumer prefixes its own |
1343
+
1344
+ `site.base` is the second and third of those and reaches the first not at all.
1345
+ It used to be written into the `url:` as well, so every consumer's Hugo prefixed
1346
+ its own base to a value that already carried one and published every content
1347
+ page a segment too deep — `/sohl/sohl/doc-rulesintro/`, 404 at the address the
1348
+ manifest, the sitemap and every inbound link named.
1349
+
1237
1350
  **There is no landing page.** A `README.md` was its section's landing and
1238
1351
  addressed the section itself; that is retired with the section. A page that
1239
1352
  introduces the notes of a type is an ordinary note addressed `doc-<type>`, with
@@ -1282,6 +1395,204 @@ It is **reported and omitted**, never guessed: the command prints one located
1282
1395
  diagnostic per note and still writes the file, because a note with no address is
1283
1396
  ordinary while a manifest entry pointing at a page that does not exist is not.
1284
1397
 
1398
+ ## Publishing a content index
1399
+
1400
+ Every content build walks the whole note tree and parses every note's
1401
+ frontmatter — the pack compilers, the site build, and the content-table expander
1402
+ each do it — and every one of them throws the result away. So nothing outside a
1403
+ build can ask a question about the content. "Which beings carry no `kbcat`?",
1404
+ "what does this table actually select?", "did that type rename leave anything
1405
+ behind?" have no answer short of writing a throwaway script that re-walks the
1406
+ tree, which is how eight dead Bestiary tables came to ship for weeks unnoticed.
1407
+
1408
+ `content-index` publishes the walk:
1409
+
1410
+ ```bash
1411
+ npx content-build content-index
1412
+ # sohl → build/content-index/sohl.jsonl (1606 notes, 1578 KiB)
1413
+ ```
1414
+
1415
+ One line of [JSON Lines](https://jsonlines.org/) per note, holding the note's
1416
+ whole frontmatter plus where it sits in the tree:
1417
+
1418
+ ```json
1419
+ {
1420
+ "type": "being",
1421
+ "shortcode": "aurochs",
1422
+ "package": "sohl",
1423
+ "file": { "path": "Bestiary/Animal/Aurochs.md", "folder": "Bestiary/Animal", "name": "Aurochs" },
1424
+ "sohl": { "kbcat": "animal", "body": { "weight": { "base": 1500 } } }
1425
+ }
1426
+ ```
1427
+
1428
+ so a question is one line of `jq`:
1429
+
1430
+ ```bash
1431
+ jq -r 'select(.type == "being" and .sohl.kbcat == "animal") | .shortcode' \
1432
+ build/content-index/sohl.jsonl
1433
+ ```
1434
+
1435
+ ### The record is the note, not a projection of it
1436
+
1437
+ Nothing is selected, flattened, or renamed. A reader addresses
1438
+ `sohl.body.weight.base` because that is what the note says — which is also,
1439
+ not by accident, exactly what a `dataview` content-table query writes.
1440
+
1441
+ That is a deliberate refusal to impose a schema, and the tree is why. In `sohl`,
1442
+ frontmatter spreads **242 distinct leaf paths** unevenly over **15 types**, from
1443
+ 9 on a `macro` to 72 on a `being`, and adding a field to one type is ordinary
1444
+ authoring. A format with a fixed column set would turn that authoring into a
1445
+ schema migration; a document format has no such problem.
1446
+
1447
+ Two keys are **derived** rather than authored, and a note carrying either is an
1448
+ error rather than a silent overwrite:
1449
+
1450
+ | Key | What it holds |
1451
+ | --------- | -------------------------------------------------------------------------------------------------- |
1452
+ | `package` | The configured `contentPackage`. A note may not declare its own, and the expander reads the same. |
1453
+ | `file` | `path`, `folder` and `name` below the content root — the same `file.*` a content-table query uses. |
1454
+
1455
+ The location is namespaced under `file` precisely because `folder` is real
1456
+ frontmatter on most notes; a record states both, and they mean different things.
1457
+
1458
+ ### Every note's address, and every anchor it defines
1459
+
1460
+ A record states the address a wikilink writes to reach the note, and every
1461
+ `{#slug}` anchor its body declares:
1462
+
1463
+ ```json
1464
+ {
1465
+ "address": { "slug": "being-aurochs", "canonical": "sohl-being-aurochs" },
1466
+ "file": { "path": "Bestiary/Animal/Aurochs.md", "folder": "Bestiary/Animal", "name": "Aurochs" },
1467
+ "anchors": [
1468
+ {
1469
+ "slug": "appearance",
1470
+ "name": "Appearance",
1471
+ "level": 1,
1472
+ "line": 348,
1473
+ "link": "being-aurochs#appearance"
1474
+ },
1475
+ {
1476
+ "slug": "dossier",
1477
+ "name": "Dossier",
1478
+ "level": 1,
1479
+ "line": 352,
1480
+ "link": "being-aurochs#dossier"
1481
+ }
1482
+ ]
1483
+ }
1484
+ ```
1485
+
1486
+ A record also states `nameAscii`, the note's `name.full` reduced to printable
1487
+ 7-bit ASCII:
1488
+
1489
+ | `name.full` | `nameAscii` |
1490
+ | --------------- | ----------------- |
1491
+ | `Kûrbúl ¾-Helm` | `Kurbul 3/4-Helm` |
1492
+ | `Kèthîra` | `Kethira` |
1493
+ | `Ærling` | `AErling` |
1494
+ | `Þorn` | `Thorn` |
1495
+ | `Ðunhold` | `Dunhold` |
1496
+ | `Straße` | `Strasse` |
1497
+
1498
+ Names carry the setting's orthography and nobody types them, so anything
1499
+ searching or completing over the index needs a form a keyboard produces. Stating
1500
+ one means every consumer matches the same way, rather than each inventing a
1501
+ slightly different fold and two searches over the same data disagreeing.
1502
+
1503
+ It **transliterates rather than strips**, through the same `unidecode` table
1504
+ `slugify` already runs — so an ASCII name and a slug can never disagree about a
1505
+ character. Diacritics fold, ligatures expand, the runic letters spell out, and a
1506
+ vulgar fraction becomes readable. Deleting the marks instead would reduce
1507
+ `Kûrbúl` to `Krbl`, which is worse than the original for anyone trying to
1508
+ recognise it. Whatever is still outside printable ASCII afterwards becomes a
1509
+ space and runs of whitespace collapse — a space rather than nothing, so a
1510
+ character that transliterates away cannot weld two words together.
1511
+
1512
+ The value is emitted even when it equals the name, so a consumer matching on it
1513
+ never has to branch on whether the name happened to be ASCII already; it is
1514
+ `null` only when the note has no name at all. On the `sohl` tree, 25 of 1,606
1515
+ notes differ from their `name.full`.
1516
+
1517
+ `aliasesAscii` does the same for `name.aliases`, in the authored order. An alias
1518
+ is the name a reader is at least as likely to reach for as the canonical one —
1519
+ `Killer Whale` for an orca, `Ice Bear` for a polar bear, `Ix'balam` for a
1520
+ jaguar — so anything searching the index has to match them too. It is an **empty
1521
+ array**, never null, when a note has no aliases: an empty set of names is a fact
1522
+ rather than a missing value, and a consumer iterating it should not have to check
1523
+ first. An entry that is not a non-empty string is dropped rather than left as a
1524
+ hole, since the array is a set of names to match and a null is not one.
1525
+
1526
+ `address.slug` is what goes inside `[[…]]` within the package; `address.canonical`
1527
+ is the package-qualified key the link manifest files the note under. Both are
1528
+ `null` for a note with no type or no shortcode, which has no address at all — the
1529
+ record says so rather than leaving each reader to rediscover the rule.
1530
+
1531
+ **Neither is new information** — both derive from `type` and `shortcode`, which
1532
+ every record already carries. What the fields add is the _rule_: the lowercasing
1533
+ and the hyphen join live in one place, derived by the same `addressSlug` and
1534
+ `canonicalKey` the manifest and the site build use, so an index cannot disagree
1535
+ with either about where a note lives. A consumer that reimplements the join
1536
+ slightly differently gets a lookup matching nothing and no explanation — which is
1537
+ exactly how a resolver keyed on a bare `type/shortcode` silently misses every
1538
+ canonical `pkg-type-shortcode` entry.
1539
+
1540
+ **Anchors make a link checkable without a build.** Because the index states every
1541
+ anchor a note defines, `[[being-aurochs#dossier]]` can be confirmed — or shown
1542
+ dead — by a lookup, rather than by re-parsing the tree. Each anchor also carries
1543
+ its **line in the file**, so an editor jumps straight to the heading instead of
1544
+ searching for it, and a diagnostic about a section can name a real position.
1545
+
1546
+ Only headings carrying an explicit `{#slug}` are listed. A bare `#` heading also
1547
+ starts a journal page, but declares no slug, so nothing can address it with `#…`
1548
+ and listing it would offer a link that cannot be written. What counts as an anchor
1549
+ is kept identical to what `splitPages` matches — that pass decides which sections
1550
+ become addressable journal pages — and a test asserts the two agree, so drift
1551
+ fails the suite rather than advertising a link that resolves nowhere.
1552
+
1553
+ **The path stays relative.** `file.path` is below the content root and is
1554
+ deliberately never absolute: an absolute path is a fact about the machine that
1555
+ built the index rather than about the content, so it would differ between two
1556
+ checkouts of the same tree — costing the byte-stability the artifact depends on —
1557
+ and a published copy would carry someone's home directory and be wrong for every
1558
+ reader. Anyone holding the index knows the root it was built from, and
1559
+ `root + file.path` is the absolute form whenever it is wanted.
1560
+
1561
+ ### Why JSON Lines, and not a database
1562
+
1563
+ The artifact has to survive the build that made it and be usable by anything — a
1564
+ person with `jq`, an editor, a CI check, another package's build. A
1565
+ line-per-note text file needs no server, no driver, and no schema; it is
1566
+ readable by every language without an install; and it **diffs**, so a migration
1567
+ that quietly empties a category shows up as a reviewable change rather than as a
1568
+ silently different binary.
1569
+
1570
+ Choosing it forfeits no SQL: DuckDB reads JSON Lines directly, with nested
1571
+ access, so `FROM read_json_auto('build/content-index/sohl.jsonl')` is a query
1572
+ away. A stored schema would forfeit the open shape, which is the asymmetry that
1573
+ decides it.
1574
+
1575
+ ### It is derived, disposable, and byte-stable
1576
+
1577
+ The index is written under `build/`, gitignored with the rest of it, and
1578
+ **nothing may be authored against it**. It is deliberately not in `paths.stage`:
1579
+ that tree is mirrored destructively into a Foundry data root, so anything left
1580
+ there ships inside the installed system to every player.
1581
+
1582
+ Regenerating costs a frontmatter parse rather than a build, so the intended way
1583
+ to use it is to rebuild it whenever it looks stale — which is why it is a
1584
+ command of its own and not only a build step, and why it need never be
1585
+ committed.
1586
+
1587
+ That only holds if a rebuild is a no-op when nothing changed, so the output is
1588
+ **byte-stable**: records are ordered by content path with the note id breaking
1589
+ any tie, and every object's keys are sorted at every depth. A walk order is a
1590
+ directory-read order, and directory-read order is not a fact about the content.
1591
+
1592
+ An empty tree is an **error**, not an empty index. A reader takes the file as
1593
+ authoritative, and an index stating that a package has no content is
1594
+ indistinguishable from one built against a mis-pointed tree.
1595
+
1285
1596
  ## Publishing a website
1286
1597
 
1287
1598
  ```bash
@@ -1363,7 +1674,8 @@ The homepage's file is written at the root of `site.out` — the package's own
1363
1674
  site root, one level above the content mount, which is where
1364
1675
  `publish.address.prefix` puts everything else — under the name its address gives
1365
1676
  it, `homepage-root.md`. As with every other page, the front matter's `url`
1366
- decides where it publishes.
1677
+ decides where it publishes, and states it relative to the site root — `site.base`
1678
+ does not reach it (#217).
1367
1679
 
1368
1680
  **What it does not do is decide addresses.** Those come from `publish.address`,
1369
1681
  the same setting the link manifest reads, so a page and its manifest entry cannot
@@ -1374,7 +1686,7 @@ published beside the content:
1374
1686
  ```yaml
1375
1687
  site:
1376
1688
  out: kb/content # required; wiped on every run
1377
- base: /sohl/ # default: /<contentPackage>/
1689
+ base: /sohl/ # default: /<contentPackage>/ — hrefs only, never a page's `url:`
1378
1690
  packages: [sohl, thalorna] # default: just contentPackage
1379
1691
  backfillSections: true
1380
1692
  landing: { title: Knowledgebase, type: knowledgebase }
@@ -1394,18 +1706,18 @@ site:
1394
1706
  dev-docs: { title: Developer Documentation, banner: banners/dev-docs.webp }
1395
1707
  ```
1396
1708
 
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. |
1709
+ | Key | What it decides |
1710
+ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1711
+ | `out` | The Hugo content root. **Required** in both modes, and wiped on every run — see below. |
1712
+ | `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>/`. |
1713
+ | `packages` | Which content packages this site renders. Defaults to its own. |
1714
+ | `sections` | The Hugo sections this site declares, and what each says about itself — see below. |
1715
+ | `readmeSections` | The same, for a `trees` entry, whose landing comes from its own `README`. |
1716
+ | `landing` | Frontmatter for the mount's own `_index.md`. Passed through — the vocabulary is the theme's. |
1717
+ | `backfillSections` | Write a bare `_index.md` for any other directory directly under the mount. |
1718
+ | `trees` | Extra source trees published beside the content, preserving their source layout below a section. |
1719
+ | `pass` | A named bundle of this repository's own body rewrites. |
1720
+ | `passOptions` | That bundle's options. |
1409
1721
 
1410
1722
  ### What a section may declare
1411
1723
 
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