@heroiclands/package-build 22.25.0 → 22.27.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 (67) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +4 -0
  3. package/assets/styles/content-base.css +46 -3
  4. package/docs/api.md +35 -12
  5. package/docs/authoring/frontmatter.md +21 -0
  6. package/docs/authoring/links-and-markup.md +96 -149
  7. package/docs/content-format.md +6 -2
  8. package/docs/guides/government-migration.md +52 -0
  9. package/docs/guides/site.md +5 -5
  10. package/docs/reference/format-details.md +152 -246
  11. package/docs/reference/note-types.md +27 -28
  12. package/docs/reference/theme-pages.md +33 -29
  13. package/engine/anchors.mjs +85 -13
  14. package/engine/art-fields.mjs +1 -1
  15. package/engine/base-compiler.mjs +9 -1
  16. package/engine/bundles.mjs +1 -1
  17. package/engine/calendar-notes.mjs +1 -1
  18. package/engine/code-fences.mjs +6 -2
  19. package/engine/content-alerts.mjs +195 -0
  20. package/engine/content-blocks.mjs +119 -241
  21. package/engine/content-embeds.mjs +1 -1
  22. package/engine/content-figures.mjs +166 -230
  23. package/engine/content-format-check.mjs +3 -2
  24. package/engine/content-format.yaml +1 -5
  25. package/engine/content-images.mjs +3 -3
  26. package/engine/content-poetry.mjs +141 -0
  27. package/engine/content-spans.mjs +177 -0
  28. package/engine/foundry-entries.mjs +2 -0
  29. package/engine/frontmatter-lint.mjs +2 -1
  30. package/engine/heading-attributes.mjs +1 -1
  31. package/engine/helpers.mjs +8 -3
  32. package/engine/holdings.mjs +197 -333
  33. package/engine/index.mjs +1 -1
  34. package/engine/infobox.mjs +11 -1
  35. package/engine/journals.mjs +8 -6
  36. package/engine/markdown-expressions.mjs +9 -3
  37. package/engine/metadata-index.mjs +5 -3
  38. package/engine/note-vocabulary.mjs +30 -13
  39. package/engine/pdf-build.mjs +1 -1
  40. package/engine/pdf-render.mjs +90 -31
  41. package/engine/populations.mjs +17 -83
  42. package/engine/site-build.mjs +44 -16
  43. package/engine/site-preview.mjs +22 -6
  44. package/engine/wikilinks.mjs +18 -4
  45. package/hugo-theme/layouts/partials/holdings/entry.html +2 -2
  46. package/hugo-theme/layouts/partials/holdings.html +14 -14
  47. package/hugo-theme/static/css/style.css +40 -2
  48. package/package.json +1 -1
  49. package/sohl/item-fields.mjs +0 -16
  50. package/types/engine/anchors.d.mts +13 -4
  51. package/types/engine/art-fields.d.mts +1 -1
  52. package/types/engine/calendar-notes.d.mts +1 -1
  53. package/types/engine/code-fences.d.mts +3 -1
  54. package/types/engine/content-alerts.d.mts +62 -0
  55. package/types/engine/content-blocks.d.mts +3 -10
  56. package/types/engine/content-embeds.d.mts +1 -1
  57. package/types/engine/content-figures.d.mts +13 -20
  58. package/types/engine/content-images.d.mts +1 -1
  59. package/types/engine/content-poetry.d.mts +32 -0
  60. package/types/engine/content-spans.d.mts +18 -0
  61. package/types/engine/holdings.d.mts +75 -118
  62. package/types/engine/infobox.d.mts +1 -0
  63. package/types/engine/markdown-expressions.d.mts +1 -1
  64. package/types/engine/note-vocabulary.d.mts +14 -2
  65. package/types/engine/populations.d.mts +4 -13
  66. package/types/engine/site-build.d.mts +3 -3
  67. package/types/engine/wikilinks.d.mts +5 -1
@@ -12,195 +12,114 @@
12
12
  */
13
13
 
