@heroiclands/package-build 22.4.7 → 22.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 22.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ **Unwritten notes**
8
+
9
+ - A note left unwritten builds: an empty body no longer stops a package
10
+ compiling its packs.
11
+ - An unwritten note offers no compendium link, so nothing sends a reader to a
12
+ journal that was never made.
13
+
14
+ ## 22.5.0
15
+
16
+ ### Minor Changes
17
+
18
+ **Three states of a note** — a note with no body is now a **stub**: it carries
19
+
20
+ - A page's settlements, deities or characters can be listed with the unwritten
21
+ ones beside the written, and counted by how finished they are — select
22
+ `FROM entries` rather than `FROM notes`, which leaves stubs out as it always
23
+ has.
24
+ - A folder note and the homepage are complete with no body, and keep their pages.
25
+ - An unwritten note still compiles the compendium document its facts describe.
26
+ - `content-build lint` asks a stub for a description, refuses one tagged
27
+ `draft`, and reports a body that says only "TBD".
28
+ - Every build now prints how many of a package's notes are full, draft and stub.
29
+
30
+ ### Patch Changes
31
+
32
+ **Population figures are checked against each other.** A region whose polities
33
+ or inner regions count more people than it states, a settlement larger than the
34
+ land around it, and a reference page carrying a figure the place's own note
35
+ contradicts are each reported while the note is open.
36
+
3
37
  ## 22.4.7
4
38
 
5
39
  ### Patch Changes
package/CONTENT.md CHANGED
@@ -1093,6 +1093,66 @@ it was addressed by the package rather than by a slug. It carries an address
1093
1093
  like every other note now; the guard is unchanged, because what it reads
1094
1094
  was never the key count.)
1095
1095
 
1096
+ ### An empty body is a stub, on purpose
1097
+
1098
+ A note whose body is empty is a **stub**: it carries its frontmatter into the
1099
+ content index and is queryable there, it keeps its `id`, and it publishes no
1100
+ page, no address and no link target. A note with a body and the `draft` tag is a
1101
+ **draft**, and one with a body and no tag is **full**.
1102
+ `docs/content-format.md` states the rule; this is what the lint does with it.
1103
+
1104
+ **A document that would be empty is not created**, and the index then names no
1105
+ artefact — `uuid`, `path` and `address` are `null`. An unwritten note still
1106
+ compiles every document derived from `data:`, so an arcane talent is still its
1107
+ Item; what it compiles no journal for is its prose, because there is none. An
1108
+ infobox does not count as content: the panel renders `data:` the index already
1109
+ carries.
1110
+
1111
+ Four findings and two reports:
1112
+
1113
+ - **A stub carries a `description`.** With nothing in the body it is the only
1114
+ sentence a reader gets, and it is what every table listing the note prints. An
1115
+ **error**, at the note's `type:` line.
1116
+ - **A stub is not tagged `draft`.** A thing not started is not a thing in
1117
+ progress. An **error**: remove the tag, or write a body.
1118
+ - **A body that reduces to nothing is an abandoned draft, not a stub.** A body
1119
+ that is only headings, rules, comments and placeholder phrases — `TBD`, `To be
1120
+ written`, `Coming soon`, `Placeholder`, `N/A` — publishes a page that tells a
1121
+ reader nothing. An **error** naming the phrase and where it sits: empty the
1122
+ body to make it a stub, or write it.
1123
+ - **A short unmarked body may be a draft that forgot its marker.** Under
1124
+ twenty-five words with no `draft` tag is a **warning, never an error**. A note
1125
+ reading `See [[affiliation-meivor|Mëivōr]]` says everything it has to say, and
1126
+ a corpus holds many of them.
1127
+
1128
+ And two things the rules cannot decide, so they are reported as prose rather
1129
+ than flagged:
1130
+
1131
+ - **The oldest drafts are listed** by last modification. A long-standing draft
1132
+ is unfinished, which the tag already says; a warning nobody can clear is how a
1133
+ report teaches its reader to stop looking at it.
1134
+ - **The counts are reported**, per package and per type:
1135
+
1136
+ ```text
1137
+ thalorna: 2572 notes — 2089 full, 464 draft, 19 stub
1138
+ ```
1139
+
1140
+ `content-build content-index` prints the same line, so the ratio of written to
1141
+ unwritten is visible on every build rather than discovered in a year.
1142
+
1143
+ #### Adopting it
1144
+
1145
+ A package with no empty-bodied note is unaffected. A package that has them:
1146
+
1147
+ 1. Take the release and run `content-build lint`. Act on the stub findings — a
1148
+ `description` on every stub, and no stub tagged `draft`.
1149
+ 2. Change the `sql` fences that **should** see stubs from `FROM notes` to
1150
+ `FROM entries`. The ones that would gain rows are warned about at the fence,
1151
+ so the list comes to you.
1152
+ 3. Read the site's page count. A stub publishes no page, so a tree with stubs
1153
+ publishes fewer pages than it did — which is the point, and worth confirming
1154
+ against any `min-pages` gate the deploy declares.
1155
+
1096
1156
  ### Exactly one homepage
