@heroiclands/package-build 22.26.0 → 22.28.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 (70) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +3 -1
  3. package/assets/styles/content-base.css +46 -3
  4. package/bin/content-commands.mjs +53 -5
  5. package/ci/publish-npm.mjs +49 -0
  6. package/docs/api.md +44 -24
  7. package/docs/authoring/first-note.md +1 -1
  8. package/docs/authoring/frontmatter.md +2 -2
  9. package/docs/authoring/links-and-markup.md +96 -149
  10. package/docs/content-format.md +7 -3
  11. package/docs/guides/government-migration.md +52 -0
  12. package/docs/guides/site.md +5 -5
  13. package/docs/reference/format-details.md +131 -230
  14. package/docs/reference/note-types.md +27 -29
  15. package/docs/reference/theme-pages.md +31 -29
  16. package/engine/anchors.mjs +85 -13
  17. package/engine/art-fields.mjs +1 -1
  18. package/engine/base-compiler.mjs +9 -1
  19. package/engine/bundles.mjs +1 -1
  20. package/engine/code-fences.mjs +6 -2
  21. package/engine/content-alerts.mjs +195 -0
  22. package/engine/content-blocks.mjs +119 -241
  23. package/engine/content-embeds.mjs +1 -1
  24. package/engine/content-figures.mjs +166 -230
  25. package/engine/content-format-check.mjs +3 -2
  26. package/engine/content-format.yaml +0 -5
  27. package/engine/content-images.mjs +3 -3
  28. package/engine/content-poetry.mjs +141 -0
  29. package/engine/content-spans.mjs +177 -0
  30. package/engine/folder-notes.mjs +2 -3
  31. package/engine/foundry-entries.mjs +2 -0
  32. package/engine/frontmatter-lint.mjs +2 -1
  33. package/engine/heading-attributes.mjs +1 -1
  34. package/engine/helpers.mjs +8 -3
  35. package/engine/holdings.mjs +165 -273
  36. package/engine/index.mjs +2 -2
  37. package/engine/infobox.mjs +1 -1
  38. package/engine/journals.mjs +8 -6
  39. package/engine/markdown-expressions.mjs +9 -3
  40. package/engine/metadata-index.mjs +2 -3
  41. package/engine/note-vocabulary.mjs +19 -15
  42. package/engine/pdf-build.mjs +1 -1
  43. package/engine/pdf-render.mjs +90 -31
  44. package/engine/populations.mjs +17 -83
  45. package/engine/site-build.mjs +44 -16
  46. package/engine/site-preview.mjs +22 -6
  47. package/engine/stub-lint.mjs +20 -64
  48. package/engine/wikilinks.mjs +18 -4
  49. package/githooks/pre-push +1 -1
  50. package/hugo-theme/layouts/partials/holdings/entry.html +2 -2
  51. package/hugo-theme/layouts/partials/holdings.html +14 -14
  52. package/hugo-theme/static/css/style.css +40 -2
  53. package/package.json +3 -1
  54. package/sohl/item-fields.mjs +0 -16
  55. package/types/engine/anchors.d.mts +13 -4
  56. package/types/engine/art-fields.d.mts +1 -1
  57. package/types/engine/code-fences.d.mts +3 -1
  58. package/types/engine/content-alerts.d.mts +62 -0
  59. package/types/engine/content-blocks.d.mts +3 -10
  60. package/types/engine/content-embeds.d.mts +1 -1
  61. package/types/engine/content-figures.d.mts +13 -20
  62. package/types/engine/content-images.d.mts +1 -1
  63. package/types/engine/content-poetry.d.mts +32 -0
  64. package/types/engine/content-spans.d.mts +18 -0
  65. package/types/engine/holdings.d.mts +59 -105
  66. package/types/engine/markdown-expressions.d.mts +1 -1
  67. package/types/engine/note-vocabulary.d.mts +12 -4
  68. package/types/engine/populations.d.mts +4 -13
  69. package/types/engine/site-build.d.mts +3 -3
  70. package/types/engine/wikilinks.d.mts +5 -1
@@ -12,194 +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
- * **Government is stated separately from tenure.** A place with positive
39
- * `data.population` and no authored `data.government` receives an advisory.
40
- * Explicit null means complete anarchy. Holdings never supply a government.
41
- *
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.
42
21
  * @module
43
22
  */