14
14
  /**
15
- * What lies within a place, who holds it, and what an affiliation holds.
16
- *
17
- * Two keys the notes already carry answer all three. A place's `data.parents`
18
- * is geography — the places it sits within — and an affiliation's
19
- * `data.domains` is tenure — the places it holds. Read across the whole
20
- * corpus and inverted, they give each page the lists a reader wants:
21
- * `contains` and `held_by` on a place, `holdings` on an affiliation, each
22
- * shaped like an entry of `related` and absent where empty.
23
- *
24
- * **`domains` names what an affiliation holds directly**, and is never
25
- * expanded. A polity whose `domains` names a region holds the region; the
26
- * settlements within it are reachable from the region's `contains`, and
27
- * repeating them in `holdings` would make every list say the same thing at
28
- * every level. Tenure below a lord runs through `affiliation.parents` — a
29
- * house of its earl, an earl of the crown — and geography through
30
- * `place.parents`, so a subinfeudated manor sits in one region by the first
31
- * and under a lord of another polity by the second, and both pages say so.
32
- *
33
- * **A dependency's places and affiliations take part.** A fetched index entry
34
- * carries the `parents` and `domains` its record stated, so a place another
35
- * package publishes is listed within a local region, and a house another
36
- * package publishes is named on the local manor it holds.
37
- *
38
- * **A place's tenure is checked.** A settlement, a site or a structure that no
39
- * affiliation's `domains` names is land nobody holds — a gap in tenure the
40
- * lint reports as a warning at the note's `type:` line. A region is held
41
- * through its polity's `domains` and a feature by nobody, so both are exempt.
42
- *
15
+ * Derive containment and government lists from place facts. Geography comes
16
+ * only from `data.parents`; government comes only from `data.government`.
17
+ * Affiliation domains never supply government, and neither graph expands
18
+ * through geographic or affiliation ancestors. Government identity preserves the Address package, type and shortcode.
19
+ * Its system normalizes to `note` because Item and journal documents share one page.
20
+ * Historical holdings API names remain for callers migrating their imports.
43
21
  * @module
44
22
  */
45
-
46
- import { isAddressTuple } from "./address.mjs";
47
- import { positionInFrontmatter } from "./diagnostics.mjs";
48
- import { parseAddress } from "./address.mjs";
23
+ import { isAddressTuple, ownDocumentSystem, parseAddress, renderAddress } from "./address.mjs";
24
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
49
25
  import { readCanonicalKey } from "./content-address.mjs";
50
26
 
51
- /**
52
- * The one type a `parents` or `domains` entry may name — the default
53
- * {@link module:engine/address.parseAddress} fills in when the segment is
54
- * omitted, and the whole of what this position accepts.
55
- *
56
- * @type {ReadonlySet<string>}
57
- */
58
- const PLACE_TYPES = Object.freeze(new Set(["place"]));
59
-
60
- /**
61
- * The keys this module writes, and which a note cannot author: `contains`
62
- * and `held_by` on a place, `holdings` on an affiliation.
63
- *
27
+ /** Derived keys, including retired keys removed from generated pages.
64
28
  * @type {readonly string[]}
65
29
  */
66
- export const HOLDINGS_KEYS = Object.freeze(["contains", "held_by", "holdings"]);
67
-
68
- /**
69
- * The place subTypes tenure is checked on. A region is held through its
70
- * polity's `domains`, a world by nobody, a feature by nobody — a river has no
71
- * lord — so the rest are the kinds of place a body holds directly.
72
- *
30
+ export const HOLDINGS_KEYS = Object.freeze([
31
+ "contains",
32
+ "governed_by",
33
+ "governed_places",
34
+ "held_by",
35
+ "holdings",
36
+ ]);
37
+ /** @deprecated Government advisories apply to every inhabited place subtype.
73
38
  * @type {readonly string[]}
74
39
  */
75
40
  export const HELD_SUBTYPES = Object.freeze(["settlement", "site", "structure"]);
76
41
 
