@heroiclands/package-build 22.4.6 → 22.5.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/CONTENT.md +192 -1
  3. package/bin/content-build.mjs +26 -4
  4. package/content-config.mjs +8 -3
  5. package/docs/api.md +99 -26
  6. package/docs/configuration.md +20 -1
  7. package/docs/content-format.md +238 -0
  8. package/docs/diagnostics.md +44 -0
  9. package/engine/content-index.mjs +63 -21
  10. package/engine/content-links.mjs +60 -6
  11. package/engine/content-tables.mjs +29 -2
  12. package/engine/frontmatter-lint.mjs +2 -0
  13. package/engine/holdings.mjs +416 -0
  14. package/engine/index-records.mjs +25 -0
  15. package/engine/index.mjs +16 -0
  16. package/engine/map-graphviz.mjs +12 -3
  17. package/engine/map-places.mjs +33 -3
  18. package/engine/market-class.mjs +178 -0
  19. package/engine/metadata-index.mjs +7 -4
  20. package/engine/note-state.mjs +213 -0
  21. package/engine/note-vocabulary.mjs +59 -2
  22. package/engine/pdf-build.mjs +4 -2
  23. package/engine/populations.mjs +557 -0
  24. package/engine/site-build.mjs +96 -10
  25. package/engine/site-maps.mjs +165 -0
  26. package/engine/sql-tables.mjs +157 -4
  27. package/engine/stub-lint.mjs +237 -0
  28. package/engine/wikilink-syntax.mjs +27 -1
  29. package/package.json +1 -1
  30. package/types/engine/content-index.d.mts +19 -5
  31. package/types/engine/content-links.d.mts +3 -3
  32. package/types/engine/content-tables.d.mts +2 -1
  33. package/types/engine/holdings.d.mts +157 -0
  34. package/types/engine/index-records.d.mts +22 -0
  35. package/types/engine/index.d.mts +6 -0
  36. package/types/engine/map-graphviz.d.mts +8 -1
  37. package/types/engine/map-places.d.mts +26 -0
  38. package/types/engine/market-class.d.mts +62 -0
  39. package/types/engine/note-state.d.mts +110 -0
  40. package/types/engine/note-vocabulary.d.mts +25 -0
  41. package/types/engine/populations.d.mts +121 -0
  42. package/types/engine/site-build.d.mts +37 -6
  43. package/types/engine/site-maps.d.mts +81 -0
  44. package/types/engine/stub-lint.d.mts +51 -0
  45. package/types/engine/wikilink-syntax.d.mts +19 -1
@@ -770,6 +770,7 @@ Any other key under `docs.itemFields` is refused:
770
770
  | `site.notfound` | object | `null` |
771
771
  | `site.hugo` | object | `{}` |
772
772
  | `site.search` | boolean | `true` |
773
+ | `site.maps` | boolean | `true` |
773
774
 
774
775
  How much of a package reaches the web at all is **not** here — it is