44
-
45
- import { isAddressTuple } from "./address.mjs";
23
+ import { isAddressTuple, ownDocumentSystem, parseAddress, renderAddress } from "./address.mjs";
46
24
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
47
- import { parseAddress } from "./address.mjs";
48
25
  import { readCanonicalKey } from "./content-address.mjs";
49
26
 
50
- /**
51
- * The one type a `parents` or `domains` entry may name — the default
52
- * {@link module:engine/address.parseAddress} fills in when the segment is
53
- * omitted, and the whole of what this position accepts.
54
- *
55
- * @type {ReadonlySet<string>}
56
- */
57
- const PLACE_TYPES = Object.freeze(new Set(["place"]));
58
-
59
- /**
60
- * The keys this module writes, and which a note cannot author: `contains`
61
- * and `held_by` on a place, `holdings` on an affiliation.
62
- *
27
+ /** Derived keys, including retired keys removed from generated pages.
63
28
  * @type {readonly string[]}
64
29
  */
65
- export const HOLDINGS_KEYS = Object.freeze(["contains", "held_by", "holdings"]);
66
-
67
- /**
68
- * Legacy subtypes of the retired tenure advisory. Government advisories
69
- * apply to every place subtype when its authored population is positive.
70
- * @deprecated This list no longer gates any advisory.
71
- *
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.
72
38
  * @type {readonly string[]}
73
39
  */
74
40
  export const HELD_SUBTYPES = Object.freeze(["settlement", "site", "structure"]);
75
41
 
42
+ /** @typedef {{title:string,url?:string,type:string,subType?:string}} HoldingsEntry */
76
43
  /**
77
- * One entry of a holdings list — a page a reader is pointed at.
78
- *
79
- * @typedef {object} HoldingsEntry
80
- * @property {string} title The page's published title.
81
- * @property {string} [url] `<base><slug>/` — the page's address as every
82
- * href this build renders composes it. **Absent
83
- * for a stub**, which has no page: an entry with no
84
- * `url` renders as plain text.
85
- * @property {string} type The note's `type` — `place` or `affiliation`.
86
- * @property {string} [subType] The note's `subType`, where it declares one.
87
- */
88
-
89
- /**
90
- * One place or affiliation, as the derivation reads it — a local page or a
91
- * fetched index entry through one shape.
92
- *
93
44
  * @typedef {object} HoldingsNode
94
- * @property {string} shortcode Lower case.
45
+ * @property {string} shortcode
95
46
  * @property {"place"|"affiliation"} type
96
47
  * @property {string} [subType]
97
- * @property {string} title The page's published title.
98
- * @property {string} [url] The page's URL; absent where the package
99
- * publishes no page for it.
100
- * @property {string[]} parents What a place sits within, as written.
101
- * @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.
102
56
  */
57
+ /** @typedef {{contains?:HoldingsEntry[],governed_by?:HoldingsEntry[],governed_places?:HoldingsEntry[]}} Holdings */
103
58
 
104
- /**
105
- * The lists one page carries. Every key is optional, and a key present holds
106
- * at least one entry.
107
- *
108
- * @typedef {object} Holdings
109
- * @property {HoldingsEntry[]} [contains] Places whose `parents` name this one.
110
- * @property {HoldingsEntry[]} [held_by] Affiliations whose `domains` name this one.
111
- * @property {HoldingsEntry[]} [holdings] Places this affiliation's `domains` name.
112
- */
113
-
114
- /**
115
- * The shortcode a `parents` or `domains` entry names, lower case.
116
- *
117
- * **A `parents` or `domains` entry is an Address** — the specification types
118
- * both `Address[]`, targeting `place` — read at whatever length says what it
119
- * means, `place` the default and the whole of the accepted set. Frontmatter
120
- * never holds a Wikilink, so no bracket, `|` or `#` is stripped here; a
121
- * qualified form is read structurally by {@link readCanonicalKey} for its
122
- * literal four segments, since this position asks neither its package nor its
123
- * system, only the shortcode {@link module:engine/address} would resolve it
124
- * to anyway.
125
- *
126
- * **The holdings graph has package-blind Shortcode identity.** `places` and
127
- * `affiliations` merge local and fetched notes into one shortcode space; the
128
- * first declaration wins. A fetched place attaches to a local parent with
129
- * the same shortcode, and a fetched affiliation can name both local and
130
- * fetched domains. The authored entries remain Addresses; this graph uses
131
- * their shortcode projection for containment and tenure. See
132
- * `tests/site-holdings.test.ts`'s `empire.domains: ["mill", "abroad"]`, where
133
- * `mill` is local and `abroad` is the dependency's own, both bare, both
134
- * correct only because neither package is asked.
135
- *
136
- * @param {unknown} value - One entry.
137
- * @returns {string} Its shortcode, or `""` for an entry that names nothing.
138
- */
139
- function namedShortcode(value) {
140
- if (isAddressTuple(value)) return value.shortcode.toLowerCase();
141
- const text = String(value ?? "").trim();
142
- if (!text) return "";
143
- const qualified = readCanonicalKey(text);
144
- if (qualified) return qualified.shortcode.toLowerCase();
145
- const read = parseAddress(text, { type: "place", types: PLACE_TYPES });
146
- return read.reason ? "" : read.shortcode.toLowerCase();
147
- }
148
-
149
- /**
150
- * A `parents` or `domains` value as the shortcodes it names, in order,
151
- * empties dropped and repeats collapsed.
152
- *
153
- * @param {unknown} value - The authored value — a list, or a lone entry.
154
- * @returns {string[]} The shortcodes.
59
+ /** Historical shortcode projection for callers that explicitly need one.
60
+ * @param {unknown} value
61
+ * @returns {string[]}
155
62
  */