42
+ /** @typedef {{title:string,url?:string,type:string,subType?:string}} HoldingsEntry */
77
43
  /**
78
- * One entry of a holdings list — a page a reader is pointed at.
79
- *
80
- * @typedef {object} HoldingsEntry
81
- * @property {string} title The page's published title.
82
- * @property {string} [url] `<base><slug>/` — the page's address as every
83
- * href this build renders composes it. **Absent
84
- * for a stub**, which has no page: an entry with no
85
- * `url` renders as plain text.
86
- * @property {string} type The note's `type` — `place` or `affiliation`.
87
- * @property {string} [subType] The note's `subType`, where it declares one.
88
- */
89
-
90
- /**
91
- * One place or affiliation, as the derivation reads it — a local page or a
92
- * fetched index entry through one shape.
93
- *
94
44
  * @typedef {object} HoldingsNode
95
- * @property {string} shortcode Lower case.
45
+ * @property {string} shortcode
96
46
  * @property {"place"|"affiliation"} type
97
47
  * @property {string} [subType]
98
- * @property {string} title The page's published title.
99
- * @property {string} [url] The page's URL; absent where the package
100
- * publishes no page for it.
101
- * @property {string[]} parents What a place sits within, as written.
102
- * @property {string[]} domains What an affiliation holds, as written.
48
+ * @property {string} title
49
+ * @property {string} [url]
50
+ * @property {string} [package] Owning content package.
51
+ * @property {string} [system] Own document system.
52
+ * @property {string} [canonical] Full own Address.
53
+ * @property {string} [governmentSystem] Affiliation reference default system.
54
+ * @property {unknown[]} parents
55
+ * @property {unknown} [government] Null is explicit anarchy; absence is omission.
103
56
  */
57
+ /** @typedef {{contains?:HoldingsEntry[],governed_by?:HoldingsEntry[],governed_places?:HoldingsEntry[]}} Holdings */
104
58
 
105
- /**
106
- * The lists one page carries. Every key is optional, and a key present holds
107
- * at least one entry.
108
- *
109
- * @typedef {object} Holdings
110
- * @property {HoldingsEntry[]} [contains] Places whose `parents` name this one.
111
- * @property {HoldingsEntry[]} [held_by] Affiliations whose `domains` name this one.
112
- * @property {HoldingsEntry[]} [holdings] Places this affiliation's `domains` name.
113
- */
114
-
115
- /**
116
- * The shortcode a `parents` or `domains` entry names, lower case.
117
- *
118
- * **A `parents` or `domains` entry is an Address** — the specification types
119
- * both `Address[]`, targeting `place` — read at whatever length says what it
120
- * means, `place` the default and the whole of the accepted set. Frontmatter
121
- * never holds a Wikilink, so no bracket, `|` or `#` is stripped here; a
122
- * qualified form is read structurally by {@link readCanonicalKey} for its
123
- * literal four segments, since this position asks neither its package nor its
124
- * system, only the shortcode {@link module:engine/address} would resolve it
125
- * to anyway.
126
- *
127
- * **The holdings graph has package-blind Shortcode identity.** `places` and
128
- * `affiliations` merge local and fetched notes into one shortcode space; the
129
- * first declaration wins. A fetched place attaches to a local parent with
130
- * the same shortcode, and a fetched affiliation can name both local and
131
- * fetched domains. The authored entries remain Addresses; this graph uses
132
- * their shortcode projection for containment and tenure. See
133
- * `tests/site-holdings.test.ts`'s `empire.domains: ["mill", "abroad"]`, where
134
- * `mill` is local and `abroad` is the dependency's own, both bare, both
135
- * correct only because neither package is asked.
136
- *
137
- * @param {unknown} value - One entry.
138
- * @returns {string} Its shortcode, or `""` for an entry that names nothing.
139
- */
140
- function namedShortcode(value) {
141
- if (isAddressTuple(value)) return value.shortcode.toLowerCase();
142
- const text = String(value ?? "").trim();
143
- if (!text) return "";
144
- const qualified = readCanonicalKey(text);
145
- if (qualified) return qualified.shortcode.toLowerCase();
146
- const read = parseAddress(text, { type: "place", types: PLACE_TYPES });
147
- return read.reason ? "" : read.shortcode.toLowerCase();
148
- }
149
-
150
- /**
151
- * A `parents` or `domains` value as the shortcodes it names, in order,
152
- * empties dropped and repeats collapsed.
153
- *
154
- * @param {unknown} value - The authored value — a list, or a lone entry.
155
- * @returns {string[]} The shortcodes.
59
+ /** Historical shortcode projection for callers that explicitly need one.
60
+ * @param {unknown} value
61
+ * @returns {string[]}
156
62
  */
