@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
|
@@ -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.
|
|
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
|
|
209
|
-
*
|
|
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"`,
|
|
137
|
-
* them is an **error**: the three
|
|
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
|
*
|
package/types/engine/index.d.mts
CHANGED
|
@@ -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
|
|
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
|
*
|