156
63
  export function shortcodesOf(value) {
157
64
  const list =
158
65
  Array.isArray(value) ? value
159
66
  : value ? [value]
160
67
  : [];
161
- 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
+ ];
162
81
  }
163
82
 
164
- /**
165
- * A stable order for `contains` and `holdings`: by subType, then title. An
166
- * entry with no subType sorts first.
167
- *
168
- * @param {HoldingsEntry} a
169
- * @param {HoldingsEntry} b
170
- * @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}
171
88
  */
172
- function bySubTypeThenTitle(a, b) {
173
- return (
174
- (a.subType ?? "").localeCompare(b.subType ?? "", "en") ||
175
- 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 },
176
103
  );
104
+ if (tuple.reason || tuple.type !== type) return "";
105
+ return renderAddress({ ...tuple, system: "note" }).toLowerCase();
177
106
  }
178
107
 
179
- /**
180
- * A stable order for `held_by`: by title.
181
- *
182
- * @param {HoldingsEntry} a
183
- * @param {HoldingsEntry} b
184
- * @returns {number}
185
- */
186
- function byTitle(a, b) {
187
- 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
+ });
188
117
  }
189
-
190
- /**
191
- * The entry a node is listed as.
192
- *
193
- * **`url` is absent for a node that publishes no page**, and the entry is
194
- * emitted all the same. A stub is a place somebody has not written yet: it
195
- * carries its facts, and Weyshott belongs in Aelwyth's `contains` whether or
196
- * not anyone has written Weyshott's page. What it cannot have is a link, so
197
- * the renderer prints the title as plain text — the same rule a table cell
198
- * follows when its `_ref` is null, in a different renderer.
199
- *
200
- * @param {HoldingsNode} node - The node.
201
- * @returns {HoldingsEntry} Its entry.
202
- */
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} */
203
123
  function entryOf(node) {
204
124
  return {
205
125
  title: node.title,
@@ -208,35 +128,20 @@ function entryOf(node) {
208
128
  ...(node.subType === undefined ? {} : { subType: node.subType }),
209
129
  };
210
130
  }
211
-
212
- /**
213
- * Whether a node publishes a page.
214
- *
215
- * **A URL gates where a list is written, never whether a node may appear in
216
- * someone else's list.** You cannot write a `contains` block on a page that
217
- * does not exist, so this guards {@link holdingsPages}'s `on(url)` calls — and
218
- * nothing else. Guarding the membership too is how a stub vanishes from every
219
- * containment and tenure list in the corpus, silently, which is the one failure
220
- * mode a diff cannot show.
221
- *
222
- * @param {HoldingsNode} node - The node.
223
- * @returns {boolean} Whether it has a page to write a list on.
224
- */
225
- function listed(node) {
226
- 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
+ );
227
137
  }
228
138
 
229
- /**
230
- * Read a local note into a node, or `null` for a note that takes no part.
231
- *
232
- * @param {object} fm - The note's frontmatter.
233
- * @param {object} page - The page it publishes as.
234
- * @param {string} page.title - Its published title.
235
- * @param {string} page.url - Its URL.
236
- * @returns {HoldingsNode|null} The node, or `null` for a note that is neither
237
- * 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}
238
143
  */
