@heroiclands/package-build 22.4.6 → 22.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/CONTENT.md +192 -1
  3. package/bin/content-build.mjs +26 -4
  4. package/content-config.mjs +8 -3
  5. package/docs/api.md +99 -26
  6. package/docs/configuration.md +20 -1
  7. package/docs/content-format.md +238 -0
  8. package/docs/diagnostics.md +44 -0
  9. package/engine/content-index.mjs +63 -21
  10. package/engine/content-links.mjs +60 -6
  11. package/engine/content-tables.mjs +29 -2
  12. package/engine/frontmatter-lint.mjs +2 -0
  13. package/engine/holdings.mjs +416 -0
  14. package/engine/index-records.mjs +25 -0
  15. package/engine/index.mjs +16 -0
  16. package/engine/map-graphviz.mjs +12 -3
  17. package/engine/map-places.mjs +33 -3
  18. package/engine/market-class.mjs +178 -0
  19. package/engine/metadata-index.mjs +7 -4
  20. package/engine/note-state.mjs +213 -0
  21. package/engine/note-vocabulary.mjs +59 -2
  22. package/engine/pdf-build.mjs +4 -2
  23. package/engine/populations.mjs +557 -0
  24. package/engine/site-build.mjs +96 -10
  25. package/engine/site-maps.mjs +165 -0
  26. package/engine/sql-tables.mjs +157 -4
  27. package/engine/stub-lint.mjs +237 -0
  28. package/engine/wikilink-syntax.mjs +27 -1
  29. package/package.json +1 -1
  30. package/types/engine/content-index.d.mts +19 -5
  31. package/types/engine/content-links.d.mts +3 -3
  32. package/types/engine/content-tables.d.mts +2 -1
  33. package/types/engine/holdings.d.mts +157 -0
  34. package/types/engine/index-records.d.mts +22 -0
  35. package/types/engine/index.d.mts +6 -0
  36. package/types/engine/map-graphviz.d.mts +8 -1
  37. package/types/engine/map-places.d.mts +26 -0
  38. package/types/engine/market-class.d.mts +62 -0
  39. package/types/engine/note-state.d.mts +110 -0
  40. package/types/engine/note-vocabulary.d.mts +25 -0
  41. package/types/engine/populations.d.mts +121 -0
  42. package/types/engine/site-build.d.mts +37 -6
  43. package/types/engine/site-maps.d.mts +81 -0
  44. package/types/engine/stub-lint.d.mts +51 -0
  45. package/types/engine/wikilink-syntax.d.mts +19 -1
