@heroiclands/package-build 22.4.6 → 22.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/CONTENT.md +192 -1
- package/bin/content-build.mjs +26 -4
- package/content-config.mjs +8 -3
- package/docs/api.md +99 -26
- package/docs/configuration.md +20 -1
- package/docs/content-format.md +238 -0
- package/docs/diagnostics.md +44 -0
- package/engine/content-index.mjs +63 -21
- package/engine/content-links.mjs +60 -6
- package/engine/content-tables.mjs +29 -2
- package/engine/frontmatter-lint.mjs +2 -0
- package/engine/holdings.mjs +416 -0
- package/engine/index-records.mjs +25 -0
- package/engine/index.mjs +16 -0
- package/engine/map-graphviz.mjs +12 -3
- package/engine/map-places.mjs +33 -3
- package/engine/market-class.mjs +178 -0
- package/engine/metadata-index.mjs +7 -4
- package/engine/note-state.mjs +213 -0
- package/engine/note-vocabulary.mjs +59 -2
- package/engine/pdf-build.mjs +4 -2
- package/engine/populations.mjs +557 -0
- package/engine/site-build.mjs +96 -10
- package/engine/site-maps.mjs +165 -0
- package/engine/sql-tables.mjs +157 -4
- package/engine/stub-lint.mjs +237 -0
- package/engine/wikilink-syntax.mjs +27 -1
- package/package.json +1 -1
- package/types/engine/content-index.d.mts +19 -5
- package/types/engine/content-links.d.mts +3 -3
- package/types/engine/content-tables.d.mts +2 -1
- package/types/engine/holdings.d.mts +157 -0
- package/types/engine/index-records.d.mts +22 -0
- package/types/engine/index.d.mts +6 -0
- package/types/engine/map-graphviz.d.mts +8 -1
- package/types/engine/map-places.d.mts +26 -0
- package/types/engine/market-class.d.mts +62 -0
- package/types/engine/note-state.d.mts +110 -0
- package/types/engine/note-vocabulary.d.mts +25 -0
- package/types/engine/populations.d.mts +121 -0
- package/types/engine/site-build.d.mts +37 -6
- package/types/engine/site-maps.d.mts +81 -0
- package/types/engine/stub-lint.d.mts +51 -0
- package/types/engine/wikilink-syntax.d.mts +19 -1
|
@@ -1107,7 +1107,8 @@ function whereText(query) {
|
|
|
1107
1107
|
* each came from, so a diagnostic about the expanded body can name an
|
|
1108
1108
|
* authored position. An `errors` entry carries the 0-based line of the
|
|
1109
1109
|
* directive that failed, for the same reason. `warnings` holds one entry per
|
|
1110
|
-
* `dataview` directive the body still authors
|
|
1110
|
+
* `dataview` directive the body still authors, and one per `sql` directive
|
|
1111
|
+
* that leaves out stubs it would otherwise list.
|
|
1111
1112
|
*/
|
|
1112
1113
|
export function expandContentTables(
|
|
1113
1114
|
markdown,
|
|
@@ -1176,7 +1177,17 @@ export function expandContentTables(
|
|
|
1176
1177
|
"`sqlTables`, so the query could not be run"
|
|
1177
1178
|
: prepared.reason ? prepared.reason
|
|
1178
1179
|
: prepared.rows === 0 && !prepared.allowEmpty ?
|
|
1179
|
-
|
|
1180
|
+
// A table that lost every row to the stub rule says so.
|
|
1181
|
+
// "selects no notes" is true and useless here: the notes
|
|
1182
|
+
// are there, and the fence is reading the relation that
|
|
1183
|
+
// leaves them out.
|
|
1184
|
+
prepared.stubsExcluded > 0 ?
|
|
1185
|
+
`sql query selects no notes — ${prepared.stubsExcluded} stub(s) ` +
|
|
1186
|
+
"match it, and `FROM notes` leaves stubs out; select " +
|
|
1187
|
+
"`FROM entries` to list them beside the written notes, or " +
|
|
1188
|
+
"write ```sql allow-empty if the empty table is intended"
|
|
1189
|
+
: "sql query selects no notes — write ```sql allow-empty if " +
|
|
1190
|
+
"that is intended"
|
|
1180
1191
|
: undefined;
|
|
1181
1192
|
if (failure) {
|
|
1182
1193
|
errors.push({
|
|
@@ -1190,6 +1201,22 @@ export function expandContentTables(
|
|
|
1190
1201
|
i = close;
|
|
1191
1202
|
continue;
|
|
1192
1203
|
}
|
|
1204
|
+
// A table selecting `FROM notes` that would gain rows from
|
|
1205
|
+
// `FROM entries` has not adopted stubs. It is not wrong — which
|
|
1206
|
+
// tables list the unwritten is the author's decision, and it is a
|
|
1207
|
+
// one-word edit — so this is a warning at the fence rather than a
|
|
1208
|
+
// failure, and the count is what makes it worth reading.
|
|
1209
|
+
if (prepared.stubsExcluded > 0) {
|
|
1210
|
+
warnings.push({
|
|
1211
|
+
source,
|
|
1212
|
+
line: i,
|
|
1213
|
+
column: indent.length + 1,
|
|
1214
|
+
reason:
|
|
1215
|
+
`this table selects \`FROM notes\`, which leaves out stubs; ` +
|
|
1216
|
+
`${prepared.stubsExcluded} stub(s) match it — select ` +
|
|
1217
|
+
`\`FROM entries\` to list them beside the written notes`,
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1193
1220
|
if (out.length > 0 && out[out.length - 1].trim() !== "") emit("", i, true);
|
|
1194
1221
|
for (const row of prepared.markdown.split("\n")) emit(`${indent}${row}`, i, true);
|
|
1195
1222
|
if (close + 1 < lines.length && lines[close + 1].trim() !== "") emit("", i, true);
|
|
@@ -1424,6 +1424,8 @@ export function lintNote(
|
|
|
1424
1424
|
if (typeof field.check !== "function") continue;
|
|
1425
1425
|
findings.push(...field.check(note, { index }));
|
|
1426
1426
|
}
|
|
1427
|
+
// A check the type declares of the whole note, with the same index.
|
|
1428
|
+
if (typeof entry.check === "function") findings.push(...entry.check(note, { index }));
|
|
1427
1429
|
}
|
|
1428
1430
|
|
|
1429
1431
|
const fields = authoredFields(schema);
|
|
@@ -0,0 +1,416 @@
|
|
|
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
|
+
* 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
|
+
*
|
|
43
|
+
* @module
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { positionInFrontmatter } from "./diagnostics.mjs";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The keys this module writes, and which a note cannot author: `contains`
|
|
50
|
+
* and `held_by` on a place, `holdings` on an affiliation.
|
|
51
|
+
*
|
|
52
|
+
* @type {readonly string[]}
|
|
53
|
+
*/
|
|
54
|
+
export const HOLDINGS_KEYS = Object.freeze(["contains", "held_by", "holdings"]);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The place subTypes tenure is checked on. A region is held through its
|
|
58
|
+
* polity's `domains`, a world by nobody, a feature by nobody — a river has no
|
|
59
|
+
* lord — so the rest are the kinds of place a body holds directly.
|
|
60
|
+
*
|
|
61
|
+
* @type {readonly string[]}
|
|
62
|
+
*/
|
|
63
|
+
export const HELD_SUBTYPES = Object.freeze(["settlement", "site", "structure"]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One entry of a holdings list — a page a reader is pointed at.
|
|
67
|
+
*
|
|
68
|
+
* @typedef {object} HoldingsEntry
|
|
69
|
+
* @property {string} title The page's published title.
|
|
70
|
+
* @property {string} [url] `<base><slug>/` — the page's address as every
|
|
71
|
+
* href this build renders composes it. **Absent
|
|
72
|
+
* for a stub**, which has no page: an entry with no
|
|
73
|
+
* `url` renders as plain text.
|
|
74
|
+
* @property {string} type The note's `type` — `place` or `affiliation`.
|
|
75
|
+
* @property {string} [subType] The note's `subType`, where it declares one.
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One place or affiliation, as the derivation reads it — a local page or a
|
|
80
|
+
* fetched index entry through one shape.
|
|
81
|
+
*
|
|
82
|
+
* @typedef {object} HoldingsNode
|
|
83
|
+
* @property {string} shortcode Lower case.
|
|
84
|
+
* @property {"place"|"affiliation"} type
|
|
85
|
+
* @property {string} [subType]
|
|
86
|
+
* @property {string} title The page's published title.
|
|
87
|
+
* @property {string} [url] The page's URL; absent where the package
|
|
88
|
+
* publishes no page for it.
|
|
89
|
+
* @property {string[]} parents What a place sits within, as written.
|
|
90
|
+
* @property {string[]} domains What an affiliation holds, as written.
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The lists one page carries. Every key is optional, and a key present holds
|
|
95
|
+
* at least one entry.
|
|
96
|
+
*
|
|
97
|
+
* @typedef {object} Holdings
|
|
98
|
+
* @property {HoldingsEntry[]} [contains] Places whose `parents` name this one.
|
|
99
|
+
* @property {HoldingsEntry[]} [held_by] Affiliations whose `domains` name this one.
|
|
100
|
+
* @property {HoldingsEntry[]} [holdings] Places this affiliation's `domains` name.
|
|
101
|
+
*/
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The shortcode a `parents` or `domains` entry names, lower case.
|
|
105
|
+
*
|
|
106
|
+
* An entry is written as a bare shortcode, as an address, or as a wikilink
|
|
107
|
+
* carrying either; the shortcode is the last segment of the address in every
|
|
108
|
+
* case, because a segment carries no separator.
|
|
109
|
+
*
|
|
110
|
+
* @param {unknown} value - One entry.
|
|
111
|
+
* @returns {string} Its shortcode, or `""` for an entry that names nothing.
|
|
112
|
+
*/
|
|
113
|
+
function namedShortcode(value) {
|
|
114
|
+
const text = String(value ?? "")
|
|
115
|
+
.trim()
|
|
116
|
+
.replace(/^\[\[/, "")
|
|
117
|
+
.replace(/\]\]$/, "")
|
|
118
|
+
.split("|")[0]
|
|
119
|
+
.split("#")[0]
|
|
120
|
+
.trim();
|
|
121
|
+
return text.split("-").pop()?.toLowerCase() ?? "";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A `parents` or `domains` value as the shortcodes it names, in order,
|
|
126
|
+
* empties dropped and repeats collapsed.
|
|
127
|
+
*
|
|
128
|
+
* @param {unknown} value - The authored value — a list, or a lone entry.
|
|
129
|
+
* @returns {string[]} The shortcodes.
|
|
130
|
+
*/
|
|
131
|
+
export function shortcodesOf(value) {
|
|
132
|
+
const list =
|
|
133
|
+
Array.isArray(value) ? value
|
|
134
|
+
: value ? [value]
|
|
135
|
+
: [];
|
|
136
|
+
return [...new Set(list.map(namedShortcode).filter(Boolean))];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A stable order for `contains` and `holdings`: by subType, then title. An
|
|
141
|
+
* entry with no subType sorts first.
|
|
142
|
+
*
|
|
143
|
+
* @param {HoldingsEntry} a
|
|
144
|
+
* @param {HoldingsEntry} b
|
|
145
|
+
* @returns {number}
|
|
146
|
+
*/
|
|
147
|
+
function bySubTypeThenTitle(a, b) {
|
|
148
|
+
return (
|
|
149
|
+
(a.subType ?? "").localeCompare(b.subType ?? "", "en") ||
|
|
150
|
+
a.title.localeCompare(b.title, "en")
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A stable order for `held_by`: by title.
|
|
156
|
+
*
|
|
157
|
+
* @param {HoldingsEntry} a
|
|
158
|
+
* @param {HoldingsEntry} b
|
|
159
|
+
* @returns {number}
|
|
160
|
+
*/
|
|
161
|
+
function byTitle(a, b) {
|
|
162
|
+
return a.title.localeCompare(b.title, "en");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The entry a node is listed as.
|
|
167
|
+
*
|
|
168
|
+
* **`url` is absent for a node that publishes no page**, and the entry is
|
|
169
|
+
* emitted all the same. A stub is a place somebody has not written yet: it
|
|
170
|
+
* carries its facts, and Weyshott belongs in Aelwyth's `contains` whether or
|
|
171
|
+
* not anyone has written Weyshott's page. What it cannot have is a link, so
|
|
172
|
+
* the renderer prints the title as plain text — the same rule a table cell
|
|
173
|
+
* follows when its `_ref` is null, in a different renderer.
|
|
174
|
+
*
|
|
175
|
+
* @param {HoldingsNode} node - The node.
|
|
176
|
+
* @returns {HoldingsEntry} Its entry.
|
|
177
|
+
*/
|
|
178
|
+
function entryOf(node) {
|
|
179
|
+
return {
|
|
180
|
+
title: node.title,
|
|
181
|
+
...(listed(node) ? { url: node.url } : {}),
|
|
182
|
+
type: node.type,
|
|
183
|
+
...(node.subType === undefined ? {} : { subType: node.subType }),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Whether a node publishes a page.
|
|
189
|
+
*
|
|
190
|
+
* **A URL gates where a list is written, never whether a node may appear in
|
|
191
|
+
* someone else's list.** You cannot write a `contains` block on a page that
|
|
192
|
+
* does not exist, so this guards {@link holdingsPages}'s `on(url)` calls — and
|
|
193
|
+
* nothing else. Guarding the membership too is how a stub vanishes from every
|
|
194
|
+
* containment and tenure list in the corpus, silently, which is the one failure
|
|
195
|
+
* mode a diff cannot show.
|
|
196
|
+
*
|
|
197
|
+
* @param {HoldingsNode} node - The node.
|
|
198
|
+
* @returns {boolean} Whether it has a page to write a list on.
|
|
199
|
+
*/
|
|
200
|
+
function listed(node) {
|
|
201
|
+
return node.url !== undefined && node.url !== null && node.url !== "";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Read a local note into a node, or `null` for a note that takes no part.
|
|
206
|
+
*
|
|
207
|
+
* @param {object} fm - The note's frontmatter.
|
|
208
|
+
* @param {object} page - The page it publishes as.
|
|
209
|
+
* @param {string} page.title - Its published title.
|
|
210
|
+
* @param {string} page.url - Its URL.
|
|
211
|
+
* @returns {HoldingsNode|null} The node, or `null` for a note that is neither
|
|
212
|
+
* a place nor an affiliation, or that declares no shortcode.
|
|
213
|
+
*/
|
|
214
|
+
export function holdingsNode(fm, { title, url }) {
|
|
215
|
+
const type = String(fm?.type ?? "");
|
|
216
|
+
if (type !== "place" && type !== "affiliation") return null;
|
|
217
|
+
const shortcode = String(fm?.shortcode ?? "").toLowerCase();
|
|
218
|
+
if (!shortcode) return null;
|
|
219
|
+
const data = fm.data && typeof fm.data === "object" && !Array.isArray(fm.data) ? fm.data : {};
|
|
220
|
+
return {
|
|
221
|
+
shortcode,
|
|
222
|
+
type,
|
|
223
|
+
subType: fm.subType == null ? undefined : String(fm.subType),
|
|
224
|
+
title,
|
|
225
|
+
url,
|
|
226
|
+
parents: type === "place" ? shortcodesOf(data.parents) : [],
|
|
227
|
+
domains: type === "affiliation" ? shortcodesOf(data.domains) : [],
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Read a fetched index's places and affiliations as nodes.
|
|
233
|
+
*
|
|
234
|
+
* An entry carries what the producer's record stated — its name, `subType`,
|
|
235
|
+
* `parents` and `domains` — and its page URL where the package publishes one.
|
|
236
|
+
*
|
|
237
|
+
* @param {Map<string, object>|undefined} foreignIndex - As
|
|
238
|
+
* {@link module:engine/metadata-index.loadForeignIndexes} returns it.
|
|
239
|
+
* @returns {HoldingsNode[]} The nodes, in index order.
|
|
240
|
+
*/
|
|
241
|
+
export function foreignHoldingsNodes(foreignIndex) {
|
|
242
|
+
const out = [];
|
|
243
|
+
if (!foreignIndex) return out;
|
|
244
|
+
for (const [canonical, entry] of foreignIndex) {
|
|
245
|
+
const type = String(entry?.type ?? "");
|
|
246
|
+
if (type !== "place" && type !== "affiliation") continue;
|
|
247
|
+
const shortcode = canonical.split("-").pop()?.toLowerCase() ?? "";
|
|
248
|
+
if (!shortcode) continue;
|
|
249
|
+
out.push({
|
|
250
|
+
shortcode,
|
|
251
|
+
type,
|
|
252
|
+
subType: entry.subType == null ? undefined : String(entry.subType),
|
|
253
|
+
title: String(entry.name ?? shortcode),
|
|
254
|
+
url: entry.url,
|
|
255
|
+
parents: type === "place" ? shortcodesOf(entry.parents) : [],
|
|
256
|
+
domains: type === "affiliation" ? shortcodesOf(entry.domains) : [],
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Invert `parents` and `domains` into each page's lists.
|
|
264
|
+
*
|
|
265
|
+
* A shortcode is declared once per type: the first node declaring it wins,
|
|
266
|
+
* so local nodes handed in ahead of fetched ones shadow a dependency's, the
|
|
267
|
+
* way the link resolver answers. A node with no URL carries no lists of its
|
|
268
|
+
* own, since there is no page to write them on — and is still an entry on
|
|
269
|
+
* everybody else's, rendered as plain text. A name no node declares is
|
|
270
|
+
* dropped: a `parents` naming nowhere is the map's finding, a `domains`
|
|
271
|
+
* naming nowhere the reference check's.
|
|
272
|
+
*
|
|
273
|
+
* The result holds only pages with at least one entry on at least one list,
|
|
274
|
+
* and a page's block carries only the keys it has entries for — the theme's
|
|
275
|
+
* silent-disappear convention, kept at the source.
|
|
276
|
+
*
|
|
277
|
+
* @param {Iterable<HoldingsNode|null>} nodes - Every place and affiliation,
|
|
278
|
+
* local first. `parents` and `domains` are read as written, so a node
|
|
279
|
+
* built by hand may carry them as the note spells them.
|
|
280
|
+
* @returns {Map<string, Holdings>} URL → the page's lists, sorted.
|
|
281
|
+
*/
|
|
282
|
+
export function holdingsPages(nodes) {
|
|
283
|
+
/** @type {Map<string, HoldingsNode>} */
|
|
284
|
+
const places = new Map();
|
|
285
|
+
/** @type {Map<string, HoldingsNode>} */
|
|
286
|
+
const affiliations = new Map();
|
|
287
|
+
for (const node of nodes) {
|
|
288
|
+
if (!node) continue;
|
|
289
|
+
const by = node.type === "place" ? places : affiliations;
|
|
290
|
+
const shortcode = String(node.shortcode ?? "").toLowerCase();
|
|
291
|
+
if (!shortcode || by.has(shortcode)) continue;
|
|
292
|
+
// Read through the one reader, so a caller handing in what a note
|
|
293
|
+
// wrote — an address, a wikilink, a repeat — is read as `holdingsNode`
|
|
294
|
+
// reads it.
|
|
295
|
+
by.set(shortcode, {
|
|
296
|
+
...node,
|
|
297
|
+
shortcode,
|
|
298
|
+
parents: shortcodesOf(node.parents),
|
|
299
|
+
domains: shortcodesOf(node.domains),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** @type {Map<string, {contains: HoldingsEntry[], held_by: HoldingsEntry[], holdings: HoldingsEntry[]}>} */
|
|
304
|
+
const lists = new Map();
|
|
305
|
+
const on = (url) => {
|
|
306
|
+
let found = lists.get(url);
|
|
307
|
+
if (!found) {
|
|
308
|
+
found = { contains: [], held_by: [], holdings: [] };
|
|
309
|
+
lists.set(url, found);
|
|
310
|
+
}
|
|
311
|
+
return found;
|
|
312
|
+
};
|
|
313
|
+
// Every gate below is on the page a list is *written* on, never on the
|
|
314
|
+
// node the list names — see {@link listed}.
|
|
315
|
+
for (const child of places.values()) {
|
|
316
|
+
for (const shortcode of child.parents) {
|
|
317
|
+
const parent = places.get(shortcode);
|
|
318
|
+
if (!parent || !listed(parent) || parent === child) continue;
|
|
319
|
+
on(parent.url).contains.push(entryOf(child));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (const holder of affiliations.values()) {
|
|
324
|
+
for (const shortcode of holder.domains) {
|
|
325
|
+
const held = places.get(shortcode);
|
|
326
|
+
if (!held) continue;
|
|
327
|
+
if (listed(holder)) on(holder.url).holdings.push(entryOf(held));
|
|
328
|
+
if (listed(held)) on(held.url).held_by.push(entryOf(holder));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const out = new Map();
|
|
333
|
+
for (const [url, { contains, held_by, holdings }] of lists) {
|
|
334
|
+
/** @type {Holdings} */
|
|
335
|
+
const block = {};
|
|
336
|
+
if (contains.length) block.contains = contains.sort(bySubTypeThenTitle);
|
|
337
|
+
if (held_by.length) block.held_by = held_by.sort(byTitle);
|
|
338
|
+
if (holdings.length) block.holdings = holdings.sort(bySubTypeThenTitle);
|
|
339
|
+
if (Object.keys(block).length) out.set(url, block);
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* --------------------------------------------------------------------- */
|
|
345
|
+
/* The lint */
|
|
346
|
+
/* --------------------------------------------------------------------- */
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Every shortcode some affiliation's `domains` names, computed once per link
|
|
350
|
+
* index. The lint asks the question once per place note, and the answer is a
|
|
351
|
+
* fact about the whole corpus — local notes and every fetched entry — so it
|
|
352
|
+
* is read off the index the first time and kept beside it.
|
|
353
|
+
*
|
|
354
|
+
* @type {WeakMap<object, Set<string>>}
|
|
355
|
+
*/
|
|
356
|
+
const HELD = new WeakMap();
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The shortcodes every affiliation's `domains` names, across the link index.
|
|
360
|
+
*
|
|
361
|
+
* @param {object} index - The link index.
|
|
362
|
+
* @returns {Set<string>} The held shortcodes, lower case.
|
|
363
|
+
*/
|
|
364
|
+
function heldShortcodes(index) {
|
|
365
|
+
let held = HELD.get(index);
|
|
366
|
+
if (held) return held;
|
|
367
|
+
held = new Set();
|
|
368
|
+
for (const note of index.notes ?? []) {
|
|
369
|
+
if (String(note.type ?? note.fm?.type ?? "") !== "affiliation") continue;
|
|
370
|
+
const data = note.fm?.data;
|
|
371
|
+
const domains = data && typeof data === "object" ? data.domains : undefined;
|
|
372
|
+
for (const shortcode of shortcodesOf(domains)) held.add(shortcode);
|
|
373
|
+
}
|
|
374
|
+
for (const entry of index.foreign?.index?.values?.() ?? []) {
|
|
375
|
+
if (String(entry?.type ?? "") !== "affiliation") continue;
|
|
376
|
+
for (const shortcode of shortcodesOf(entry.domains)) held.add(shortcode);
|
|
377
|
+
}
|
|
378
|
+
HELD.set(index, held);
|
|
379
|
+
return held;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Check a place note's tenure: a settlement, a site or a structure that no
|
|
384
|
+
* affiliation's `domains` names is unheld land, reported as a warning at the
|
|
385
|
+
* note's `type:` line.
|
|
386
|
+
*
|
|
387
|
+
* Declared on the `place` vocabulary as its type-level check, so the lint
|
|
388
|
+
* runs it beside the field checks with the same index. Without an index the
|
|
389
|
+
* question cannot be asked and nothing is reported.
|
|
390
|
+
*
|
|
391
|
+
* @param {object} note - The note, as the link index hands it over.
|
|
392
|
+
* @param {object} [opts]
|
|
393
|
+
* @param {object} [opts.index] - The link index, holding every affiliation.
|
|
394
|
+
* @returns {object[]} Findings.
|
|
395
|
+
*/
|
|
396
|
+
export function checkHeld(note, { index } = {}) {
|
|
397
|
+
if (!index) return [];
|
|
398
|
+
const fm = note.fm ?? {};
|
|
399
|
+
if (String(fm.type ?? "") !== "place") return [];
|
|
400
|
+
const subType = String(fm.subType ?? "");
|
|
401
|
+
if (!HELD_SUBTYPES.includes(subType)) return [];
|
|
402
|
+
const shortcode = String(fm.shortcode ?? "").toLowerCase();
|
|
403
|
+
if (!shortcode) return [];
|
|
404
|
+
if (heldShortcodes(index).has(shortcode)) return [];
|
|
405
|
+
return [
|
|
406
|
+
{
|
|
407
|
+
file: note.file,
|
|
408
|
+
...positionInFrontmatter(note.raw ?? "", "type", undefined, { topLevel: true }),
|
|
409
|
+
severity: "warning",
|
|
410
|
+
message:
|
|
411
|
+
`unheld land: no affiliation's \`domains\` names "${shortcode}", so nothing ` +
|
|
412
|
+
`says who holds this ${subType}; name it in the \`domains\` of the house, ` +
|
|
413
|
+
`order or polity that does`,
|
|
414
|
+
},
|
|
415
|
+
];
|
|
416
|
+
}
|
package/engine/index-records.mjs
CHANGED
|
@@ -168,6 +168,31 @@ export function isNoteRecord(record) {
|
|
|
168
168
|
return !record?.documents && !isAssetRecord(record);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Whether a record is a **stub**: a note with an empty body.
|
|
173
|
+
*
|
|
174
|
+
* Asked of the **absent address**, not of a marker block, because the absent
|
|
175
|
+
* address is the whole signal. A note's body is not in the index — the index
|
|
176
|
+
* carries what is *about* a note — so there is nothing else to ask, and nothing
|
|
177
|
+
* else needs to be: an address is emitted if and only if the body is non-empty
|
|
178
|
+
* on a type an empty body suppresses (see
|
|
179
|
+
* {@link module:engine/note-state.isStubNote}).
|
|
180
|
+
*
|
|
181
|
+
* A stub keeps its file, its `id` and, where its type compiles one, its Foundry
|
|
182
|
+
* document. What the empty body suppresses is the **page**, and the address and
|
|
183
|
+
* the link target that go with it.
|
|
184
|
+
*
|
|
185
|
+
* Asked only of a note's own record. A documentation journal and an asset are
|
|
186
|
+
* documents rather than notes, and both are addressed whatever the note they
|
|
187
|
+
* belong to says.
|
|
188
|
+
*
|
|
189
|
+
* @param {Record<string, any>} record - An index record.
|
|
190
|
+
* @returns {boolean} True for a stub's record.
|
|
191
|
+
*/
|
|
192
|
+
export function isStub(record) {
|
|
193
|
+
return isNoteRecord(record) && !record?.address;
|
|
194
|
+
}
|
|
195
|
+
|
|
171
196
|
/**
|
|
172
197
|
* The path a record names inside its package, whichever shape it is.
|
|
173
198
|
*
|
package/engine/index.mjs
CHANGED
|
@@ -84,9 +84,22 @@ export * as noteSchemas from "./note-schemas.mjs";
|
|
|
84
84
|
/** The closed frontmatter regions: the `data:` container and each type's `subType`. */
|
|
85
85
|
export * as noteVocabulary from "./note-vocabulary.mjs";
|
|
86
86
|
|
|
87
|
+
/** The three states of a note — stub, draft, full — and the empty body they rest on. */
|
|
88
|
+
export * as noteState from "./note-state.mjs";
|
|
89
|
+
|
|
90
|
+
/** Whether every empty body is a stub on purpose, and how finished a tree is. */
|
|
91
|
+
export * as stubLint from "./stub-lint.mjs";
|
|
92
|
+
|
|
87
93
|
/** What a place is next to and reachable from: the closed sets, and the checks. */
|
|
88
94
|
export * as placeRelations from "./place-relations.mjs";
|
|
89
95
|
|
|
96
|
+
/** What lies within a place, who holds it, and what an affiliation holds — the page lists, and the tenure check. */
|
|
97
|
+
export * as holdings from "./holdings.mjs";
|
|
98
|
+
export * as populations from "./populations.mjs";
|
|
99
|
+
|
|
100
|
+
/** What trade a settlement supports: the six-step market scale, and the check that holds a value to it. */
|
|
101
|
+
export * as marketClass from "./market-class.mjs";
|
|
102
|
+
|
|
90
103
|
/** The places a map is drawn from, read from the content index, and what `parents` says about them. */
|
|
91
104
|
export * as mapPlaces from "./map-places.mjs";
|
|
92
105
|
|
|
@@ -102,6 +115,9 @@ export * as mapGraphviz from "./map-graphviz.mjs";
|
|
|
102
115
|
/** `content-build map`: the drawings, written under `build/map/`. */
|
|
103
116
|
export * as mapBuild from "./map-build.mjs";
|
|
104
117
|
|
|
118
|
+
/** The map from each place, drawn for the site and written into its page. */
|
|
119
|
+
export * as siteMaps from "./site-maps.mjs";
|
|
120
|
+
|
|
105
121
|
/** The closed registry of system ids, and the `none` that stands for no system. */
|
|
106
122
|
export * as systems from "./systems.mjs";
|
|
107
123
|
|
package/engine/map-graphviz.mjs
CHANGED
|
@@ -89,9 +89,19 @@ export function findGraphviz(engine, { env = process.env, fallbacks = KNOWN_DIRE
|
|
|
89
89
|
return undefined;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* What installs GraphViz on the two platforms a contributor is likely to be
|
|
94
|
+
* on, as the clause every "not installed" message ends with.
|
|
95
|
+
*
|
|
96
|
+
* @type {string}
|
|
97
|
+
*/
|
|
98
|
+
export const GRAPHVIZ_INSTALL =
|
|
99
|
+
"install GraphViz (`brew install graphviz` on macOS, " +
|
|
100
|
+
"`apt-get install graphviz` on Debian or Ubuntu)";
|
|
101
|
+
|
|
92
102
|
/**
|
|
93
103
|
* What to say when an engine is not installed: the name, and what installs
|
|
94
|
-
* it
|
|
104
|
+
* it.
|
|
95
105
|
*
|
|
96
106
|
* @param {string} engine - The engine that was wanted.
|
|
97
107
|
* @returns {string} The message.
|
|
@@ -99,8 +109,7 @@ export function findGraphviz(engine, { env = process.env, fallbacks = KNOWN_DIRE
|
|
|
99
109
|
export function graphvizMissingMessage(engine) {
|
|
100
110
|
return (
|
|
101
111
|
`GraphViz's \`${engine}\` is not installed, and \`content-build map\` draws with it; ` +
|
|
102
|
-
|
|
103
|
-
`\`apt-get install graphviz\` on Debian or Ubuntu) and run the command again`
|
|
112
|
+
`${GRAPHVIZ_INSTALL} and run the command again`
|
|
104
113
|
);
|
|
105
114
|
}
|
|
106
115
|
|
package/engine/map-places.mjs
CHANGED
|
@@ -251,14 +251,44 @@ export function loadMapWorld({ config, contentBase, base, problems }) {
|
|
|
251
251
|
skipDirectories: config.skipDirectories,
|
|
252
252
|
problems,
|
|
253
253
|
});
|
|
254
|
-
const world = placesFromRecords(records, { contentBase: tree, base });
|
|
255
|
-
world.config = config;
|
|
256
254
|
const foreign = loadForeignIndexes(config, [config.contentPackage]);
|
|
257
|
-
|
|
255
|
+
const world = mapWorld({
|
|
256
|
+
records,
|
|
257
|
+
foreignIndex: foreign.index,
|
|
258
|
+
contentBase: tree,
|
|
259
|
+
base,
|
|
260
|
+
config,
|
|
261
|
+
});
|
|
258
262
|
world.stale = foreign.stale;
|
|
259
263
|
return world;
|
|
260
264
|
}
|
|
261
265
|
|
|
266
|
+
/**
|
|
267
|
+
* The world a map is drawn from, out of records and indexes a caller already
|
|
268
|
+
* holds: this package's places from its index records, and every
|
|
269
|
+
* dependency's from the fetched indexes. The site build reads its corpus and
|
|
270
|
+
* its foreign indexes once for every surface it publishes, and hands them
|
|
271
|
+
* here rather than walking the tree a second time.
|
|
272
|
+
*
|
|
273
|
+
* @param {object} opts
|
|
274
|
+
* @param {Array<Record<string, any>>} opts.records - Index records, as
|
|
275
|
+
* {@link module:engine/content-index.indexRecordsFor} returns them.
|
|
276
|
+
* @param {Map<string, object>} opts.foreignIndex - The fetched indexes, as
|
|
277
|
+
* {@link module:engine/metadata-index.loadForeignIndexes} returns `index`.
|
|
278
|
+
* @param {string} opts.contentBase - The content root the records were read from.
|
|
279
|
+
* @param {string} [opts.base] - The site base this package's pages are served
|
|
280
|
+
* under. Absent, no local place carries a URL.
|
|
281
|
+
* @param {object} opts.config - The resolved configuration.
|
|
282
|
+
* @returns {MapWorld} The world, with nothing stale: the caller has already
|
|
283
|
+
* judged its indexes usable.
|
|
284
|
+
*/
|
|
285
|
+
export function mapWorld({ records, foreignIndex, contentBase, base, config }) {
|
|
286
|
+
const world = placesFromRecords(records, { contentBase, base });
|
|
287
|
+
world.config = config;
|
|
288
|
+
addForeignPlaces(world, foreignIndex);
|
|
289
|
+
return world;
|
|
290
|
+
}
|
|
291
|
+
|
|
262
292
|
/* --------------------------------------------------------------------- */
|
|
263
293
|
/* Containment */
|
|
264
294
|
/* --------------------------------------------------------------------- */
|