239
- export function holdingsNode(fm, { title, url }) {
144
+ export function holdingsNode(fm, { title, url, package: pkg, system, governmentSystem }) {
240
145
  const type = String(fm?.type ?? "");
241
146
  if (type !== "place" && type !== "affiliation") return null;
242
147
  const shortcode = String(fm?.shortcode ?? "").toLowerCase();
@@ -248,123 +153,110 @@ export function holdingsNode(fm, { title, url }) {
248
153
  subType: fm.subType == null ? undefined : String(fm.subType),
249
154
  title,
250
155
  url,
251
- parents: type === "place" ? shortcodesOf(data.parents) : [],
252
- 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
+ : {}),
253
168
  };
254
169
  }
255
170
 
256
- /**
257
- * Read a fetched index's places and affiliations as nodes.
258
- *
259
- * An entry carries what the producer's record stated — its name, `subType`,
260
- * `parents` and `domains` — and its page URL where the package publishes one.
261
- *
262
- * @param {Map<string, object>|undefined} foreignIndex - As
263
- * {@link module:engine/metadata-index.loadForeignIndexes} returns it.
264
- * @returns {HoldingsNode[]} The nodes, in index order.
265
- */
171
+ /** @param {Map<string,object>|undefined} foreignIndex @returns {HoldingsNode[]} */
266
172
  export function foreignHoldingsNodes(foreignIndex) {
267
173
  const out = [];
268
- if (!foreignIndex) return out;
269
- for (const [canonical, entry] of foreignIndex) {
174
+ for (const [canonical, entry] of foreignIndex ?? []) {
175
+ const tuple = readCanonicalKey(canonical);
270
176
  const type = String(entry?.type ?? "");
271
- if (type !== "place" && type !== "affiliation") continue;
272
- const shortcode = readCanonicalKey(canonical)?.shortcode?.toLowerCase() ?? "";
273
- if (!shortcode) continue;
177
+ if (!tuple || (type !== "place" && type !== "affiliation")) continue;
274
178
  out.push({
275
- shortcode,
179
+ canonical,
180
+ package: tuple.package,
181
+ system: tuple.system,
182
+ shortcode: tuple.shortcode.toLowerCase(),
276
183
  type,
277
184
  subType: entry.subType == null ? undefined : String(entry.subType),
278
- title: String(entry.name ?? shortcode),
185
+ title: String(entry.name ?? tuple.shortcode),
279
186
  url: entry.url,
280
- parents: type === "place" ? shortcodesOf(entry.parents) : [],
281
- 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
+ : {}),
282
196
  });
283
197
  }
284
198
  return out;
285
199
  }
286
200
 
287
- /**
288
- * Invert `parents` and `domains` into each page's lists.
289
- *
290
- * A shortcode is declared once per type: the first node declaring it wins,
291
- * so local nodes handed in ahead of fetched ones shadow a dependency's, the
292
- * way the link resolver answers. A node with no URL carries no lists of its
293
- * own, since there is no page to write them on — and is still an entry on
294
- * everybody else's, rendered as plain text. A name no node declares is
295
- * dropped: a `parents` naming nowhere is the map's finding, a `domains`
296
- * naming nowhere the reference check's.
297
- *
298
- * The result holds only pages with at least one entry on at least one list,
299
- * and a page's block carries only the keys it has entries for — the theme's
300
- * silent-disappear convention, kept at the source.
301
- *
302
- * @param {Iterable<HoldingsNode|null>} nodes - Every place and affiliation,
303
- * local first. `parents` and `domains` are read as written, so a node
304
- * built by hand may carry them as the note spells them.
305
- * @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>}
306
209
  */