775
776
  [`publish.site`](#publish). `site` is framing: which named pass bundle
@@ -819,7 +820,7 @@ published. A `site.out` is refused by name:
819
820
 
820
821
  > ``package-build config: `site` must be a mapping.``
821
822
 
822
- > ``package-build config: `site.<key>` is not a recognized option (expected one of: base, assets, description, packages, pass, passOptions, notfound, hugo, search).``
823
+ > ``package-build config: `site.<key>` is not a recognized option (expected one of: base, assets, description, packages, pass, passOptions, notfound, hugo, search, maps).``
823
824
 
824
825
  `site.assets` is the host every package's imagery is served from, and it is
825
826
  the one address in this file that is not this repository's own. A note names
@@ -950,6 +951,24 @@ site:
950
951
 
951
952
  > ``package-build config: `site.search` must be a boolean.``
952
953
 
954
+ `site.maps` is whether every place page carries the map from that place. On
955
+ by default: `content-build site` draws the map `content-build map --from`
956
+ draws — the place's borders and routes, each neighbour at its bearing — for
957
+ every place that states a border or a route or is named in one, lays it beside
958
+ the page as `from-<shortcode>.svg`, and names the file in the page's front
959
+ matter as `map`, which the theme inlines so every place name on it is a link
960
+ to that place's page. A place with no relation carries no map and no key.
961
+ GraphViz draws; when it is not installed the build says so once, as a
962
+ warning, and writes every page as it would without maps — a site never fails
963
+ for want of one. `false` draws nothing and asks nothing of GraphViz.
964
+
965
+ ```yaml
966
+ site:
967
+ maps: false
968
+ ```
969
+
970
+ > ``package-build config: `site.maps` must be a boolean.``
971
+
953
972
  ### The generated Hugo configuration
954
973
 
955
974
  `content-build site` writes `build/hugo/hugo.toml` on every run. Every value
@@ -1657,6 +1657,83 @@ not honoured in part either.
1657
1657
  convention rather than declared in a field — see
1658
1658
  [the lead image](#the-lead-image).
1659
1659
 
1660
+ ### Three states of a note
1661
+
1662
+ A note has three states, and two of them are nothing but conventions the format
1663
+ already carried.
1664
+
1665
+ | State | Body | Marker | Page | Document | Link target |
1666
+ | --------- | ------- | --------------- | ----------- | -------------------------------- | ----------- |
1667
+ | **stub** | empty | none | no | yes, where its type compiles one | no |
1668
+ | **draft** | written | `tags: [draft]` | yes, marked | yes | yes |
1669
+ | **full** | written | none | yes | yes | yes |
1670
+
1671
+ **Full is the absence of everything.** It is the default and carries no marker,
1672
+ which is right: the overwhelming majority of a finished corpus should say
1673
+ nothing about its own finishedness.
1674
+
1675
+ There is no new frontmatter key, no new filetype and no second parser. A stub is
1676
+ a `.md` file the walk already finds, and the moment somebody writes a body it
1677
+ becomes an ordinary note — nothing moves, nothing is deleted, and every inbound
1678
+ link starts resolving.
1679
+
1680
+ #### A stub is a note with an empty body
1681
+
1682
+ > **The body is empty when everything after the closing frontmatter fence is
1683
+ > whitespace.** Nothing else is empty.
1684
+
1685
+ The rule is that severe because the test has to be one you can apply by looking
1686
+ at the file. Every softer rule makes two files that look different behave the
1687
+ same.
1688
+
1689
+ | After the fence | Empty? | What happens |
1690
+ | ----------------------- | ------ | --------------------------------------------------------------- |
1691
+ | Nothing, or blank lines | yes | A stub |
1692
+ | An HTML comment | no | A note with a body that renders to nothing. The lint reports it |
1693
+ | A horizontal rule | no | The same |
1694
+ | A lone heading | no | The same |
1695
+ | `_To be written._` | no | The same |
1696
+
1697
+ An HTML comment is deliberately not empty. An invisible marker deciding whether
1698
+ a page exists is exactly the failure the retired `draft:` field was removed for.
1699
+
1700
+ #### What a stub emits
1701
+
1702
+ A stub's index record is its frontmatter plus the derived keys, **minus
1703
+ `address` and `anchors`**. The absent address is the whole signal, and it is the
1704
+ column a table renderer already reads.
1705
+
1706
+ It keeps everything else:
1707
+
1708
+ - **`file`**, which is what you edit and what every diagnostic names.
1709
+ - **`id` and `foundry`**, because a **document is derived from `data:`, not from
1710
+ prose**. An unwritten arcane talent still compiles into its Item, and its
1711
+ documentation journal is still a document in its own right. **The empty body
1712
+ suppresses the page, never the document.**
1713
+
1714
+ What is suppressed with the page is the web address and the link target: a
1715
+ wikilink into a stub is refused, and a table cell listing one prints its name as
1716
+ plain text rather than as a link.
1717
+
1718
+ #### Two types are structural and exempt
1719
+
1720
+ `folder` and `homepage` have no body by design — a folder note's page is a
1721
+ generated section index, and the homepage's is the site's front door. An empty
1722
+ body on either keeps its address and resolves as **full**, because such a note is
1723
+ complete.
1724
+
1725
+ Every type in the vocabulary declares whether an empty body suppresses its page,
1726
+ so adding a type means answering the question rather than inheriting a default.
1727
+
1728
+ #### A stub is still referenceable as data
1729
+
1730
+ `borders.to` and `routes.to` name a place by shortcode, and those are **index
1731
+ relations rather than page links**: a border with a stub on the far side is a
1732
+ valid statement about the world and resolves. So does `parents` for the
1733
+ containment tree and `domains` for tenure — a stub appears in its region's
1734
+ `contains` and on its holder's `holdings`, rendered as plain text. What a stub
1735
+ cannot be is the target of a wikilink, because a wikilink points at a page.
1736
+
1660
1737
  ### What a note produces
1661
1738
 
1662
1739
  Note types fall into two groups, and only the first has a mapping table.
@@ -1903,6 +1980,49 @@ journals. A nested field is addressed exactly as a note authors it —
1903
1980
  every nested object is inferred as a struct. A field a note type does not carry
1904
1981
  reads `NULL` rather than failing.
1905
1982
 
1983
+ **`notes` leaves out stubs; `entries` does not.** Both carry a derived `state`
1984
+ column — `stub`, `draft` or `full` — and `notes` is exactly
1985
+ `SELECT * FROM entries WHERE state <> 'stub'`. So every table already written
1986
+ selects what it always selected, and gains `state` for free; a table that should
1987
+ list the unwritten beside the written changes one word:
1988
+
1989
+ ````markdown
1990
+ ```sql
1991
+ SELECT address.slug AS _ref,
1992
+ name.full AS "Name",
1993
+ state AS "State",
1994
+ m.name AS "Market",
1995
+ data.population AS "People"
1996
+ FROM entries
1997
+ LEFT JOIN market m ON m.value = data.market
1998
+ WHERE type = 'place' AND subType = 'settlement'
1999
+ AND list_contains(data.parents, 'place-aelwyth')
2000
+ ORDER BY data.population DESC NULLS LAST, name.full COLLATE NOCASE
2001
+ ```
2002
+ ````
2003
+
2004
+ A written settlement renders as a link and a stub renders as plain text, from
2005
+ one query with no branch in it: `_ref` is the stub's absent address, and the
2006
+ renderer already prints a bare cell where it is null.
2007
+
2008
+ A fence selecting `FROM notes` that **would** gain rows from `FROM entries` is
2009
+ warned about at the fence, with the count — so adopting stubs in a given table
2010
+ stays a decision somebody makes rather than one they forget.
2011
+
2012
+ The ladder is what makes "how finished is this?" a query:
2013
+
2014
+ ```sql
2015
+ SELECT state, count(*) AS n FROM entries GROUP BY state ORDER BY state
2016
+ ```
2017
+
2018
+ **`market`** is the six-step market scale as a relation — `value`, `name`,
2019
+ `trade` — so a table prints `village` beside the number a note wrote without a
2020
+ second copy of the scale living in authored content.
2021
+
2022
+ **The state is derived, never stored.** No index record carries it, no note can
2023
+ author it, and nothing in `data:` may claim it: the only authored signal is the
2024
+ `draft` tag, which marks the one thing reading the body cannot reveal.
2025
+
1906
2026
  **Two aliases are read by the renderer rather than printed**, because which
1907
2027
  column links and where a section breaks are decisions about output, not
1908
2028
  relational operations:
@@ -2440,6 +2560,23 @@ subType `deity` covers a god's nature, domains, epithets and aspects in prose.
2440
2560
  That frees the name for the territorial sense, which earns it: every affiliation
2441
2561
  subType holds sway somewhere, while the divine sense applies to one.
2442
2562
 
2563
+ **`domains` names what the affiliation holds directly**, and nothing beneath
2564
+ it. A polity whose `domains` names a region holds the region; the settlements
2565
+ within it are the region's, reached through its `contains`, and are not
2566
+ repeated here. A house holds its manors and names each; the crown holds the
2567
+ realm and names the realm. What lies below a holder is answered by the two
2568
+ hierarchies together — `place.parents` for where a place sits, and
2569
+ `affiliation.parents` for whom a holder answers to — so a subinfeudated manor
2570
+ sits in one region by the first and under a lord of another polity by the
2571
+ second, and both pages say so.
2572
+
2573
+ The website reads `domains` across the package and every fetched index and
2574
+ writes the affiliation's page a `holdings` list — every place its `domains`
2575
+ names, each entry `{ title, url, type, subType }`, sorted by `subType` then
2576
+ `title`, and absent when `domains` names nothing. Every place it names carries
2577
+ the affiliation back in `held_by`. Both are derived; a note that writes one has
2578
+ it replaced.
2579
+
2443
2580
  If `sohl` is present, this becomes an `affiliation` item.
2444
2581
 
2445
2582
  | shared source | → sohl | → hm3 |
@@ -3087,9 +3224,38 @@ three maps, and none of those maps needs to know it is a keep.
3087
3224
  | `lore` | `WikiLink[]` | Lore concerning this place — its peoples, its law, its calendar, its history |
3088
3225
  | `parents` | `WikiLink[]` | Enclosing places within which this place is located |
3089
3226
  | `population` | `number` | Approximate population (precision 2 significant digits) |
3227
+ | `market` | `number` | Market class, 1 to 6 — what trade a settlement supports |
3090
3228
  | `borders` | `{ to, bearing }[]` | Places sharing a frontier with this one, and where each lies from here |
3091
3229
  | `routes` | `{ to, bearing, mode, days, terrain?, leagues? }[]` | Journeys from this place's centre to another place |
3092
3230
 
3231
+ #### What a settlement's market supports
3232
+
3233
+ `population` states how many people a settlement holds; `market` states what a
3234
+ traveller can buy there. Those are different questions — two settlements of
3235
+ eight hundred are a different proposition when one holds a weekly market and
3236
+ the other a chartered fair with a moneylender — so a note states both, either,
3237
+ or neither.
3238
+
3239
+ The value is a **class**, not a measurement. The numbers order six steps and do
3240
+ nothing else: a `5` is not a market two fifths larger than a `3`, it is a city
3241
+ rather than a town. `content-build lint` refuses a value off the scale instead
3242
+ of rounding it onto one, so a reader can trust that a number means the same
3243
+ thing on every page.
3244
+
3245
+ | `market` value | the settlement is | what can be had there |
3246
+ | -------------- | ----------------- | ------------------------------------------------------------------------------------------------- |
3247
+ | `1` | a hamlet | no market — what neighbours trade among themselves |
3248
+ | `2` | a village | a weekly market: staples, a smith, what a household cannot make |
3249
+ | `3` | a town | a regular market, most common goods, several trades working full time |
3250
+ | `4` | a market town | a chartered fair, goods carried from a week away, a moneylender |
3251
+ | `5` | a city | anything ordinary, in quantity; foreign goods; a guild structure |
3252
+ | `6` | a great city | the rare and the imported as a matter of course; banking; the market from which other markets buy |
3253
+
3254
+ The key is meaningful on a settlement, and the lint checks no `subType`
3255
+ condition — as it checks none for `population`. Which places a fact says
3256
+ something about is the author's judgement, and the `data:` container is closed
3257
+ per type rather than per subType.
3258
+
3093
3259
  #### Where a place is, in relation to other places
3094
3260
 
3095
3261
  `parents` states what a place is **within**. Two more properties state what
@@ -3187,6 +3353,78 @@ closed set, and each finding is located at the entry that states it.
3187
3353
  | `river` | boat |
3188
3354
  | `lake` | boat |
3189
3355
 
3356
+ #### What lies within a place, and who holds it
3357
+
3358
+ `parents` states what a place is within; an affiliation's `domains` states
3359
+ what it holds. The website reads both across the package and every fetched
3360
+ index and writes each place's page two lists, each entry
3361
+ `{ title, url, type, subType }` and each absent when empty:
3362
+
3363
+ - `contains` — every place whose `parents` names this one, sorted by
3364
+ `subType` then `title`. A region lists its settlements, sites, structures
3365
+ and features; a settlement lists the structures within it.
3366
+ - `held_by` — every affiliation whose `domains` names this one, sorted by
3367
+ `title`.
3368
+
3369
+ Both are derived; a note that writes one has it replaced. **`domains` is
3370
+ never expanded**: a region held by a polity lists the polity in `held_by`, and
3371
+ a settlement within that region lists only the house or order whose `domains`
3372
+ names it — its lord's lord is reached through `affiliation.parents`.
3373
+
3374
+ **A settlement, a site or a structure is held by someone.** One of those that
3375
+ no affiliation's `domains` names — in this package or in a fetched index — is
3376
+ a warning from `content-build lint`, `unheld land`, located at the note's
3377
+ `type:` line, so a gap in tenure shows while a note is written. A `world`, a
3378
+ `region` and a `feature` are exempt: a region is held through its polity's
3379
+ `domains`, and a river by nobody.
3380
+
3381
+ #### Population figures
3382
+
3383
+ `population` is an approximate count, written to two significant digits. A
3384
+ place states one, and so does an affiliation — how many people it counts,
3385
+ wherever they are. `content-build lint` reads those figures against each other
3386
+ and reports four warnings, each named by the phrase its message begins with.
3387
+
3388
+ - **`over-held land`** — the polities whose `domains` name a place count more
3389
+ people than the place states. A polity subordinate to another polity holding
3390
+ the same place is already inside that polity's figure and is not counted
3391
+ twice. A **settlement is exempt**: the polity of a city-state holds the city
3392
+ and counts the hinterland around it, so its figure is properly the larger.
3393
+ - **`over-full region`** — the regions whose `parents` name a place count more
3394
+ people than the place states. A region naming both its parent and its
3395
+ grandparent counts under the nearer of the two, so a place written into two
3396
+ levels of the hierarchy is not counted at both.
3397
+ - **`oversized settlement`** — a settlement states more people than a place
3398
+ containing it does, whether its own region or any place above that.
3399
+ - **`disputed figure`** — a `doc` note carries a figure beside a wikilink to
3400
+ the place or affiliation it belongs to, and the two disagree. The finding is
3401
+ located at the number the page wrote, and a figure written twice is located
3402
+ twice.
3403
+
3404
+ The first three are reported on the note's own `data.population`; the fourth
3405
+ on the citation.
3406
+
3407
+ **A figure is read as a population only where it carries `~`.** That marker is
3408
+ what separates a population from the other numbers a table puts beside a place
3409
+ — a distance, a stage count, a tally of days — so `| [[place-rgn\|X]] | ~2,000 |`
3410
+ is a claim about people and `| [[place-rgn\|X]] | 3 |` is not.
3411
+
3412
+ **Rounding is admitted.** Two significant digits carry a half-unit of error,
3413
+ widest at the bottom of a decade, so a place whose figure falls up to five per
3414
+ cent below what it contains is not a finding.
3415
+
3416
+ **An unstated figure is silent.** A place or affiliation with no `population`
3417
+ contributes nothing to a sum and is never the subject of a finding, so a
3418
+ half-written region is quiet rather than noisy. A fetched index carries
3419
+ `parents` and `domains` but no figure, so a dependency's places sit in the
3420
+ geography and its people are counted nowhere.
3421
+
3422
+ **There is deliberately no urban-share rule.** The share of a region's people
3423
+ living in its named settlements cannot be measured from the notes: the
3424
+ settlement layer names the notable places and is complete nowhere, so the ratio
3425
+ reports how much of a region has been written rather than how much of it is
3426
+ urban.
3427
+
3190
3428
  **A place declares only what is true of ground.** Four properties were removed
3191
3429
  because they were true of something else, and each removal has a home to go to.
3192
3430
 
@@ -163,6 +163,50 @@ A configuration finding reads the same as any other:
163
163
  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).
164
164
  ```
165
165
 
166
+ ## A worked case: the three states of a note
167
+
168
+ A note with an empty body is a **stub**, and the diagnostics about one show each
169
+ rule above in turn.
170
+
171
+ An empty body must be deliberate, so a stub that says nothing about itself is an
172
+ error, located at its `type:` line — the key whose value made the note what it
173
+ is:
174
+
175
+ ```text
176
+ Regions/Aelwyth/Weyshott.md:4:1: error: this note has no body, so it is a stub — and a stub carries a `description`, which is the only sentence a reader gets and what every table listing it prints
177
+ ```
178
+
179
+ A body that reduces to a placeholder is located **by searching the file for the
180
+ phrase the rule matched**, which is the implicit-position rule: the finding is
181
+ about a literal, so it lands on that literal rather than on the frontmatter:
182
+
183
+ ```text
184
+ Regions/Aelwyth/Harad.md:14:1: error: this note's body says only "_To be written._", so it publishes a page that tells a reader nothing — empty the body to make this a stub, or write it
185
+ ```
186
+
187
+ A link into a stub is refused, and because the stub is in the index the message
188
+ can name the file to open rather than reporting that the address resolves
189
+ nowhere:
190
+
191
+ ```text
192
+ Regions/Aelwyth/Aelwyth_Region.md:41:14: error: address [[place-weyshott]] names a stub at Regions/Aelwyth/Weyshott.md, which has no body and therefore no page — name it in prose, or write the note
193
+ ```
194
+
195
+ A body that may be a draft that forgot its marker is a **warning**, because the
196
+ rule is a guess and a corpus holds many short notes that are complete:
197
+
198
+ ```text
199
+ Rituals/Meivor.md:3:1: warning: this note's body is 4 word(s) and carries no `draft` tag — tag it if it is a beginning, and leave it alone if it says everything it has to say
200
+ ```
201
+
202
+ And the counts, and the drafts nothing has touched in longest, are **prose**:
203
+ a note nobody has finished is not wrong, so it is listed rather than flagged,
204
+ and the summary carries no position because it has none.
205
+
206
+ ```text
207
+ thalorna: 2572 notes — 2089 full, 464 draft, 19 stub.
208
+ ```
209
+
166
210
  ## Summary counts and prose are not findings
167
211
 
168
212
  A run's progress and summary lines — `42 address(es) across 10 note(s).`,
@@ -114,6 +114,9 @@ import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./note-claims.mjs";
114
114
  export { collectAnchors };
115
115
  import { entriesForNote, foundryIdentities } from "./foundry-entries.mjs";
116
116
  import { walkMarkdownTree } from "./helpers.mjs";
117
+ // The one statement of what an empty body means, shared with the lint and the
118
+ // SQL view that derives the ladder from the absence this writes.
119
+ import { isStubNote } from "./note-state.mjs";
117
120
  import { resolveNoteId } from "./note-ids.mjs";
118
121
  // The retired-field refusal and the key locator, so a note authoring a derived
119
122
  // key is reported where it is rather than as a bare abort.
@@ -128,16 +131,19 @@ import {
128
131
  DERIVED_KEYS,
129
132
  isAssetRecord,
130
133
  isNoteRecord,
134
+ isStub,
131
135
  noteFile,
132
136
  recordPath,
133
137
  sortKeysDeep,
134
138
  } from "./index-records.mjs";
139
+ import { isDraftNote } from "./note-vocabulary.mjs";
135
140
 
136
141
  export {
137
142
  authoredFrontmatter,
138
143
  DERIVED_KEYS,
139
144
  isAssetRecord,
140
145
  isNoteRecord,
146
+ isStub,
141
147
  noteFile,
142
148
  recordPath,
143
149
  sortKeysDeep,
@@ -407,11 +413,18 @@ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage) {
407
413
  * @param {string} [options.absPath] - The file, read only on the failing path to
408
414
  * locate the offending key.
409
415
  * @param {string} options.contentPackage - The package the tree compiles as.
410
- * @param {string} [options.body] - The note's markdown body, for its anchors.
416
+ * @param {string} [options.body] - The note's markdown body, for its anchors
417
+ * and for the one question that decides whether the note publishes a page. A
418
+ * caller that states no body is stating an empty one, and the note is a stub.
411
419
  * @param {number} [options.bodyLine] - The 1-based file line the body starts on.
412
420
  * @param {object} [options.manifest] - The package manifest, which the Foundry
413
421
  * entries are derived against.
414
- * @returns {Record<string, any>} The record, keys sorted at every depth.
422
+ * @returns {Record<string, any>} The record, keys sorted at every depth. A
423
+ * **stub** — a note with an empty body, on a type an empty body suppresses —
424
+ * carries `address` and `anchors` as `null`: it publishes no page, so it
425
+ * holds no address and offers no anchor. It keeps everything else, its `id`
426
+ * and its `foundry` block included, because the document it compiles is
427
+ * derived from `data:` rather than from prose.
415
428
  * @throws {Error} When the note carries a key this module derives, which would
416
429
  * otherwise be overwritten without a word. `file` and, where the file was
417
430
  * read, `position` ride on the error.
@@ -430,23 +443,36 @@ export function buildIndexRecord({
430
443
  const posix = relPath.split(path.sep).join("/");
431
444
  const folder = posix.includes("/") ? posix.slice(0, posix.lastIndexOf("/")) : "";
432
445
  const address = noteAddress(frontmatter, contentPackage);
446
+ // Derived from the address the note *would* hold, because a stub keeps its
447
+ // Foundry document: a document is derived from `data:`, not from prose, and
448
+ // the empty body suppresses the page rather than the document.
433
449
  const entries = foundryEntries({ frontmatter, address, body, manifest });
450
+ // A stub — an empty body on a type an empty body suppresses — publishes no
451
+ // page, so it holds no address and offers no anchor to link to. **The
452
+ // absent address is the signal**, and it is the column the table renderer
453
+ // already reads through `_ref`. Everything else the note declares is kept:
454
+ // its file is what the author edits and what every diagnostic names, and
455
+ // its `id` and `foundry` block are the document it still compiles.
456
+ const stub = isStubNote(frontmatter, body);
434
457
 
435
458
  return /** @type {Record<string, any>} */ (
436
459
  sortKeysDeep({
437
460
  ...frontmatter,
438
461
  package: contentPackage,
439
- address,
462
+ address: stub ? null : address,
440
463
  nameAscii: asciiName(frontmatter?.name?.full),
441
464
  aliasesAscii: asciiAliases(frontmatter?.name?.aliases),
442
465
  // Each anchor carries the link that reaches it, so a section is
443
466
  // addressable from the index without anyone re-deriving how an
444
467
  // anchor is spelled — and its file line, so an editor can jump
445
468
  // there rather than search for the heading.
446
- anchors: collectAnchors(body, bodyLine).map((a) => ({
447
- ...a,
448
- link: address ? `${address.slug}#${a.slug}` : null,
449
- })),
469
+ anchors:
470
+ stub ? null : (
471
+ collectAnchors(body, bodyLine).map((a) => ({
472
+ ...a,
473
+ link: address ? `${address.slug}#${a.slug}` : null,
474
+ }))
475
+ ),
450
476
  foundry: foundryBlock(
451
477
  entries?.own,
452
478
  systemOf(frontmatter?.type, KNOWN_DOCUMENT_SUBTYPE_MAPS),
@@ -609,22 +635,21 @@ export function collectContentIndex(
609
635
  }
610
636
  records.push(record);
611
637
 
612
- // An item note is two documents, so it is two records.
613
- const doc = foundryEntries({
614
- frontmatter: fm,
615
- address: record.address,
616
- body,
617
- manifest,
618
- })?.doc;
619
- if (doc?.key && record.address) {
638
+ // An item note is two documents, so it is two records. Derived from
639
+ // the address the note *would* hold rather than from the one its
640
+ // record carries: a stub withholds its own address and still compiles
641
+ // both documents, and the journal is one of them.
642
+ const address = noteAddress(fm, contentPackage);
643
+ const doc = foundryEntries({ frontmatter: fm, address, body, manifest })?.doc;
644
+ if (doc?.key && address) {
620
645
  records.push(
621
646
  buildDocRecord({
622
647
  frontmatter: fm,
623
- address: record.address,
648
+ address,
624
649
  entry: doc,
625
650
  file: record.file,
626
651
  contentPackage,
627
- anchors: record.anchors,
652
+ anchors: record.anchors ?? [],
628
653
  }),
629
654
  );
630
655
  }
@@ -751,8 +776,11 @@ export function indexRecordsFor({
751
776
  * `paths.contentIndex`.
752
777
  * @param {object} [options.config] - A resolved configuration; loaded when omitted.
753
778
  * @returns {{file: string, notes: number, assets: number, records: number,
754
- * bytes: number}} Where it was written, how many notes and how many assets it
755
- * holds, how many records that is in all, and its size.
779
+ * bytes: number, full: number, draft: number, stub: number}} Where it was
780
+ * written, how many notes and how many assets it holds, how many records that
781
+ * is in all, its size, and the ladder those notes sit on — so the ratio of
782
+ * written to unwritten is visible on every build rather than discovered in a
783
+ * year.
756
784
  * @throws {Error} When the content tree is absent, or when it yields no note at
757
785
  * all — an empty index is indistinguishable from a mis-pointed tree, and a
758
786
  * reader would take it as the authoritative statement that this package has
@@ -794,7 +822,21 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
794
822
  // note yields a second record for its documentation journal and a file
795
823
  // yields an asset record, so reporting records as notes would overstate how
796
824
  // large the tree is.
797
- const notes = records.filter(isNoteRecord).length;
825
+ const noteRecords = records.filter(isNoteRecord);
798
826
  const assets = records.filter(isAssetRecord).length;
799
- return { file, notes, assets, records: records.length, bytes: Buffer.byteLength(text) };
827
+ // Read off the records rather than from a second pass over the tree: a stub
828
+ // is the note whose address the emitter just withheld, and a draft is the
829
+ // tag the note carries, so both are already in hand.
830
+ const stub = noteRecords.filter(isStub).length;
831
+ const draft = noteRecords.filter((record) => !isStub(record) && isDraftNote(record)).length;
832
+ return {
833
+ file,
834
+ notes: noteRecords.length,
835
+ assets,
836
+ records: records.length,
837
+ bytes: Buffer.byteLength(text),
838
+ full: noteRecords.length - stub - draft,
839
+ draft,
840
+ stub,
841
+ };
800
842
  }
@@ -182,6 +182,21 @@ export function buildLinkIndex(
182
182
  records ?? indexRecordsFor({ contentBase, config: resolved, skipDirectories, problems });
183
183
 
184
184
  const byKey = new Map();
185
+ /**
186
+ * Canonical address to the **stub** that would hold it.
187
+ *
188
+ * A stub has no page, so nothing may link to one — but it is a note the
189
+ * tree holds, and keeping it here is what lets the checker say so. The
190
+ * diagnostic names the note and reports that it has no body, where a link
191
+ * resolving against nothing at all can only say "resolves to no note".
192
+ *
193
+ * Kept beside {@link byKey} rather than in it, because the two answer
194
+ * different questions: `byKey` is what a **page** link may reach, and a
195
+ * stub is reachable only as **data** — a border's far side, a reference
196
+ * naming an item to stand beside. That is the whole boundary, and it is
197
+ * drawn here rather than per field.
198
+ */
199
+ const byStub = new Map();
185
200
  /** Canonical address to asset record, for the files this package ships. */
186
201
  const byAssetKey = new Map();
187
202
  const anchors = new Map();
@@ -234,12 +249,15 @@ export function buildLinkIndex(
234
249
  const canonical =
235
250
  record.address?.canonical ??
236
251
  canonicalKey(pkg, systemOf(type, KNOWN_DOCUMENT_SUBTYPE_MAPS), type, fm.shortcode);
237
- byKey.set(canonical, note);
252
+ // A note with no address publishes no page, so its identity is
253
+ // filed where a link cannot reach it and a reference still can.
254
+ const into = record.address ? byKey : byStub;
255
+ into.set(canonical, note);
238
256
  if (hasDocEntry(type)) {
239
257
  // A documentation journal is `none`: no game system defines a
240
258
  // JournalEntry, and one note has one of them however many
241
259
  // system blocks it carries.
242
- byKey.set(canonicalKey(pkg, NO_SYSTEM, `doc${type}`, fm.shortcode), note);
260
+ into.set(canonicalKey(pkg, NO_SYSTEM, `doc${type}`, fm.shortcode), note);
243
261
  }
244
262
  }
245
263
  }
@@ -486,6 +504,26 @@ export function buildLinkIndex(
486
504
  return byKey.get(canonical) ?? byAssetKey.get(canonical);
487
505
  }
488
506
 
507
+ /**
508
+ * The **stub** an address names, or `undefined`.
509
+ *
510
+ * Asked only after {@link resolveAddress} has answered nothing, and read
511
+ * exactly as it reads: same qualifier, same defaults, same expansion. What
512
+ * it buys is the diagnostic — a link into a stub is refused because there
513
+ * is no page to reach, and the message can name the file the author has to
514
+ * open rather than saying the address resolves nowhere.
515
+ *
516
+ * @param {string} target - The link target, anchor already removed.
517
+ * @param {string} [keyPath] - The dotted frontmatter key path the link sits
518
+ * under; body prose has none.
519
+ * @returns {object|undefined} The stub it addresses.
520
+ */
521
+ function stubAt(target, keyPath) {
522
+ const qualified = readQualifier(target, types, packages, noIndexPackages);
523
+ if (!qualified || qualified.reason) return undefined;
524
+ return byStub.get(expandAddress(qualified, { package: pkg, system: blockSystem(keyPath) }));
525
+ }
526
+
489
527
  /**
490
528
  * Every foreign manifest entry an address names, in package order.
491
529
  *
@@ -558,7 +596,12 @@ export function buildLinkIndex(
558
596
  function referenceHit(target) {
559
597
  const q = readQualifier(target, types, packages, noIndexPackages);
560
598
  if (!q || q.reason) return null;
561
- const local = matchAddress([...byKey, ...byAssetKey], q);
599
+ // Stubs last, and deliberately included: a reference names an item to
600
+ // stand beside rather than a document to point at, so a border with a
601
+ // stub on the far side is a valid statement about the world. Resolving
602
+ // these against the addressed rows alone would report every one of
603
+ // them dead.
604
+ const local = matchAddress([...byKey, ...byAssetKey, ...byStub], q);
562
605
  if (local.length) return local[0][1];
563
606
  const abroad = matchAddress([...foreign.index], q);
564
607
  return abroad.length ? abroad[0][1] : null;
@@ -599,6 +642,8 @@ export function buildLinkIndex(
599
642
  */
600
643
  resolve: resolveAddress,
601
644
  resolveAddress,
645
+ /** The stub an address names, for the refusal that says which file. */
646
+ stubAt,
602
647
  manifestHit,
603
648
  foreignHits,
604
649
  referenceHit,
@@ -893,9 +938,9 @@ export function auditHomepageLinks(index) {
893
938
  * which addresses a foreign manifest answered. Each `deadAddresses` entry
894
939
  * carries a `reason` from {@link LINK_FINDING_REASONS} —
895
940
  * `"not-an-address"`, `"unknown-type"`, `"ambiguous"` (with the claiming
896
- * `packages`), `"no-content-index"`, or `"unresolved"` — and every one of
897
- * them is an **error**: the three resolvers agree on severity for every
898
- * class.
941
+ * `packages`), `"no-content-index"`, `"stub"` (with the stub's path) or
942
+ * `"unresolved"` — and every one of them is an **error**: the three
943
+ * resolvers agree on severity for every class.
899
944
  */
900
945
  export function auditLinks(index) {
901
946
  const { notes, anchors, linksOf, embedsOf, resolve, manifestHit, isAddress } = index;
@@ -957,6 +1002,15 @@ export function auditLinks(index) {
957
1002
  continue;
958
1003
  }
959
1004
  if (index.resolveAddress(target)) continue;
1005
+ // A stub is in the index, so the refusal can name it. Asked before
1006
+ // the foreign manifests, because a local note is what the author
1007
+ // meant and reporting it as an unresolved foreign address would
1008
+ // send them looking in the wrong package.
1009
+ const stub = index.stubAt?.(target);
1010
+ if (stub) {
1011
+ deadAddresses.push({ ...at, reason: "stub", stub: stub.rel });
1012
+ continue;
1013
+ }
960
1014
  // A manifest answers with the target package's own build output
961
1015
  // rather than a reviewed guess.
962
1016
  const hits = index.foreignHits(target);