@@ -0,0 +1,237 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * An empty body must be deliberate.
16
+ *
17
+ * An empty body is what makes a note a **stub**, and the whole scheme turns on
18
+ * that being on purpose rather than something somebody forgot. So four rules
19
+ * hold the state honest, and two more report what the rules cannot decide:
20
+ *
21
+ * 1. **A stub states what it is.** With nothing in the body, the `description`
22
+ * is the only sentence a reader gets and it is what every table and index
23
+ * prints. An **error**.
24
+ * 2. **A stub is not tagged `draft`.** A thing not started is not a thing in
25
+ * progress. An **error**.
26
+ * 3. **A body that renders to nothing is an abandoned draft, not a stub.** This
27
+ * is what keeps the empty-body rule's severity honest: the softer cases are
28
+ * *reported* rather than silently treated as one state or the other. An
29
+ * **error**.
30
+ * 4. **A short unmarked body may be a draft that forgot its marker.** A
31
+ * **warning**, never an error — a note reading `See [[affiliation-meivor|Mëivōr]]`
32
+ * says everything it has to say, and a corpus holds many of them.
33
+ * 5. **A long-standing draft is a report, not a finding.** It is unfinished,
34
+ * which the tag already says, so the oldest are listed and nobody is asked
35
+ * to dismiss a warning they cannot clear.
36
+ * 6. **The counts are reported**, per package and per type, so the ratio of
37
+ * written to unwritten is visible on every run rather than discovered in a
38
+ * year.
39
+ *
40
+ * **What this does not decide is what earns a body.** Whether a given village
41
+ * deserves prose is a question about a setting, and it belongs in the
42
+ * repository that ships it. The toolchain's job is to make promotion free.
43
+ *
44
+ * @module
45
+ */
46
+
47
+ import fs from "node:fs";
48
+ import path from "node:path";
49
+
50
+ import { authoredFrontmatter, isNoteRecord, isStub, noteFile } from "./index-records.mjs";
51
+ import { positionInFrontmatter, positionOfLiteral } from "./diagnostics.mjs";
52
+ import { parseMarkdownFile } from "./helpers.mjs";
53
+ import { bodyWordCount, isStubNote, placeholderBody } from "./note-state.mjs";
54
+ import { isDraftNote } from "./note-vocabulary.mjs";
55
+
56
+ /**
57
+ * The word count below which an unmarked body is asked whether it is a draft.
58
+ *
59
+ * A threshold, so it is a value the rule *is* rather than a census: twenty-five
60
+ * words is about two sentences, which is where a note stops reading as a
61
+ * statement and starts reading as a beginning.
62
+ *
63
+ * @type {number}
64
+ */
65
+ export const SHORT_BODY_WORDS = 25;
66
+
67
+ /** How many of the oldest drafts the report names. */
68
+ const OLDEST_DRAFTS = 10;
69
+
70
+ /**
71
+ * Whether a note states a description.
72
+ *
73
+ * @param {Record<string, any>} fm - The note's authored frontmatter.
74
+ * @returns {boolean} Whether `description` carries a sentence.
75
+ */
76
+ function hasDescription(fm) {
77
+ return String(fm?.description ?? "").trim() !== "";
78
+ }
79
+
80
+ /**
81
+ * Check one note's state, and say where the fault is.
82
+ *
83
+ * @param {object} note - `{ fm, body, file, raw }`.
84
+ * @returns {object[]} The findings.
85
+ */
86
+ function checkNote({ fm, body, file, raw }) {
87
+ const findings = [];
88
+ const at = (key, value) => positionInFrontmatter(raw, key, value, { topLevel: true });
89
+
90
+ if (isStubNote(fm, body)) {
91
+ if (!hasDescription(fm)) {
92
+ findings.push({
93
+ file,
94
+ ...at("type", fm.type),
95
+ severity: "error",
96
+ message:
97
+ "this note has no body, so it is a stub — and a stub carries " +
98
+ "a `description`, which is the only sentence a reader gets " +
99
+ "and what every table listing it prints",
100
+ });
101
+ }
102
+ if (isDraftNote(fm)) {
103
+ findings.push({
104
+ file,
105
+ ...at("type", fm.type),
106
+ severity: "error",
107
+ message:
108
+ "this note has no body and is tagged `draft`, and a thing " +
109
+ "not started is not a thing in progress — remove the tag, " +
110
+ "or write a body",
111
+ });
112
+ }
113
+ return findings;
114
+ }
115
+
116
+ const placeholders = placeholderBody(body);
117
+ if (placeholders.length) {
118
+ const [{ phrase }] = placeholders;
119
+ findings.push({
120
+ file,
121
+ ...positionOfLiteral(raw, phrase),
122
+ severity: "error",
123
+ message:
124
+ `this note's body says only "${phrase}", so it publishes a page ` +
125
+ `that tells a reader nothing — empty the body to make this a ` +
126
+ `stub, or write it`,
127
+ });
128
+ return findings;
129
+ }
130
+
131
+ if (!isDraftNote(fm)) {
132
+ const words = bodyWordCount(body);
133
+ if (words > 0 && words < SHORT_BODY_WORDS) {
134
+ findings.push({
135
+ file,
136
+ ...at("type", fm.type),
137
+ severity: "warning",
138
+ message:
139
+ `this note's body is ${words} word(s) and carries no \`draft\` ` +
140
+ `tag — tag it if it is a beginning, and leave it alone if it ` +
141
+ `says everything it has to say`,
142
+ });
143
+ }
144
+ }
145
+ return findings;
146
+ }
147
+
148
+ /**
149
+ * The state of every note in a tree, checked and counted.
150
+ *
151
+ * Read from the content index rather than from a walk of this pass's own, like
152
+ * every other lint: the command derives the corpus once and hands it to each
153
+ * pass, so no two findings are drawn from different ideas of which files the
154
+ * content is. The **body** is read from the file, because the index carries
155
+ * what is *about* a note and never the note's text.
156
+ *
157
+ * @param {string} contentBase - Root of the content tree.
158
+ * @param {object} opts
159
+ * @param {readonly object[]} opts.records - Index records the caller derived.
160
+ * @param {string} [opts.contentPackage] - Named in the counts line.
161
+ * @returns {{findings: object[], counts: {full: number, draft: number,
162
+ * stub: number}, byType: Array<{type: string, full: number, draft: number,
163
+ * stub: number}>, oldestDrafts: Array<{file: string, modified: Date}>,
164
+ * summary: string[]}} The findings, the ladder's counts, and the lines a
165
+ * reader is shown as prose rather than as findings.
166
+ */
167
+ export function lintNoteStates(contentBase, { records, contentPackage } = {}) {
168
+ const findings = [];
169
+ const counts = { full: 0, draft: 0, stub: 0 };
170
+ /** @type {Map<string, {full: number, draft: number, stub: number}>} */
171
+ const byType = new Map();
172
+ /** @type {Array<{file: string, modified: Date}>} */
173
+ const drafts = [];
174
+
175
+ for (const record of records ?? []) {
176
+ // A documentation journal and an asset are documents rather than notes,
177
+ // so neither has a state. A note with no `type` — vault scaffolding, a
178
+ // README — does: it publishes no page, which is what a stub is, and
179
+ // counting it here is what keeps this line and the index's own agreeing
180
+ // with `SELECT state, count(*) FROM entries`. What it has no frontmatter
181
+ // for is the **rules**, which are skipped below.
182
+ if (!isNoteRecord(record)) continue;
183
+ const absPath = noteFile(contentBase, record);
184
+ const file = path.relative(process.cwd(), absPath);
185
+ const fm = authoredFrontmatter(record);
186
+ const { body } = parseMarkdownFile(absPath);
187
+ const raw = fs.readFileSync(absPath, "utf8");
188
+
189
+ // The ladder, read exactly as the `entries` view reads it: the absent
190
+ // address is the state, not a second body test beside it. A note with
191
+ // no `type` and no `shortcode` has no address whatever its body says,
192
+ // and calling it anything else here would make this line disagree with
193
+ // `SELECT state, count(*) FROM entries` — two ladders, one corpus.
194
+ const state =
195
+ isStub(record) ? "stub"
196
+ : isDraftNote(fm) ? "draft"
197
+ : "full";
198
+ counts[state] += 1;
199
+ // Vault scaffolding is counted under a name rather than under
200
+ // "undefined", so the per-type table reads as a sentence.
201
+ const type = record.type ? String(record.type) : "(no type)";
202
+ if (!byType.has(type)) byType.set(type, { full: 0, draft: 0, stub: 0 });
203
+ byType.get(type)[state] += 1;
204
+ if (state === "draft") drafts.push({ file, modified: fs.statSync(absPath).mtime });
205
+
206
+ // Held to the rules only where there is a note to hold: a file that
207
+ // declares no type declares nothing for them to read, and asking it for
208
+ // a `description` would report vault scaffolding as content.
209
+ if (record.type) findings.push(...checkNote({ fm, body, file, raw }));
210
+ }
211
+
212
+ const oldestDrafts = drafts
213
+ .sort((a, b) => a.modified.getTime() - b.modified.getTime())
214
+ .slice(0, OLDEST_DRAFTS);
215
+
216
+ const total = counts.full + counts.draft + counts.stub;
217
+ const summary = [
218
+ `${contentPackage ?? "this package"}: ${total} notes — ` +
219
+ `${counts.full} full, ${counts.draft} draft, ${counts.stub} stub.`,
220
+ ];
221
+ if (oldestDrafts.length) {
222
+ summary.push(
223
+ `The drafts nothing has touched in longest: ` +
224
+ `${oldestDrafts.map((d) => d.file).join(", ")}.`,
225
+ );
226
+ }
227
+
228
+ return {
229
+ findings,
230
+ counts,
231
+ byType: [...byType]
232
+ .map(([type, c]) => ({ type, ...c }))
233
+ .sort((a, b) => b.full + b.draft + b.stub - (a.full + a.draft + a.stub)),
234
+ oldestDrafts,
235
+ summary,
236
+ };
237
+ }
@@ -182,6 +182,8 @@ export function unlabelledLinkMessage(target) {
182
182
  * - `unknown-type` — definitely qualified, but names no type this build knows.
183
183
  * - `not-an-asset` — a real address, embedded where only a file can be drawn.
184
184
  * - `unresolved` — parses as an address, and nothing publishes it.
185
+ * - `stub` — the address names a note this tree holds whose body is empty, so
186
+ * it publishes no page for the link to reach.
185
187
  * - `ambiguous` — more than one package publishes the short address.
186
188
  * - `unknown-anchor` — the address resolved, the `#section` it names did not.
187
189
  * - `no-content-index` — the address names a package declared
@@ -198,6 +200,7 @@ export const LINK_FINDING_REASONS = Object.freeze(
198
200
  "unknown-type",
199
201
  "not-an-asset",
200
202
  "unresolved",
203
+ "stub",
201
204
  "ambiguous",
202
205
  "unknown-anchor",
203
206
  "no-content-index",
@@ -231,6 +234,26 @@ export function unresolvedAddressMessage(target) {
231
234
  );
232
235
  }
233
236
 
237
+ /**
238
+ * What an author linking to a **stub** is told.
239
+ *
240
+ * Strictly better than "resolves to no note", and available only because a stub
241
+ * is in the index: the note exists, its file can be named, and the reason the
242
+ * link cannot land is a fact about that file rather than a guess about a
243
+ * shortcode. So the message names the file to open and offers both corrections
244
+ * — the link was either premature or the note is owed a body.
245
+ *
246
+ * @param {string} target - The address as authored.
247
+ * @param {string} [file] - Where the stub is, relative to the content root.
248
+ * @returns {string} The message, unpunctuated at the end as a finding is.
249
+ */
250
+ export function stubAddressMessage(target, file) {
251
+ return (
252
+ `address [[${target}]] names a stub${file ? ` at ${file}` : ""}, which has ` +
253
+ `no body and therefore no page — name it in prose, or write the note`
254
+ );
255
+ }
256
+
234
257
  /**
235
258
  * What an author writing a short address more than one package publishes is
236
259
  * told.
@@ -278,11 +301,12 @@ export function ambiguousAddressMessage(target, packages = []) {
278
301
  * @param {string} [finding.anchor] - For `unknown-anchor`, the section named.
279
302
  * @param {string} [finding.type] - For `not-an-asset`, the type the address
280
303
  * named.
304
+ * @param {string} [finding.stub] - For `stub`, where the stub is.
281
305
  * @returns {string} The message.
282
306
  * @throws {Error} On a reason outside the closed set — a resolver inventing one
283
307
  * would otherwise report a link with no explanation at all.
284
308
  */
285
- export function linkFindingMessage({ reason, target, packages, anchor, type }) {
309
+ export function linkFindingMessage({ reason, target, packages, anchor, type, stub }) {
286
310
  switch (reason) {
287
311
  case "unlabelled":
288
312
  return unlabelledLinkMessage(target);
@@ -316,6 +340,8 @@ export function linkFindingMessage({ reason, target, packages, anchor, type }) {
316
340
  );
317
341
  case "unresolved":
318
342
  return unresolvedAddressMessage(target);
343
+ case "stub":
344
+ return stubAddressMessage(target, stub);
319
345
  case "no-content-index":
320
346
  return (
321
347
  `address [[${target}]] names a package declared \`contentIndex: false\` — ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.4.6",
3
+ "version": "22.5.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -95,11 +95,18 @@ export function asciiAliases(aliases: unknown): Array<string>;
95
95
  * @param {string} [options.absPath] - The file, read only on the failing path to
96
96
  * locate the offending key.
97
97
  * @param {string} options.contentPackage - The package the tree compiles as.
98
- * @param {string} [options.body] - The note's markdown body, for its anchors.
98
+ * @param {string} [options.body] - The note's markdown body, for its anchors
99
+ * and for the one question that decides whether the note publishes a page. A
100
+ * caller that states no body is stating an empty one, and the note is a stub.
99
101
  * @param {number} [options.bodyLine] - The 1-based file line the body starts on.
100
102
  * @param {object} [options.manifest] - The package manifest, which the Foundry
101
103
  * entries are derived against.
102
- * @returns {Record<string, any>} The record, keys sorted at every depth.
104
+ * @returns {Record<string, any>} The record, keys sorted at every depth. A
105
+ * **stub** — a note with an empty body, on a type an empty body suppresses —
106
+ * carries `address` and `anchors` as `null`: it publishes no page, so it
107
+ * holds no address and offers no anchor. It keeps everything else, its `id`
108
+ * and its `foundry` block included, because the document it compiles is
109
+ * derived from `data:` rather than from prose.
103
110
  * @throws {Error} When the note carries a key this module derives, which would
104
111
  * otherwise be overwritten without a word. `file` and, where the file was
105
112
  * read, `position` ride on the error.
@@ -205,8 +212,11 @@ export function indexRecordsFor({ contentBase, assetsBase, config, skipDirectori
205
212
  * `paths.contentIndex`.
206
213
  * @param {object} [options.config] - A resolved configuration; loaded when omitted.
207
214
  * @returns {{file: string, notes: number, assets: number, records: number,
208
- * bytes: number}} Where it was written, how many notes and how many assets it
209
- * holds, how many records that is in all, and its size.
215
+ * bytes: number, full: number, draft: number, stub: number}} Where it was
216
+ * written, how many notes and how many assets it holds, how many records that
217
+ * is in all, its size, and the ladder those notes sit on — so the ratio of
218
+ * written to unwritten is visible on every build rather than discovered in a
219
+ * year.
210
220
  * @throws {Error} When the content tree is absent, or when it yields no note at
211
221
  * all — an empty index is indistinguishable from a mis-pointed tree, and a
212
222
  * reader would take it as the authoritative statement that this package has
@@ -222,13 +232,17 @@ export function emitContentIndex({ contentBase, outDir, config }?: {
222
232
  assets: number;
223
233
  records: number;
224
234
  bytes: number;
235
+ full: number;
236
+ draft: number;
237
+ stub: number;
225
238
  };
226
239
  import { collectAnchors } from "./anchors.mjs";
227
240
  import { authoredFrontmatter } from "./index-records.mjs";
228
241
  import { DERIVED_KEYS } from "./index-records.mjs";
229
242
  import { isAssetRecord } from "./index-records.mjs";
230
243
  import { isNoteRecord } from "./index-records.mjs";
244
+ import { isStub } from "./index-records.mjs";
231
245
  import { noteFile } from "./index-records.mjs";
232
246
  import { recordPath } from "./index-records.mjs";
233
247
  import { sortKeysDeep } from "./index-records.mjs";
234
- export { collectAnchors, authoredFrontmatter, DERIVED_KEYS, isAssetRecord, isNoteRecord, noteFile, recordPath, sortKeysDeep };
248
+ export { collectAnchors, authoredFrontmatter, DERIVED_KEYS, isAssetRecord, isNoteRecord, isStub, noteFile, recordPath, sortKeysDeep };
@@ -133,9 +133,9 @@ export function auditHomepageLinks(index: ReturnType<typeof buildLinkIndex>): Ar
133
133
  * which addresses a foreign manifest answered. Each `deadAddresses` entry
134
134
  * carries a `reason` from {@link LINK_FINDING_REASONS} —
135
135
  * `"not-an-address"`, `"unknown-type"`, `"ambiguous"` (with the claiming
136
- * `packages`), `"no-content-index"`, or `"unresolved"` — and every one of
137
- * them is an **error**: the three resolvers agree on severity for every
138
- * class.
136
+ * `packages`), `"no-content-index"`, `"stub"` (with the stub's path) or
137
+ * `"unresolved"` — and every one of them is an **error**: the three
138
+ * resolvers agree on severity for every class.
139
139
  */
140
140
  export function auditLinks(index: ReturnType<typeof buildLinkIndex>): {
141
141
  deadAnchors: object[];
@@ -105,7 +105,8 @@ export function renderContentTable(spec: object, rows: Array<ContentTableDoc>, l
105
105
  * each came from, so a diagnostic about the expanded body can name an
106
106
  * authored position. An `errors` entry carries the 0-based line of the
107
107
  * directive that failed, for the same reason. `warnings` holds one entry per
108
- * `dataview` directive the body still authors.
108
+ * `dataview` directive the body still authors, and one per `sql` directive
109
+ * that leaves out stubs it would otherwise list.
109
110
  */
110
111
  export function expandContentTables(markdown: string, { docs, linkable, source, self, sqlTables, }?: {
111
112
  docs: Array<ContentTableDoc>;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * A `parents` or `domains` value as the shortcodes it names, in order,
3
+ * empties dropped and repeats collapsed.
4
+ *
5
+ * @param {unknown} value - The authored value — a list, or a lone entry.
6
+ * @returns {string[]} The shortcodes.
7
+ */
8
+ export function shortcodesOf(value: unknown): string[];
9
+ /**
10
+ * Read a local note into a node, or `null` for a note that takes no part.
11
+ *
12
+ * @param {object} fm - The note's frontmatter.
13
+ * @param {object} page - The page it publishes as.
14
+ * @param {string} page.title - Its published title.
15
+ * @param {string} page.url - Its URL.
16
+ * @returns {HoldingsNode|null} The node, or `null` for a note that is neither
17
+ * a place nor an affiliation, or that declares no shortcode.
18
+ */
19
+ export function holdingsNode(fm: object, { title, url }: {
20
+ title: string;
21
+ url: string;
22
+ }): HoldingsNode | null;
23
+ /**
24
+ * Read a fetched index's places and affiliations as nodes.
25
+ *
26
+ * An entry carries what the producer's record stated — its name, `subType`,
27
+ * `parents` and `domains` — and its page URL where the package publishes one.
28
+ *
29
+ * @param {Map<string, object>|undefined} foreignIndex - As
30
+ * {@link module:engine/metadata-index.loadForeignIndexes} returns it.
31
+ * @returns {HoldingsNode[]} The nodes, in index order.
32
+ */
33
+ export function foreignHoldingsNodes(foreignIndex: Map<string, object> | undefined): HoldingsNode[];
34
+ /**
35
+ * Invert `parents` and `domains` into each page's lists.
36
+ *
37
+ * A shortcode is declared once per type: the first node declaring it wins,
38
+ * so local nodes handed in ahead of fetched ones shadow a dependency's, the
39
+ * way the link resolver answers. A node with no URL carries no lists of its
40
+ * own, since there is no page to write them on — and is still an entry on
41
+ * everybody else's, rendered as plain text. A name no node declares is
42
+ * dropped: a `parents` naming nowhere is the map's finding, a `domains`
43
+ * naming nowhere the reference check's.
44
+ *
45
+ * The result holds only pages with at least one entry on at least one list,
46
+ * and a page's block carries only the keys it has entries for — the theme's
47
+ * silent-disappear convention, kept at the source.
48
+ *
49
+ * @param {Iterable<HoldingsNode|null>} nodes - Every place and affiliation,
50
+ * local first. `parents` and `domains` are read as written, so a node
51
+ * built by hand may carry them as the note spells them.
52
+ * @returns {Map<string, Holdings>} URL → the page's lists, sorted.
53
+ */
54
+ export function holdingsPages(nodes: Iterable<HoldingsNode | null>): Map<string, Holdings>;
55
+ /**
56
+ * Check a place note's tenure: a settlement, a site or a structure that no
57
+ * affiliation's `domains` names is unheld land, reported as a warning at the
58
+ * note's `type:` line.
59
+ *
60
+ * Declared on the `place` vocabulary as its type-level check, so the lint
61
+ * runs it beside the field checks with the same index. Without an index the
62
+ * question cannot be asked and nothing is reported.
63
+ *
64
+ * @param {object} note - The note, as the link index hands it over.
65
+ * @param {object} [opts]
66
+ * @param {object} [opts.index] - The link index, holding every affiliation.
67
+ * @returns {object[]} Findings.
68
+ */
69
+ export function checkHeld(note: object, { index }?: {
70
+ index?: object | undefined;
71
+ }): object[];
72
+ /**
73
+ * The keys this module writes, and which a note cannot author: `contains`
74
+ * and `held_by` on a place, `holdings` on an affiliation.
75
+ *
76
+ * @type {readonly string[]}
77
+ */
78
+ export const HOLDINGS_KEYS: readonly string[];
79
+ /**
80
+ * The place subTypes tenure is checked on. A region is held through its
81
+ * polity's `domains`, a world by nobody, a feature by nobody — a river has no
82
+ * lord — so the rest are the kinds of place a body holds directly.
83
+ *
84
+ * @type {readonly string[]}
85
+ */
86
+ export const HELD_SUBTYPES: readonly string[];
87
+ /**
88
+ * One entry of a holdings list — a page a reader is pointed at.
89
+ */
90
+ export type HoldingsEntry = {
91
+ /**
92
+ * The page's published title.
93
+ */
94
+ title: string;
95
+ /**
96
+ * `<base><slug>/` — the page's address as every
97
+ * href this build renders composes it. **Absent
98
+ * for a stub**, which has no page: an entry with no
99
+ * `url` renders as plain text.
100
+ */
101
+ url?: string | undefined;
102
+ /**
103
+ * The note's `type` — `place` or `affiliation`.
104
+ */
105
+ type: string;
106
+ /**
107
+ * The note's `subType`, where it declares one.
108
+ */
109
+ subType?: string | undefined;
110
+ };
111
+ /**
112
+ * One place or affiliation, as the derivation reads it — a local page or a
113
+ * fetched index entry through one shape.
114
+ */
115
+ export type HoldingsNode = {
116
+ /**
117
+ * Lower case.
118
+ */
119
+ shortcode: string;
120
+ type: "place" | "affiliation";
121
+ subType?: string | undefined;
122
+ /**
123
+ * The page's published title.
124
+ */
125
+ title: string;
126
+ /**
127
+ * The page's URL; absent where the package
128
+ * publishes no page for it.
129
+ */
130
+ url?: string | undefined;
131
+ /**
132
+ * What a place sits within, as written.
133
+ */
134
+ parents: string[];
135
+ /**
136
+ * What an affiliation holds, as written.
137
+ */
138
+ domains: string[];
139
+ };
140
+ /**
141
+ * The lists one page carries. Every key is optional, and a key present holds
142
+ * at least one entry.
143
+ */
144
+ export type Holdings = {
145
+ /**
146
+ * Places whose `parents` name this one.
147
+ */
148
+ contains?: HoldingsEntry[] | undefined;
149
+ /**
150
+ * Affiliations whose `domains` name this one.
151
+ */
152
+ held_by?: HoldingsEntry[] | undefined;
153
+ /**
154
+ * Places this affiliation's `domains` name.
155
+ */
156
+ holdings?: HoldingsEntry[] | undefined;
157
+ };
@@ -77,6 +77,28 @@ export function isAssetRecord(record: Record<string, any>): boolean;
77
77
  * @returns {boolean} True for a note's own record.
78
78
  */
79
79
  export function isNoteRecord(record: Record<string, any>): boolean;
80
+ /**
81
+ * Whether a record is a **stub**: a note with an empty body.
82
+ *
83
+ * Asked of the **absent address**, not of a marker block, because the absent
84
+ * address is the whole signal. A note's body is not in the index — the index
85
+ * carries what is *about* a note — so there is nothing else to ask, and nothing
86
+ * else needs to be: an address is emitted if and only if the body is non-empty
87
+ * on a type an empty body suppresses (see
88
+ * {@link module:engine/note-state.isStubNote}).
89
+ *
90
+ * A stub keeps its file, its `id` and, where its type compiles one, its Foundry
91
+ * document. What the empty body suppresses is the **page**, and the address and
92
+ * the link target that go with it.
93
+ *
94
+ * Asked only of a note's own record. A documentation journal and an asset are
95
+ * documents rather than notes, and both are addressed whatever the note they
96
+ * belong to says.
97
+ *
98
+ * @param {Record<string, any>} record - An index record.
99
+ * @returns {boolean} True for a stub's record.
100
+ */
101
+ export function isStub(record: Record<string, any>): boolean;
80
102
  /**
81
103
  * The path a record names inside its package, whichever shape it is.
82
104
  *
@@ -15,12 +15,18 @@ export * as derivedFields from "./derived-fields.mjs";
15
15
  export * as homepage from "./homepage.mjs";
16
16
  export * as noteSchemas from "./note-schemas.mjs";
17
17
  export * as noteVocabulary from "./note-vocabulary.mjs";
18
+ export * as noteState from "./note-state.mjs";
19
+ export * as stubLint from "./stub-lint.mjs";
18
20
  export * as placeRelations from "./place-relations.mjs";
21
+ export * as holdings from "./holdings.mjs";
22
+ export * as populations from "./populations.mjs";
23
+ export * as marketClass from "./market-class.mjs";
19
24
  export * as mapPlaces from "./map-places.mjs";
20
25
  export * as mapLayout from "./map-layout.mjs";
21
26
  export * as mapDot from "./map-dot.mjs";
22
27
  export * as mapGraphviz from "./map-graphviz.mjs";
23
28
  export * as mapBuild from "./map-build.mjs";
29
+ export * as siteMaps from "./site-maps.mjs";
24
30
  export * as systems from "./systems.mjs";
25
31
  export * as contentSlug from "./content-slug.mjs";
26
32
  export * as contentAddress from "./content-address.mjs";
@@ -21,7 +21,7 @@ export function findGraphviz(engine: string, { env, fallbacks }?: {
21
21
  }): string | undefined;
22
22
  /**
23
23
  * What to say when an engine is not installed: the name, and what installs
24
- * it on the two platforms a contributor is likely to be on.
24
+ * it.
25
25
  *
26
26
  * @param {string} engine - The engine that was wanted.
27
27
  * @returns {string} The message.
@@ -59,3 +59,10 @@ export function renderDot(dotPath: string, outPath: string, { binary, format, ar
59
59
  * @type {readonly string[]}
60
60
  */
61
61
  export const GRAPHVIZ_ENGINES: readonly string[];
62
+ /**
63
+ * What installs GraphViz on the two platforms a contributor is likely to be
64
+ * on, as the clause every "not installed" message ends with.
65
+ *
66
+ * @type {string}
67
+ */
68
+ export const GRAPHVIZ_INSTALL: string;
@@ -34,6 +34,32 @@ export function loadMapWorld({ config, contentBase, base, problems }: {
34
34
  base?: string | undefined;
35
35
  problems?: object[] | undefined;
36
36
  }): MapWorld;
37
+ /**
38
+ * The world a map is drawn from, out of records and indexes a caller already
39
+ * holds: this package's places from its index records, and every
40
+ * dependency's from the fetched indexes. The site build reads its corpus and
41
+ * its foreign indexes once for every surface it publishes, and hands them
42
+ * here rather than walking the tree a second time.
43
+ *
44
+ * @param {object} opts
45
+ * @param {Array<Record<string, any>>} opts.records - Index records, as
46
+ * {@link module:engine/content-index.indexRecordsFor} returns them.
47
+ * @param {Map<string, object>} opts.foreignIndex - The fetched indexes, as
48
+ * {@link module:engine/metadata-index.loadForeignIndexes} returns `index`.
49
+ * @param {string} opts.contentBase - The content root the records were read from.
50
+ * @param {string} [opts.base] - The site base this package's pages are served
51
+ * under. Absent, no local place carries a URL.
52
+ * @param {object} opts.config - The resolved configuration.
53
+ * @returns {MapWorld} The world, with nothing stale: the caller has already
54
+ * judged its indexes usable.
55
+ */
56
+ export function mapWorld({ records, foreignIndex, contentBase, base, config }: {
57
+ records: Array<Record<string, any>>;
58
+ foreignIndex: Map<string, object>;
59
+ contentBase: string;
60
+ base?: string | undefined;
61
+ config: object;
62
+ }): MapWorld;
37
63
  /**
38
64
  * Analyse the containment tree.
39
65
  *