307
- export function holdingsPages(nodes) {
308
- /** @type {Map<string, HoldingsNode>} */
210
+ export function holdingsPages(nodes, { governmentNodes = [] } = {}) {
309
211
  const places = new Map();
310
- /** @type {Map<string, HoldingsNode>} */
311
212
  const affiliations = new Map();
213
+ const geographicPlaces = new Map();
312
214
  for (const node of nodes) {
313
- if (!node) continue;
215
+ if (!node || !node.shortcode) continue;
314
216
  const by = node.type === "place" ? places : affiliations;
315
- const shortcode = String(node.shortcode ?? "").toLowerCase();
316
- if (!shortcode || by.has(shortcode)) continue;
317
- // Read through the one reader, so a caller handing in what a note
318
- // wrote — an address, a wikilink, a repeat — is read as `holdingsNode`
319
- // reads it.
320
- // Shortcode identity: the first node of each type owns this graph key.
321
- by.set(shortcode, {
322
- ...node,
323
- shortcode,
324
- parents: shortcodesOf(node.parents),
325
- domains: shortcodesOf(node.domains),
326
- });
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);
327
231
  }
328
-
329
- /** @type {Map<string, {contains: HoldingsEntry[], held_by: HoldingsEntry[], holdings: HoldingsEntry[]}>} */
330
232
  const lists = new Map();
331
- const on = (url) => {
332
- let found = lists.get(url);
333
- if (!found) {
334
- found = { contains: [], held_by: [], holdings: [] };
335
- 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);
336
239
  }
337
- return found;
240
+ (block[key] ??= []).push(entryOf(entry));
338
241
  };
339
- // Every gate below is on the page a list is *written* on, never on the
340
- // node the list names — see {@link listed}.
341
- for (const child of places.values()) {
342
- for (const shortcode of child.parents) {
343
- const parent = places.get(shortcode);
344
- if (!parent || !listed(parent) || parent === child) continue;
345
- 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);
346
247
  }
347
248
  }
348
-
349
- for (const holder of affiliations.values()) {
350
- for (const shortcode of holder.domains) {
351
- const held = places.get(shortcode);
352
- if (!held) continue;
353
- if (listed(holder)) on(holder.url).holdings.push(entryOf(held));
354
- if (listed(held)) on(held.url).held_by.push(entryOf(holder));
355
- }
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);
356
254
  }
357
-
358
- const out = new Map();
359
- for (const [url, { contains, held_by, holdings }] of lists) {
360
- /** @type {Holdings} */
361
- const block = {};
362
- if (contains.length) block.contains = contains.sort(bySubTypeThenTitle);
363
- if (held_by.length) block.held_by = held_by.sort(byTitle);
364
- if (holdings.length) block.holdings = holdings.sort(bySubTypeThenTitle);
365
- 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);
366
258
  }
367
- return out;
259
+ return lists;
368
260
  }
369
261
 
370
262
  /* --------------------------------------------------------------------- */
package/engine/index.mjs CHANGED
@@ -93,13 +93,13 @@ export * as noteState from "./note-state.mjs";
93
93
  /** What a draft note says about itself, in one sentence, on every surface. */
94
94
  export * as draftNotice from "./draft-notice.mjs";
95
95
 
96
- /** Whether every empty body is a stub on purpose, and how finished a tree is. */
96
+ /** Require written bodies on typed notes and report how finished a tree is. */
97
97
  export * as stubLint from "./stub-lint.mjs";
98
98
 
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 government advisory. */
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
 