1097
1157
 
1098
1158
  A content tree declares **exactly one** `type: homepage` note. Zero is an
@@ -1190,6 +1250,26 @@ is a warning, `unheld land`, at the note's `type:` line, so a gap in tenure
1190
1250
  shows. A region is held through its polity's `domains` and a feature by
1191
1251
  nobody, so neither is checked.
1192
1252
 
1253
+ **Population figures agree from the region down.** A place states
1254
+ `data.population` and an affiliation states how many people it counts, and four
1255
+ warnings read those figures against each other. The polities whose `domains`
1256
+ name a place must not count more people than the place states — `over-held
1257
+ land` — with a polity subordinate to another holding the same place counted
1258
+ once and a settlement exempt, its holder's figure counting a hinterland the
1259
+ settlement does not. The regions whose `parents` name a place must not count
1260
+ more than it states either — `over-full region` — a region naming both its
1261
+ parent and its grandparent counting under the nearer. A settlement must not
1262
+ hold more than any place containing it states, which is `oversized settlement`.
1263
+ And a `doc` note carrying a figure beside a wikilink to the place or
1264
+ affiliation it belongs to must state that note's figure, which is `disputed
1265
+ figure`, located at the number it wrote. Figures are authored to two
1266
+ significant digits, so a parent rounded up to five per cent below what it
1267
+ contains is not a finding, and a place or affiliation stating no figure is
1268
+ neither compared nor counted. A fetched index carries no figure, so a
1269
+ dependency's people are counted nowhere. There is deliberately **no urban-share
1270
+ rule**: a share computed from the named settlements against a region's total
1271
+ measures how much of the region has been written, not how much of it is urban.
1272
+
1193
1273
  **A settlement's market class is one of six.** `data.market` states what trade
1194
1274
  a settlement supports — `1` a hamlet, with no market beyond what neighbours
1195
1275
  trade among themselves; `2` a village and its weekly market; `3` a town with
@@ -2054,7 +2134,15 @@ the build fails naming both.
2054
2134
  ### Every note's address, and every anchor it defines
2055
2135
 
2056
2136
  A record states the address a wikilink writes to reach the note, and every
2057
- `{#slug}` anchor its body declares:
2137
+ `{#slug}` anchor its body declares — unless the note is a **stub**, which
2138
+ carries both as `null`: it publishes no page, so it holds no address and offers
2139
+ no anchor to reach. Everything else it declares is kept, its `id` and `foundry`
2140
+ block included, because a document is derived from `data:` rather than from
2141
+ prose.
2142
+
2143
+ The SQL views read that absence as the state: `entries` is every row plus a
2144
+ derived `state` column, and `notes` is `entries` without the stubs. Nothing
2145
+ about the state is stored, and no note may author it.
2058
2146
 
2059
2147
  ```json
2060
2148
  {
@@ -83,6 +83,7 @@ import {
83
83
  import { fetchNavigation, generateHugoConfig, writeHugoConfig } from "../engine/site-config.mjs";
84
84
  import { renderItemFieldReference, renderItemFieldsPage } from "../engine/field-reference.mjs";
85
85
  import { lintContentTree } from "../engine/content-lint.mjs";
86
+ import { lintNoteStates } from "../engine/stub-lint.mjs";
86
87
  import { lintContentCharset } from "../engine/content-charset.mjs";
87
88
  import { lintContentHtml } from "../engine/content-html.mjs";
88
89
  import { lintContentIcons } from "../engine/content-icons.mjs";
@@ -1004,6 +1005,17 @@ function lintCommand() {
1004
1005
  config,
1005
1006
  });
1006
1007
 
1008
+ // Whether every empty body is a stub on purpose, and how
1009
+ // finished the tree is. The counts and the oldest drafts are
1010
+ // prose rather than findings: a note nobody has finished is not
1011
+ // wrong, and 464 warnings a reader cannot clear is how a report
1012
+ // teaches its reader to stop looking at it.
1013
+ const states = lintNoteStates(root, {
1014
+ records,
1015
+ contentPackage: config.contentPackage,
1016
+ });
1017
+ for (const line of states.summary) log.info(line);
1018
+
1007
1019
  const findings = [
1008
1020
  ...addresses.findings,
1009
1021
  ...frontmatter.findings,
@@ -1012,6 +1024,7 @@ function lintCommand() {
1012
1024
  ...icons.findings,
1013
1025
  ...html.findings,
1014
1026
  ...images.findings,
1027
+ ...states.findings,
1015
1028
  ];
1016
1029
  // Only an **error** fails the run. Every finding was an error
1017
1030
  // by then, so this changes nothing on its own —
@@ -1404,14 +1417,15 @@ function contentIndexCommand() {
1404
1417
  handler: (argv) => {
1405
1418
  try {
1406
1419
  const config = loadPackConfig();
1407
- const { file, notes, bytes } = emitContentIndex({
1420
+ const { file, notes, bytes, full, draft, stub } = emitContentIndex({
1408
1421
  config,
1409
1422
  ...(argv.root ? { contentBase: argv.root } : {}),
1410
1423
  ...(argv.out ? { outDir: argv.out } : {}),
1411
1424
  });
1412
1425
  log.info(
1413
1426
  `${config.contentPackage} → ${path.relative(process.cwd(), file)} ` +
1414
- `(${notes} notes, ${Math.round(bytes / 1024)} KiB)`,
1427
+ `(${notes} notes — ${full} full, ${draft} draft, ` +
1428
+ `${stub} stub — ${Math.round(bytes / 1024)} KiB)`,
1415
1429
  );
1416
1430
  } catch (err) {
1417
1431
  reportFailure(err);
package/docs/api.md CHANGED
@@ -412,6 +412,30 @@ Drawing a declared infobox: HTML for a Foundry Journal Page, Typst for the book.
412
412
  | `linkToUuid` | `linkToUuid(value)` | `string` | drawing a link value as a Foundry document reference |
413
413
  | `sectionHasContent` | `sectionHasContent(section)` | `boolean` | deciding whether a section holds anything worth drawing |
414
414
 
415
+ ### `engine.noteState`
416
+
417
+ The three states of a note — **stub**, **draft**, **full** — and the one observable fact they rest on. A note whose body is empty is a stub: it carries its facts into the index and is queryable there, and it has no page, no address and no link target. Nothing is stored: the ladder is derived once in the SQL `entries` view and no note can assert its own state.
418
+
419
+ | Export | Signature | Returns | Use it when |
420
+ | --------------------- | ------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
421
+ | `NOTE_STATES` | `const NOTE_STATES` | `ReadonlyArray<string>` | naming the three states in ladder order |
422
+ | `isEmptyBody` | `isEmptyBody(body)` | `boolean` | asking the exact question: is everything after the closing frontmatter fence whitespace? |
423
+ | `isStubbableType` | `isStubbableType(type, vocabulary)` | `boolean` | asking whether an empty body suppresses this type's page; `folder` and `homepage` are structural and exempt |
424
+ | `isStubNote` | `isStubNote(frontmatter, body, vocabulary)` | `boolean` | deciding whether one note is a stub, which is what the index and the site build both ask |
425
+ | `journalHasContent` | `journalHasContent(body)` | `boolean` | asking whether the JournalEntry a body compiles into would hold anything, which is what decides whether one is made at all |
426
+ | `PLACEHOLDER_PHRASES` | `const PLACEHOLDER_PHRASES` | `ReadonlyArray<string>` | reading the phrases that stand in for prose nobody has written |
427
+ | `placeholderBody` | `placeholderBody(body)` | `Array<{phrase: string, line: number}>` | finding a body that reduces to nothing but placeholders, which is an abandoned draft rather than a stub |
428
+ | `bodyWordCount` | `bodyWordCount(body)` | `number` | counting the prose a reader meets, with a wikilink counting as the one word it renders |
429
+
430
+ ### `engine.stubLint`
431
+
432
+ An empty body must be deliberate. Four rules hold the state honest — a stub states what it is, a stub is not tagged `draft`, a body that renders to nothing is an abandoned draft, and a short unmarked body is asked whether it forgot its marker — and two reports say what the rules cannot decide.
433
+
434
+ | Export | Signature | Returns | Use it when |
435
+ | ------------------ | ---------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------- |
436
+ | `SHORT_BODY_WORDS` | `const SHORT_BODY_WORDS` | `number` | reading the word count below which an unmarked body is asked whether it is a draft |
437
+ | `lintNoteStates` | `lintNoteStates(contentBase, { records, contentPackage })` | `{findings, counts, byType, oldestDrafts, summary}` | checking a tree's states and counting the ladder, from the corpus the command already derived |
438
+
415
439
  ### `engine.placeRelations`
416
440
 
417
441
  What a place is next to (`data.borders`) and reachable from (`data.routes`): the closed sets both draw from, stated once for the lint, the specification and a consumer drawing a map, and the checks that hold a note to them and to the note at the other end.
@@ -440,8 +464,20 @@ What lies within a place, who holds it, and what an affiliation holds — read o
440
464
  | `holdingsNode` | `holdingsNode(fm, { title, url })` | `HoldingsNode\|null` | reading a local note as a node of the derivation, or `null` for one that is neither a place nor an affiliation |
441
465
  | `foreignHoldingsNodes` | `foreignHoldingsNodes(foreignIndex)` | `HoldingsNode[]` | reading a fetched index's places and affiliations as nodes |
442
466
  | `holdingsPages` | `holdingsPages(nodes)` | `Map<string, Holdings>` | inverting `parents` and `domains` into each page's sorted lists, keyed by URL, for every page with at least one entry |
467
+ | `shortcodesOf` | `shortcodesOf(value)` | `string[]` | the shortcodes a `parents` or `domains` value names — bare, addressed or wikilinked — empties dropped and repeats collapsed |
443
468
  | `checkHeld` | `checkHeld(note, { index })` | `object[]` | linting a place's tenure — a settlement, site or structure no affiliation's `domains` names is a warning at its `type:` line |
444
469
 
470
+ ### `engine.populations`
471
+
472
+ Whether a world's population figures agree from the region down — the polities and regions inside a place against what it states, a settlement against every place containing it, and a `doc` note's cited figures against the notes that own them. `content-build lint` runs all four as warnings.
473
+
474
+ | Export | Signature | Returns | Use it when |
475
+ | ----------------------- | ---------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
476
+ | `POPULATION_TOLERANCE` | `const POPULATION_TOLERANCE` | `number` | how far a figure written to two significant digits may round below what it contains before that is a finding |
477
+ | `POPULATION_RULES` | `const POPULATION_RULES` | `readonly object[]` | the rules in order, each its `name` — the prefix its findings carry — and what it `describe`s |
478
+ | `checkPopulation` | `checkPopulation(note, { index })` | `object[]` | linting a place's `population` — over-held land, an over-full region, and an oversized settlement |
479
+ | `checkCitedPopulations` | `checkCitedPopulations(note, { index })` | `object[]` | linting a `doc` note's cited figures, each located at the `~figure` that states it |
480
+
445
481
  ### `engine.marketClass`
446
482
 
447
483
  What trade a settlement supports (`data.market`): the six-step scale, stated once for the lint, the author-facing reference and the specification, and the check that holds a note's value to it.
@@ -651,19 +687,19 @@ Emitting this package's cross-package link manifest. `engine/content-address.mjs
651
687
 
652
688
  Emitting this package's content index. Every content build already walks the whole note tree and parses every note's frontmatter — the pack compilers, the site build, and the content-table expander each do it — and every one of them throws the result away when it finishes. So nothing outside a build can ask a question about the content: "which beings carry no `kbcat`?", "what does this table actually select?", "did that type rename leave anything behind?" have no answer short of writing a throwaway script that re-walks the tree. Eight dead Bestiary tables shipped for weeks behind exactly that gap.
653
689
 
654
- | Export | Signature | Returns | Use it when |
655
- | ----------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
656
- | `noteAddress` | `function noteAddress(frontmatter, contentPackage)` | {{slug: string, canonical: string}\|null} `slug` is what goes inside `[[…]]` within this package; `canonical` is the fully qualified key the manifest files the note under, carrying the package and the system as well. | The address a wikilink writes to reach a note, or `null` when it has none. |
657
- | `sortKeysDeep` | `function sortKeysDeep(value)` | {unknown} The value with every plain object's keys in sorted order. | Recursively sort an object's keys, so serialization is order-independent. |
658
- | `asciiName` | `function asciiName(name)` | {string\|null} The ASCII form, or `null` when there is no name, or nothing printable survives. | A note's display name reduced to printable 7-bit ASCII. |
659
- | `asciiAliases` | `function asciiAliases(aliases)` | {Array<string>} Possibly empty, never null: a note with no aliases has an empty set of them, which is a fact rather than a missing value, and a consumer iterating it should not have to check first. | A note's `name.aliases` reduced to printable 7-bit ASCII, in order. |
660
- | `serializeContentIndex` | `function serializeContentIndex(records)` | {string} One compact JSON object per line, newline-terminated. | Serialize records as JSON Lines. |
661
- | `indexRecordsFor` | `indexRecordsFor({ contentBase, config, skipDirectories, problems })` | {object[]} One record per note, plus one per documentation entry. | The index records for a content tree, without writing anything. |
662
- | `emitContentIndex` | `emitContentIndex({ contentBase, outDir, config })` | {{file: string, notes: number, assets: number, records: number, bytes: number}} Where it was written, how many notes and how many assets it holds, how many records that is in all, and its size. | Emit this package's content index. |
663
- | `buildIndexRecord` | `buildIndexRecord({ frontmatter, relPath, absPath, contentPackage, body, bodyLine, manifest })` | `Record<string, any>` — the record, with derived fields sorted deep | building one note's content-index record; refuses a note that authors a key the index derives itself (`package` among them) before building anything |
664
- | `collectContentIndex` | `collectContentIndex(contentBase, { contentPackage, skipDirectories, assetsBase, manifest, problems })` | `Array<Record<string, any>>` | reading a whole content tree into index records, in a total order independent of directory-read order; an item note yields two records (its own and its documentation journal's), and each asset root yields one per file |
690
+ | Export | Signature | Returns | Use it when |
691
+ | ----------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
692
+ | `noteAddress` | `function noteAddress(frontmatter, contentPackage)` | {{slug: string, canonical: string}\|null} `slug` is what goes inside `[[…]]` within this package; `canonical` is the fully qualified key the manifest files the note under, carrying the package and the system as well. | The address a wikilink writes to reach a note, or `null` when it has none. |
693
+ | `sortKeysDeep` | `function sortKeysDeep(value)` | {unknown} The value with every plain object's keys in sorted order. | Recursively sort an object's keys, so serialization is order-independent. |
694
+ | `asciiName` | `function asciiName(name)` | {string\|null} The ASCII form, or `null` when there is no name, or nothing printable survives. | A note's display name reduced to printable 7-bit ASCII. |
695
+ | `asciiAliases` | `function asciiAliases(aliases)` | {Array<string>} Possibly empty, never null: a note with no aliases has an empty set of them, which is a fact rather than a missing value, and a consumer iterating it should not have to check first. | A note's `name.aliases` reduced to printable 7-bit ASCII, in order. |
696
+ | `serializeContentIndex` | `function serializeContentIndex(records)` | {string} One compact JSON object per line, newline-terminated. | Serialize records as JSON Lines. |
697
+ | `indexRecordsFor` | `indexRecordsFor({ contentBase, config, skipDirectories, problems })` | {object[]} One record per note, plus one per documentation entry. | The index records for a content tree, without writing anything. |
698
+ | `emitContentIndex` | `emitContentIndex({ contentBase, outDir, config })` | {{file: string, notes: number, assets: number, records: number, bytes: number, full: number, draft: number, stub: number}} Where it was written, how many notes and how many assets it holds, how many records that is in all, its size, and the ladder those notes sit on. | Emit this package's content index. |
699
+ | `buildIndexRecord` | `buildIndexRecord({ frontmatter, relPath, absPath, contentPackage, body, bodyLine, manifest })` | `Record<string, any>` — the record, with derived fields sorted deep | building one note's content-index record; refuses a note that authors a key the index derives itself (`package` among them) before building anything |
700
+ | `collectContentIndex` | `collectContentIndex(contentBase, { contentPackage, skipDirectories, assetsBase, manifest, problems })` | `Array<Record<string, any>>` | reading a whole content tree into index records, in a total order independent of directory-read order; an item note yields two records (its own and its documentation journal's), and each asset root yields one per file |
665
701
 
666
- `engine.contentIndex` also re-exports seven names from neighbouring leaf modules, at this same import path:
702
+ `engine.contentIndex` also re-exports eight names from neighbouring leaf modules, at this same import path:
667
703
 
668
704
  | Export | Signature | Returns | Use it when |
669
705
  | --------------------- | -------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -672,6 +708,7 @@ Emitting this package's content index. Every content build already walks the who
672
708
  | `noteFile` | `noteFile(contentBase, record)` | `string` — the note's absolute path | composing an index record's `file.path` (recorded relative, for a byte-stable artifact) back into an openable absolute path |
673
709
  | `authoredFrontmatter` | `authoredFrontmatter(record)` | `Record<string, any>` — the frontmatter without `DERIVED_KEYS` | reading back exactly what a note authored from its index record, so a pass can lint or compile from the index without reasoning about derived fields as if the author wrote them |
674
710
  | `isNoteRecord` | `isNoteRecord(record)` | `boolean` | telling a note's own record apart from its documentation journal's and from an asset's, when enumerating the corpus |
711
+ | `isStub` | `isStub(record)` | `boolean` | telling a stub — a note with an empty body — apart from a note that publishes a page; asked of the absent address, which is the whole signal |
675
712
  | `isAssetRecord` | `isAssetRecord(record)` | `boolean` | telling a record that addresses a file apart from one that addresses a note; the `asset` block is what marks it |
676
713
  | `recordPath` | `recordPath(record)` | `string` — the path the record names inside its package | ordering or reporting across both record shapes: a note names the `.md` it was parsed from, an asset the file it is |
677
714
 
@@ -977,6 +1014,7 @@ What a `[[…]]` **is**, before anything decides where it points. One authored l
977
1014
  | `unlabelledLinkMessage` | `function unlabelledLinkMessage(target)` | {string} The message, unpunctuated at the end as a finding is. | What an author writing an unlabelled link is told, in one place. |
978
1015
  | `LINK_FINDING_REASONS` | `const LINK_FINDING_REASONS` | — | Every way a link can fail, named once for all three resolvers. |
979
1016
  | `unresolvedAddressMessage` | `function unresolvedAddressMessage(target)` | {string} The message, unpunctuated at the end as a finding is. | What an author writing an address that resolves to nothing is told. |
1017
+ | `stubAddressMessage` | `function stubAddressMessage(target, file)` | {string} The message, unpunctuated at the end as a finding is. | What an author linking to a stub is told, naming the file to open. |
980
1018
  | `ambiguousAddressMessage` | `function ambiguousAddressMessage(target, packages` | {string} The message. | What an author writing a short address more than one package publishes is told. |
981
1019
  | `linkFindingMessage` | `linkFindingMessage({ reason, target, packages, anchor })` | {string} The message. | The message for one link finding, whichever resolver found it. |
982
1020
  | `isSamePage` | `isSamePage({ target, anchor })` | {boolean} True when the link is same-page. | Whether a parsed link addresses a section of the page it is written on. |
@@ -1657,6 +1657,108 @@ 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 one would not be empty | 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`**, which is the identity every diagnostic and every table reads.
1710
+ - **`foundry`**, naming each document that was made.
1711
+
1712
+ #### A document that would be empty is not created
1713
+
1714
+ > **If the resulting document would be empty, it is not created** — and the
1715
+ > index then names no artefact: `uuid`, `path` and `address` are `null`, which
1716
+ > is how a record says one was not made.
1717
+
1718
+ The test is about the **output**, which is what lets one rule cover every case:
1719
+
1720
+ | The note | The document | Made? |
1721
+ | ---------------------------------- | -------------------------------------- | ----- |
1722
+ | `place`, `lore`, `doc`, `scenario` | its JournalEntry, which _is_ the prose | no |
1723
+ | any item type, `macro` | the Item or Macro, from `data:` | yes |
1724
+ | the same note's documentation | a JournalEntry of the same prose | no |
1725
+ | `map` | the Scene, from `data:` | yes |
1726
+
1727
+ An unwritten arcane talent still compiles into its Item, because an Item is its
1728
+ `data:` and that is not empty. What it has no documentation journal for is the
1729
+ same thing an unwritten `place` has no journal for: there is nothing to put in
1730
+ one.
1731
+
1732
+ **An infobox does not count as content.** The panel is a rendering of `data:`
1733
+ that the index already publishes, so an entry holding nothing else would tell a
1734
+ reader exactly what the record told them.
1735
+
1736
+ Nothing anywhere names a document that was not made: a compendium UUID for one
1737
+ would send every consumer that resolves it to a document the pack does not hold.
1738
+
1739
+ What is suppressed with the page is the web address and the link target: a
1740
+ wikilink into a stub is refused, and a table cell listing one prints its name as
1741
+ plain text rather than as a link.
1742
+
1743
+ #### Two types are structural and exempt
1744
+
1745
+ `folder` and `homepage` have no body by design — a folder note's page is a
1746
+ generated section index, and the homepage's is the site's front door. An empty
1747
+ body on either keeps its address and resolves as **full**, because such a note is
1748
+ complete.
1749
+
1750
+ Every type in the vocabulary declares whether an empty body suppresses its page,
1751
+ so adding a type means answering the question rather than inheriting a default.
1752
+
1753
+ #### A stub is still referenceable as data
1754
+
1755
+ `borders.to` and `routes.to` name a place by shortcode, and those are **index
1756
+ relations rather than page links**: a border with a stub on the far side is a
1757
+ valid statement about the world and resolves. So does `parents` for the
1758
+ containment tree and `domains` for tenure — a stub appears in its region's
1759
+ `contains` and on its holder's `holdings`, rendered as plain text. What a stub
1760
+ cannot be is the target of a wikilink, because a wikilink points at a page.
1761
+
1660
1762
  ### What a note produces
1661
1763
 
1662
1764
  Note types fall into two groups, and only the first has a mapping table.
@@ -1903,6 +2005,49 @@ journals. A nested field is addressed exactly as a note authors it —
1903
2005
  every nested object is inferred as a struct. A field a note type does not carry
1904
2006
  reads `NULL` rather than failing.
1905
2007
 
2008
+ **`notes` leaves out stubs; `entries` does not.** Both carry a derived `state`
2009
+ column — `stub`, `draft` or `full` — and `notes` is exactly
2010
+ `SELECT * FROM entries WHERE state <> 'stub'`. So every table already written
2011
+ selects what it always selected, and gains `state` for free; a table that should
2012
+ list the unwritten beside the written changes one word:
2013
+
2014
+ ````markdown
2015
+ ```sql
2016
+ SELECT address.slug AS _ref,
2017
+ name.full AS "Name",
2018
+ state AS "State",
2019
+ m.name AS "Market",
2020
+ data.population AS "People"
2021
+ FROM entries
2022
+ LEFT JOIN market m ON m.value = data.market
2023
+ WHERE type = 'place' AND subType = 'settlement'
2024
+ AND list_contains(data.parents, 'place-aelwyth')
2025
+ ORDER BY data.population DESC NULLS LAST, name.full COLLATE NOCASE
2026
+ ```
2027
+ ````
2028
+
2029
+ A written settlement renders as a link and a stub renders as plain text, from
2030
+ one query with no branch in it: `_ref` is the stub's absent address, and the
2031
+ renderer already prints a bare cell where it is null.
2032
+
2033
+ A fence selecting `FROM notes` that **would** gain rows from `FROM entries` is
2034
+ warned about at the fence, with the count — so adopting stubs in a given table
2035
+ stays a decision somebody makes rather than one they forget.
2036
+
2037
+ The ladder is what makes "how finished is this?" a query:
2038
+
2039
+ ```sql
2040
+ SELECT state, count(*) AS n FROM entries GROUP BY state ORDER BY state
2041
+ ```
2042
+
2043
+ **`market`** is the six-step market scale as a relation — `value`, `name`,
2044
+ `trade` — so a table prints `village` beside the number a note wrote without a
2045
+ second copy of the scale living in authored content.
2046
+
2047
+ **The state is derived, never stored.** No index record carries it, no note can
2048
+ author it, and nothing in `data:` may claim it: the only authored signal is the
2049
+ `draft` tag, which marks the one thing reading the body cannot reveal.
2050
+
1906
2051
  **Two aliases are read by the renderer rather than printed**, because which
1907
2052
  column links and where a section breaks are decisions about output, not
1908
2053
  relational operations:
@@ -3258,6 +3403,53 @@ a warning from `content-build lint`, `unheld land`, located at the note's
3258
3403
  `region` and a `feature` are exempt: a region is held through its polity's
3259
3404
  `domains`, and a river by nobody.
3260
3405
 
3406
+ #### Population figures
3407
+
3408
+ `population` is an approximate count, written to two significant digits. A
3409
+ place states one, and so does an affiliation — how many people it counts,
3410
+ wherever they are. `content-build lint` reads those figures against each other
3411
+ and reports four warnings, each named by the phrase its message begins with.
3412
+
3413
+ - **`over-held land`** — the polities whose `domains` name a place count more
3414
+ people than the place states. A polity subordinate to another polity holding
3415
+ the same place is already inside that polity's figure and is not counted
3416
+ twice. A **settlement is exempt**: the polity of a city-state holds the city
3417
+ and counts the hinterland around it, so its figure is properly the larger.
3418
+ - **`over-full region`** — the regions whose `parents` name a place count more
3419
+ people than the place states. A region naming both its parent and its
3420
+ grandparent counts under the nearer of the two, so a place written into two
3421
+ levels of the hierarchy is not counted at both.
3422
+ - **`oversized settlement`** — a settlement states more people than a place
3423
+ containing it does, whether its own region or any place above that.
3424
+ - **`disputed figure`** — a `doc` note carries a figure beside a wikilink to
3425
+ the place or affiliation it belongs to, and the two disagree. The finding is
3426
+ located at the number the page wrote, and a figure written twice is located
3427
+ twice.
3428
+
3429
+ The first three are reported on the note's own `data.population`; the fourth
3430
+ on the citation.
3431
+
3432
+ **A figure is read as a population only where it carries `~`.** That marker is
3433
+ what separates a population from the other numbers a table puts beside a place
3434
+ — a distance, a stage count, a tally of days — so `| [[place-rgn\|X]] | ~2,000 |`
3435
+ is a claim about people and `| [[place-rgn\|X]] | 3 |` is not.
3436
+
3437
+ **Rounding is admitted.** Two significant digits carry a half-unit of error,
3438
+ widest at the bottom of a decade, so a place whose figure falls up to five per
3439
+ cent below what it contains is not a finding.
3440
+
3441
+ **An unstated figure is silent.** A place or affiliation with no `population`
3442
+ contributes nothing to a sum and is never the subject of a finding, so a
3443
+ half-written region is quiet rather than noisy. A fetched index carries
3444
+ `parents` and `domains` but no figure, so a dependency's places sit in the
3445
+ geography and its people are counted nowhere.
3446
+
3447
+ **There is deliberately no urban-share rule.** The share of a region's people
3448
+ living in its named settlements cannot be measured from the notes: the
3449
+ settlement layer names the notable places and is complete nowhere, so the ratio
3450
+ reports how much of a region has been written rather than how much of it is
3451
+ urban.
3452
+
3261
3453
  **A place declares only what is true of ground.** Four properties were removed
3262
3454
  because they were true of something else, and each removal has a home to go to.
3263
3455
 
@@ -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).`,