157
63
  export function shortcodesOf(value) {
158
64
  const list =
159
65
  Array.isArray(value) ? value
160
66
  : value ? [value]
161
67
  : [];
162
- return [...new Set(list.map(namedShortcode).filter(Boolean))];
68
+ return [
69
+ ...new Set(
70
+ list
71
+ .map((one) => {
72
+ if (isAddressTuple(one)) return one.shortcode.toLowerCase();
73
+ const canonical = readCanonicalKey(String(one ?? "").trim());
74
+ if (canonical) return canonical.shortcode.toLowerCase();
75
+ const tuple = parseAddress(one, { type: "place", types: new Set(["place"]) });
76
+ return tuple.reason ? "" : tuple.shortcode.toLowerCase();
77
+ })
78
+ .filter(Boolean),
79
+ ),
80
+ ];
163
81
  }
164
82
 
165
- /**
166
- * A stable order for `contains` and `holdings`: by subType, then title. An
167
- * entry with no subType sorts first.
168
- *
169
- * @param {HoldingsEntry} a
170
- * @param {HoldingsEntry} b
171
- * @returns {number}
83
+ /** Resolve in the citing node's package, never another package's namespace.
84
+ * @param {unknown} value
85
+ * @param {HoldingsNode} node
86
+ * @param {"place"|"affiliation"} type
87
+ * @returns {string}
172
88
  */