@@ -723,7 +723,7 @@ function structuredRows(field, raw, resolve, label, contentPackage) {
723
723
  // Address: an authored plain list of addresses normalises to a list of
724
724
  // Address tuples, which `isMapping` also reports true for, and treating
725
725
  // one as a relationship keyed off a `to` it never carries is what emptied
726
- // `lore`, `parents`, `domains` and `economy` silently.
726
+ // `lore`, `parents` and `economy` silently.
727
727
  const relations =
728
728
  Array.isArray(raw) ? raw.filter((entry) => isMapping(entry) && !isAddressTuple(entry)) : [];
729
729
  if (relations.length) {
@@ -196,7 +196,7 @@ export function splitPages(body, leadName = "Introduction", resolveRole) {
196
196
  if (figure) {
197
197
  closeCurrent();
198
198
  current = {
199
- name: figure.label,
199
+ name: figure.label || figure.caption,
200
200
  anchorSlug: figure.id || null,
201
201
  level: 1,
202
202
  classes: [],
@@ -404,7 +404,7 @@ function figureIsWholePage(figure, page) {
404
404
  }
405
405
 
406
406
  /**
407
- * The fields particular to the Foundry page a `:::figure` fence begins.
407
+ * The fields particular to the Foundry page a captioned item begins.
408
408
  *
409
409
  * A fence holding exactly one picture, captioned with no inline markup, and
410
410
  * standing alone on its page becomes a page of type `image`: `src` the
@@ -442,14 +442,16 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
442
442
  // image's own render takes when no configuration resolves it.
443
443
  }
444
444
  return {
445
- name: figure.label,
445
+ name: figure.label || figure.caption,
446
446
  type: "image",
447
447
  src,
448
448
  image: figure.hasCaption ? { caption: figure.caption } : {},
449
449
  };
450
450
  }
451
451
  const label =
452
- figure.hasCaption ? `${figure.label}: ${md.renderInline(figure.caption)}` : figure.label;
452
+ figure.hasCaption ?
453
+ `${figure.label ? `${figure.label}: ` : ""}${md.renderInline(figure.caption)}`
454
+ : figure.label;
453
455
  const content = renderFoundryMarkdown(
454
456
  page.markdown,
455
457
  undefined,
@@ -458,7 +460,7 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
458
460
  resolveRole,
459
461
  );
460
462
  return {
461
- name: figure.label,
463
+ name: figure.label || figure.caption,
462
464
  type: "text",
463
465
  text: {
464
466
  format: 1,
@@ -763,7 +765,7 @@ export class Journals extends BasePackCompiler {
763
765
 
764
766
  // The markdown this pass reads has already had its embeds rewritten
765
767
  // into ordinary images, each `src` the pathname `pathnameRoles` keys
766
- // its roles by — so a `:::figure` of a map names itself `Map 1` here
768
+ // its roles by — so a captioned map names itself `Map 1` here
767
769
  // exactly as the website and the book name the same picture.
768
770
  const roles = pathnameRoles(this.linkIndex);
769
771
 
@@ -154,7 +154,7 @@ const HELPERS = Object.freeze([
154
154
  name: "ref",
155
155
  params: 'address [form="number"|"full"|"title"]',
156
156
  summary:
157
- 'A link to a `:::figure` fence, by its anchor — `"#thorn"` on this note, ' +
157
+ 'A link to a captioned item, by its anchor — `"#thorn"` on this note, ' +
158
158
  '`"note-address#thorn"` on another. `form` is `number` (the default, and ' +
159
159
  'admitted explicitly), rendering "Figure 13"; `full`, the number and the ' +
160
160
  "caption; or `title`, the caption alone. Always renders as a link to the " +
@@ -173,6 +173,10 @@ const HELPERS = Object.freeze([
173
173
  const { parsed, figure, crossNote, noteFound, url } = refFigure(address, figures);
174
174
  if (!figure)
175
175
  throw new RangeError(refNotFoundFault(address, parsed, crossNote, noteFound));
176
+ if (form === "number" && !figure.label)
177
+ throw new RangeError(
178
+ 'a reference to an unnumbered caption requires form="title" or form="full"',
179
+ );
176
180
  if (form !== "number" && !figure.hasCaption)
177
181
  throw new RangeError(refNoCaptionFault(form, parsed));
178
182
  return renderRef(figure, parsed, form, { url, link: figures?.link });
@@ -271,7 +275,7 @@ function flattenCaptionLinks(caption) {
271
275
  function refLabel(figure, form) {
272
276
  if (form === "number") return figure.label;
273
277
  const title = flattenCaptionLinks(figure.caption);
274
- return form === "title" ? title : `${figure.label}: ${title}`;
278
+ return form === "title" || !figure.label ? title : `${figure.label}: ${title}`;
275
279
  }
276
280
 
277
281
  /**
@@ -394,6 +398,8 @@ function refCallFault(node, figures) {
394
398
  if (typeof address !== "string" || !address.trim()) return null;
395
399
  const { parsed, figure, crossNote, noteFound } = refFigure(address, figures);
396
400
  if (!figure) return refNotFoundFault(address, parsed, crossNote, noteFound);
401
+ if (form === "number" && !figure.label)
402
+ return 'a reference to an unnumbered caption requires form="title" or form="full"';
397
403
  if (form !== "number" && !figure.hasCaption) return refNoCaptionFault(form, parsed);
398
404
  return null;
399
405
  }
@@ -448,7 +454,7 @@ function positionAt(body, offset, bodyLine) {
448
454
  * {url: string|null, figures: Map<string, object>}|undefined,
449
455
  * link?: Function}, file?: string, bodyLine?: number}} [options] -
450
456
  * `figures` is the `ref` helper's own view of the corpus: `get` reads this
451
- * note's `:::figure` fences by id, and `note` resolves another note's the
457
+ * note's captioned items by id, and `note` resolves another note's the
452
458
  * way a wikilink would — omitted, a cross-note address is refused rather
453
459
  * than looked up. `link` overrides how a resolved reference renders; the
454
460
  * default is a Markdown link to the target's own anchor.