@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.
- package/CHANGELOG.md +33 -0
- package/CONTENT.md +192 -1
- package/bin/content-build.mjs +26 -4
- package/content-config.mjs +8 -3
- package/docs/api.md +99 -26
- package/docs/configuration.md +20 -1
- package/docs/content-format.md +238 -0
- package/docs/diagnostics.md +44 -0
- package/engine/content-index.mjs +63 -21
- package/engine/content-links.mjs +60 -6
- package/engine/content-tables.mjs +29 -2
- package/engine/frontmatter-lint.mjs +2 -0
- package/engine/holdings.mjs +416 -0
- package/engine/index-records.mjs +25 -0
- package/engine/index.mjs +16 -0
- package/engine/map-graphviz.mjs +12 -3
- package/engine/map-places.mjs +33 -3
- package/engine/market-class.mjs +178 -0
- package/engine/metadata-index.mjs +7 -4
- package/engine/note-state.mjs +213 -0
- package/engine/note-vocabulary.mjs +59 -2
- package/engine/pdf-build.mjs +4 -2
- package/engine/populations.mjs +557 -0
- package/engine/site-build.mjs +96 -10
- package/engine/site-maps.mjs +165 -0
- package/engine/sql-tables.mjs +157 -4
- package/engine/stub-lint.mjs +237 -0
- package/engine/wikilink-syntax.mjs +27 -1
- package/package.json +1 -1
- package/types/engine/content-index.d.mts +19 -5
- package/types/engine/content-links.d.mts +3 -3
- package/types/engine/content-tables.d.mts +2 -1
- package/types/engine/holdings.d.mts +157 -0
- package/types/engine/index-records.d.mts +22 -0
- package/types/engine/index.d.mts +6 -0
- package/types/engine/map-graphviz.d.mts +8 -1
- package/types/engine/map-places.d.mts +26 -0
- package/types/engine/market-class.d.mts +62 -0
- package/types/engine/note-state.d.mts +110 -0
- package/types/engine/note-vocabulary.d.mts +25 -0
- package/types/engine/populations.d.mts +121 -0
- package/types/engine/site-build.d.mts +37 -6
- package/types/engine/site-maps.d.mts +81 -0
- package/types/engine/stub-lint.d.mts +51 -0
- package/types/engine/wikilink-syntax.d.mts +19 -1
package/docs/configuration.md
CHANGED
|
@@ -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
|
package/docs/content-format.md
CHANGED
|
@@ -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
|
|
package/docs/diagnostics.md
CHANGED
|
@@ -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).`,
|
package/engine/content-index.mjs
CHANGED
|
@@ -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:
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
|
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
|
|
755
|
-
*
|
|
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
|
|
825
|
+
const noteRecords = records.filter(isNoteRecord);
|
|
798
826
|
const assets = records.filter(isAssetRecord).length;
|
|
799
|
-
|
|
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
|
}
|
package/engine/content-links.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"`,
|
|
897
|
-
* them is an **error**: the three
|
|
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);
|