@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/CHANGELOG.md +327 -0
- package/CONTENT.md +331 -19
- package/MIGRATING.md +185 -0
- package/bin/content-build.mjs +54 -0
- package/content-config.mjs +10 -0
- package/docs/content-format.md +21 -0
- package/engine/content-address.mjs +21 -36
- package/engine/content-index.mjs +439 -0
- package/engine/field-reference.mjs +29 -0
- package/engine/field-spec.mjs +25 -0
- package/engine/frontmatter-lint.mjs +171 -1
- package/engine/helpers.mjs +38 -12
- package/engine/homepage.mjs +9 -4
- package/engine/index.mjs +3 -0
- package/engine/item-registry.mjs +4 -1
- package/engine/macros.mjs +3 -1
- package/engine/site-build.mjs +20 -14
- package/engine/system-block.mjs +29 -3
- package/package.json +1 -1
- package/sohl/actors.mjs +6 -3
- package/sohl/item-fields.mjs +6 -0
- package/sohl/items.mjs +4 -1
- package/types/content-config.d.mts +9 -0
- package/types/engine/content-address.d.mts +22 -34
- package/types/engine/content-index.d.mts +194 -0
- package/types/engine/field-spec.d.mts +53 -0
- package/types/engine/helpers.d.mts +34 -12
- package/types/engine/homepage.d.mts +8 -4
- package/types/engine/index.d.mts +1 -0
- package/types/engine/site-build.d.mts +11 -6
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
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
482
|
-
|
|
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
|