173
- function bySubTypeThenTitle(a, b) {
174
- return (
175
- (a.subType ?? "").localeCompare(b.subType ?? "", "en") ||
176
- a.title.localeCompare(b.title, "en")
89
+ function referenceKey(value, node, type) {
90
+ if (value == null) return "";
91
+ const tuple = parseAddress(
92
+ value,
93
+ {
94
+ package: node.package ?? "local",
95
+ system:
96
+ type === "place" ? "note" : (
97
+ (node.governmentSystem ?? ownDocumentSystem("affiliation"))
98
+ ),
99
+ type,
100
+ types: new Set([type]),
101
+ },
102
+ { declared: true, legacyShortcodeCase: true },
177
103
  );
104
+ if (tuple.reason || tuple.type !== type) return "";
105
+ return renderAddress({ ...tuple, system: "note" }).toLowerCase();
178
106
  }
179
107
 
180
- /**
181
- * A stable order for `held_by`: by title.
182
- *
183
- * @param {HoldingsEntry} a
184
- * @param {HoldingsEntry} b
185
- * @returns {number}
186
- */
187
- function byTitle(a, b) {
188
- return a.title.localeCompare(b.title, "en");
108
+ /** @param {HoldingsNode} node @returns {string} */
109
+ function ownKey(node) {
110
+ const tuple = node.canonical ? readCanonicalKey(node.canonical) : node;
111
+ return renderAddress({
112
+ package: tuple.package ?? "local",
113
+ system: "note",
114
+ type: node.type,
115
+ shortcode: node.shortcode.toLowerCase(),
116
+ });
189
117
  }
190
-
191
- /**
192
- * The entry a node is listed as.
193
- *
194
- * **`url` is absent for a node that publishes no page**, and the entry is
195
- * emitted all the same. A stub is a place somebody has not written yet: it
196
- * carries its facts, and Weyshott belongs in Aelwyth's `contains` whether or
197
- * not anyone has written Weyshott's page. What it cannot have is a link, so
198
- * the renderer prints the title as plain text — the same rule a table cell
199
- * follows when its `_ref` is null, in a different renderer.
200
- *
201
- * @param {HoldingsNode} node - The node.
202
- * @returns {HoldingsEntry} Its entry.
203
- */
118
+ /** @param {HoldingsNode} node @returns {boolean} */
119
+ function listed(node) {
120
+ return node.url !== undefined && node.url !== null && node.url !== "";
121
+ }
122
+ /** @param {HoldingsNode} node @returns {HoldingsEntry} */
204
123
  function entryOf(node) {
205
124
  return {
206
125
  title: node.title,
@@ -209,35 +128,20 @@ function entryOf(node) {
209
128
  ...(node.subType === undefined ? {} : { subType: node.subType }),
210
129
  };
211
130
  }
212
-
213
- /**
214
- * Whether a node publishes a page.
215
- *
216
- * **A URL gates where a list is written, never whether a node may appear in
217
- * someone else's list.** You cannot write a `contains` block on a page that
218
- * does not exist, so this guards {@link holdingsPages}'s `on(url)` calls — and
219
- * nothing else. Guarding the membership too is how a stub vanishes from every
220
- * containment and tenure list in the corpus, silently, which is the one failure
221
- * mode a diff cannot show.
222
- *
223
- * @param {HoldingsNode} node - The node.
224
- * @returns {boolean} Whether it has a page to write a list on.
225
- */
226
- function listed(node) {
227
- return node.url !== undefined && node.url !== null && node.url !== "";
131
+ /** @param {HoldingsEntry} a @param {HoldingsEntry} b @returns {number} */
132
+ function bySubTypeThenTitle(a, b) {
133
+ return (
134
+ (a.subType ?? "").localeCompare(b.subType ?? "", "en") ||
135
+ a.title.localeCompare(b.title, "en")
136
+ );
228
137
  }
229
138
 
230
- /**
231
- * Read a local note into a node, or `null` for a note that takes no part.
232
- *
233
- * @param {object} fm - The note's frontmatter.
234
- * @param {object} page - The page it publishes as.
235
- * @param {string} page.title - Its published title.
236
- * @param {string} page.url - Its URL.
237
- * @returns {HoldingsNode|null} The node, or `null` for a note that is neither
238
- * a place nor an affiliation, or that declares no shortcode.
139
+ /** Read local facts without converting government omission into anarchy.
140
+ * @param {object} fm
141
+ * @param {{title:string,url?:string,package?:string,system?:string,governmentSystem?:string}} page
142
+ * @returns {HoldingsNode|null}
239
143
  */
240
- export function holdingsNode(fm, { title, url }) {
144
+ export function holdingsNode(fm, { title, url, package: pkg, system, governmentSystem }) {
241
145
  const type = String(fm?.type ?? "");
242
146
  if (type !== "place" && type !== "affiliation") return null;
243
147
  const shortcode = String(fm?.shortcode ?? "").toLowerCase();
@@ -249,123 +153,110 @@ export function holdingsNode(fm, { title, url }) {
249
153
  subType: fm.subType == null ? undefined : String(fm.subType),
250
154
  title,
251
155
  url,
252
- parents: type === "place" ? shortcodesOf(data.parents) : [],
253
- domains: type === "affiliation" ? shortcodesOf(data.domains) : [],
156
+ ...(pkg ? { package: pkg } : {}),
157
+ ...(system ? { system } : {}),
158
+ ...(governmentSystem ? { governmentSystem } : {}),
159
+ parents:
160
+ type === "place" ?
161
+ Array.isArray(data.parents) ? data.parents
162
+ : data.parents ? [data.parents]
163
+ : []
164
+ : [],
165
+ ...(type === "place" && Object.hasOwn(data, "government") ?
166
+ { government: data.government }
167
+ : {}),
254
168
  };
255
169
  }
256
170
 
257
- /**
258
- * Read a fetched index's places and affiliations as nodes.
259
- *
260
- * An entry carries what the producer's record stated — its name, `subType`,
261
- * `parents` and `domains` — and its page URL where the package publishes one.
262
- *
263
- * @param {Map<string, object>|undefined} foreignIndex - As
264
- * {@link module:engine/metadata-index.loadForeignIndexes} returns it.
265
- * @returns {HoldingsNode[]} The nodes, in index order.
266
- */
171
+ /** @param {Map<string,object>|undefined} foreignIndex @returns {HoldingsNode[]} */
267
172
  export function foreignHoldingsNodes(foreignIndex) {
268
173
  const out = [];
269
- if (!foreignIndex) return out;
270
- for (const [canonical, entry] of foreignIndex) {
174
+ for (const [canonical, entry] of foreignIndex ?? []) {
175
+ const tuple = readCanonicalKey(canonical);
271
176
  const type = String(entry?.type ?? "");
272
- if (type !== "place" && type !== "affiliation") continue;
273
- const shortcode = readCanonicalKey(canonical)?.shortcode?.toLowerCase() ?? "";
274
- if (!shortcode) continue;
177
+ if (!tuple || (type !== "place" && type !== "affiliation")) continue;
275
178
  out.push({
276
- shortcode,
179
+ canonical,
180
+ package: tuple.package,
181
+ system: tuple.system,
182
+ shortcode: tuple.shortcode.toLowerCase(),
277
183
  type,
278
184
  subType: entry.subType == null ? undefined : String(entry.subType),
279
- title: String(entry.name ?? shortcode),
185
+ title: String(entry.name ?? tuple.shortcode),
280
186
  url: entry.url,
281
- parents: type === "place" ? shortcodesOf(entry.parents) : [],
282
- domains: type === "affiliation" ? shortcodesOf(entry.domains) : [],
187
+ parents:
188
+ type === "place" ?
189
+ Array.isArray(entry.parents) ? entry.parents
190
+ : entry.parents ? [entry.parents]
191
+ : []
192
+ : [],
193
+ ...(type === "place" && Object.hasOwn(entry, "government") ?
194
+ { government: entry.government }
195
+ : {}),
283
196
  });
284
197
  }
285
198
  return out;
286
199
  }
287
200
 
288
- /**
289
- * Invert `parents` and `domains` into each page's lists.
290
- *
291
- * A shortcode is declared once per type: the first node declaring it wins,
292
- * so local nodes handed in ahead of fetched ones shadow a dependency's, the
293
- * way the link resolver answers. A node with no URL carries no lists of its
294
- * own, since there is no page to write them on — and is still an entry on
295
- * everybody else's, rendered as plain text. A name no node declares is
296
- * dropped: a `parents` naming nowhere is the map's finding, a `domains`
297
- * naming nowhere the reference check's.
298
- *
299
- * The result holds only pages with at least one entry on at least one list,
300
- * and a page's block carries only the keys it has entries for — the theme's
301
- * silent-disappear convention, kept at the source.
302
- *
303
- * @param {Iterable<HoldingsNode|null>} nodes - Every place and affiliation,
304
- * local first. `parents` and `domains` are read as written, so a node
305
- * built by hand may carry them as the note spells them.
306
- * @returns {Map<string, Holdings>} URL → the page's lists, sorted.
201
+ /** Invert direct place facts. A stub remains a plain text entry; a stub's own
202
+ * absent page receives no lists. First declaration of a page Address wins;
203
+ * different document systems for one page normalize to its `note` identity.
204
+ * @param {Iterable<HoldingsNode|null>} nodes
205
+ * @param {object} [options]
206
+ * @param {Iterable<HoldingsNode|null>} [options.governmentNodes] Additional public
207
+ * records without pages, participating only in government relationships.
208
+ * @returns {Map<string,Holdings>}
307
209
  */
308
- export function holdingsPages(nodes) {
309
- /** @type {Map<string, HoldingsNode>} */
210
+ export function holdingsPages(nodes, { governmentNodes = [] } = {}) {
310
211
  const places = new Map();
311
- /** @type {Map<string, HoldingsNode>} */
312
212
  const affiliations = new Map();
213
+ const geographicPlaces = new Map();
313
214
  for (const node of nodes) {
314
- if (!node) continue;
215
+ if (!node || !node.shortcode) continue;
315
216
  const by = node.type === "place" ? places : affiliations;
316
- const shortcode = String(node.shortcode ?? "").toLowerCase();
317
- if (!shortcode || by.has(shortcode)) continue;
318
- // Read through the one reader, so a caller handing in what a note
319
- // wrote — an address, a wikilink, a repeat — is read as `holdingsNode`
320
- // reads it.
321
- // Shortcode identity: the first node of each type owns this graph key.
322
- by.set(shortcode, {
323
- ...node,
324
- shortcode,
325
- parents: shortcodesOf(node.parents),
326
- domains: shortcodesOf(node.domains),
327
- });
217
+ const key = ownKey(node);
218
+ if (!by.has(key)) by.set(key, node);
219
+ if (node.type === "place" && !geographicPlaces.has(node.shortcode.toLowerCase())) {
220
+ // Shortcode identity: geography keeps its historical first-declaration rule.
221
+ geographicPlaces.set(node.shortcode.toLowerCase(), node);
222
+ }
223
+ }
224
+ // Local records with no published page still state government. They must
225
+ // not add geographic edges: containment keeps its existing page graph.
226
+ for (const node of governmentNodes) {
227
+ if (!node || !node.shortcode) continue;
228
+ const by = node.type === "place" ? places : affiliations;
229
+ const key = ownKey(node);
230
+ if (!by.has(key)) by.set(key, node);
328
231
  }
329
-
330
- /** @type {Map<string, {contains: HoldingsEntry[], held_by: HoldingsEntry[], holdings: HoldingsEntry[]}>} */
331
232
  const lists = new Map();
332
- const on = (url) => {
333
- let found = lists.get(url);
334
- if (!found) {
335
- found = { contains: [], held_by: [], holdings: [] };
336
- lists.set(url, found);
233
+ const add = (node, key, entry) => {
234
+ if (!listed(node)) return;
235
+ let block = lists.get(node.url);
236
+ if (!block) {
237
+ block = {};
238
+ lists.set(node.url, block);
337
239
  }
338
- return found;
240
+ (block[key] ??= []).push(entryOf(entry));
339
241
  };
340
- // Every gate below is on the page a list is *written* on, never on the
341
- // node the list names — see {@link listed}.
342
- for (const child of places.values()) {
343
- for (const shortcode of child.parents) {
344
- const parent = places.get(shortcode);
345
- if (!parent || !listed(parent) || parent === child) continue;
346
- on(parent.url).contains.push(entryOf(child));
242
+ for (const child of geographicPlaces.values()) {
243
+ const parentKeys = shortcodesOf(child.parents);
244
+ for (const key of parentKeys) {
245
+ const parent = geographicPlaces.get(key);
246
+ if (parent && parent !== child) add(parent, "contains", child);
347
247
  }
348
248
  }
349
-
350
- for (const holder of affiliations.values()) {
351
- for (const shortcode of holder.domains) {
352
- const held = places.get(shortcode);
353
- if (!held) continue;
354
- if (listed(holder)) on(holder.url).holdings.push(entryOf(held));
355
- if (listed(held)) on(held.url).held_by.push(entryOf(holder));
356
- }
249
+ for (const child of places.values()) {
250
+ const government = affiliations.get(referenceKey(child.government, child, "affiliation"));
251
+ if (!government) continue;
252
+ add(child, "governed_by", government);
253
+ add(government, "governed_places", child);
357
254
  }
358
-
359
- const out = new Map();
360
- for (const [url, { contains, held_by, holdings }] of lists) {
361
- /** @type {Holdings} */
362
- const block = {};
363
- if (contains.length) block.contains = contains.sort(bySubTypeThenTitle);
364
- if (held_by.length) block.held_by = held_by.sort(byTitle);
365
- if (holdings.length) block.holdings = holdings.sort(bySubTypeThenTitle);
366
- if (Object.keys(block).length) out.set(url, block);
255
+ for (const block of lists.values()) {
256
+ block.contains?.sort(bySubTypeThenTitle);
257
+ block.governed_places?.sort(bySubTypeThenTitle);
367
258
  }
368
- return out;
259
+ return lists;
369
260
  }
370
261
 
371
262
  /* --------------------------------------------------------------------- */
@@ -373,71 +264,44 @@ export function holdingsPages(nodes) {
373
264
  /* --------------------------------------------------------------------- */
374
265
 
375
266
  /**
376
- * Every shortcode some affiliation's `domains` names, computed once per link
377
- * index. The lint asks the question once per place note, and the answer is a
378
- * fact about the whole corpus — local notes and every fetched entry — so it
379
- * is read off the index the first time and kept beside it.
380
- *
381
- * @type {WeakMap<object, Set<string>>}
382
- */
383
- const HELD = new WeakMap();
384
-
385
- /**
386
- * The shortcodes every affiliation's `domains` names, across the link index.
387
- *
388
- * @param {object} index - The link index.
389
- * @returns {Set<string>} The held shortcodes, lower case.
267
+ * Advise when a place with an authored positive population omits government.
268
+ * Missing, null or zero population establishes no inhabited population.
269
+ * Property presence distinguishes omitted government from explicit null,
270
+ * which means complete anarchy. Non-null values are validated by the declared
271
+ * affiliation reference field. Neither geographic containment nor tenure
272
+ * supplies this fact, and the rule is independent of place subtype.
273
+ *
274
+ * @param {object} note - The note with parsed frontmatter and raw source.
275
+ * @returns {object[]} Located warning findings.
390
276
  */
391
- function heldShortcodes(index) {
392
- let held = HELD.get(index);
393
- if (held) return held;
394
- held = new Set();
395
- for (const note of index.notes ?? []) {
396
- if (String(note.type ?? note.fm?.type ?? "") !== "affiliation") continue;
397
- const data = note.fm?.data;
398
- const domains = data && typeof data === "object" ? data.domains : undefined;
399
- for (const shortcode of shortcodesOf(domains)) held.add(shortcode);
400
- }
401
- for (const entry of index.foreign?.index?.values?.() ?? []) {
402
- if (String(entry?.type ?? "") !== "affiliation") continue;
403
- for (const shortcode of shortcodesOf(entry.domains)) held.add(shortcode);
404
- }
405
- HELD.set(index, held);
406
- return held;
407
- }
408
-
409
- /**
410
- * Check a place note's tenure: a settlement, a site or a structure that no
411
- * affiliation's `domains` names is unheld land, reported as a warning at the
412
- * note's `type:` line.
413
- *
414
- * Declared on the `place` vocabulary as its type-level check, so the lint
415
- * runs it beside the field checks with the same index. Without an index the
416
- * question cannot be asked and nothing is reported.
417
- *
418
- * @param {object} note - The note, as the link index hands it over.
419
- * @param {object} [opts]
420
- * @param {object} [opts.index] - The link index, holding every affiliation.
421
- * @returns {object[]} Findings.
422
- */
423
- export function checkHeld(note, { index } = {}) {
424
- if (!index) return [];
277
+ export function checkGovernment(note) {
425
278
  const fm = note.fm ?? {};
426
- if (String(fm.type ?? "") !== "place") return [];
427
- const subType = String(fm.subType ?? "");
428
- if (!HELD_SUBTYPES.includes(subType)) return [];
429
- const shortcode = String(fm.shortcode ?? "").toLowerCase();
430
- if (!shortcode) return [];
431
- if (heldShortcodes(index).has(shortcode)) return [];
279
+ if (fm.type !== "place") return [];
280
+ const data = fm.data;
281
+ if (!data || typeof data !== "object" || Array.isArray(data)) return [];
282
+ const population = data.population;
283
+ if (typeof population !== "number" && typeof population !== "string") return [];
284
+ const count = Number(population);
285
+ if (!Number.isFinite(count) || count <= 0 || Object.hasOwn(data, "government")) return [];
432
286
  return [
433
287
  {
434
288
  file: note.file,
435
- ...positionInFrontmatter(note.raw ?? "", "type", undefined, { topLevel: true }),
289
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "population"], { key: true }),
436
290
  severity: "warning",
437
291
  message:
438
- `unheld land: no affiliation's \`domains\` names "${shortcode}", so nothing ` +
439
- `says who holds this ${subType}; name it in the \`domains\` of the house, ` +
440
- `order or polity that does`,
292
+ `missing government: place "${fm.shortcode ?? ""}" has positive population ` +
293
+ "but no `data.government`; name an affiliation or write null for complete anarchy",
441
294
  },
442
295
  ];
443
296
  }
297
+
298
+ /**
299
+ * The historical API name for the government advisory. Tenure is no longer checked.
300
+ * @deprecated Use checkGovernment.
301
+ * @param {object} note - The place note.
302
+ * @param {object} [_opts] - Historical options, no longer needed.
303
+ * @returns {object[]} Government advisory findings.
304
+ */
305
+ export function checkHeld(note, _opts = {}) {
306
+ return checkGovernment(note);
307
+ }
package/engine/index.mjs CHANGED
@@ -99,7 +99,7 @@ export * as stubLint from "./stub-lint.mjs";
99
99
  /** What a place is next to and reachable from: the closed sets, and the checks. */
100
100
  export * as placeRelations from "./place-relations.mjs";
101
101
 
102
- /** What lies within a place, who holds it, and what an affiliation holds — the page lists, and the tenure check. */
102
+ /** What lies within a place and who governs it — derived page lists and the government advisory. */
103
103
  export * as holdings from "./holdings.mjs";
104
104
  export * as populations from "./populations.mjs";
105
105