@heroiclands/package-build 22.4.7 → 22.5.1
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 +34 -0
- package/CONTENT.md +89 -1
- package/bin/content-build.mjs +16 -2
- package/docs/api.md +50 -12
- package/docs/content-format.md +192 -0
- package/docs/diagnostics.md +44 -0
- package/engine/content-index.mjs +75 -24
- package/engine/content-links.mjs +60 -6
- package/engine/content-tables.mjs +29 -2
- package/engine/foundry-entries.mjs +32 -16
- package/engine/holdings.mjs +42 -17
- package/engine/index-records.mjs +25 -0
- package/engine/index.mjs +7 -0
- package/engine/journals.mjs +13 -5
- package/engine/note-state.mjs +242 -0
- package/engine/note-vocabulary.mjs +40 -2
- package/engine/pdf-build.mjs +4 -2
- package/engine/populations.mjs +557 -0
- package/engine/site-build.mjs +8 -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 +20 -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 +17 -6
- package/types/engine/index-records.d.mts +22 -0
- package/types/engine/index.d.mts +3 -0
- package/types/engine/note-state.d.mts +136 -0
- package/types/engine/note-vocabulary.d.mts +15 -0
- package/types/engine/populations.d.mts +121 -0
- package/types/engine/stub-lint.d.mts +51 -0
- package/types/engine/wikilink-syntax.d.mts +19 -1
package/engine/sql-tables.mjs
CHANGED
|
@@ -38,6 +38,7 @@ import os from "node:os";
|
|
|
38
38
|
import path from "node:path";
|
|
39
39
|
|
|
40
40
|
import { FENCE_LINE, parseHeaderArgs } from "./code-fences.mjs";
|
|
41
|
+
import { MARKET_CLASSES } from "./market-class.mjs";
|
|
41
42
|
import { parseMarkdownFile } from "./helpers.mjs";
|
|
42
43
|
// The record accessors only — see `engine/index-records.mjs`.
|
|
43
44
|
import { isNoteRecord, noteFile } from "./index-records.mjs";
|
|
@@ -166,19 +167,84 @@ export async function openNotesDatabase(records, { dir, dependencies = [] } = {}
|
|
|
166
167
|
const instance = await DuckDBInstance.create(":memory:");
|
|
167
168
|
const connection = await instance.connect();
|
|
168
169
|
await connection.run("SET threads=1");
|
|
169
|
-
await connection
|
|
170
|
+
await createRelations(connection, jsonl);
|
|
170
171
|
|
|
171
172
|
// One schema per declared dependency, so `FROM sohl.notes` reads the notes
|
|
172
173
|
// that package published. Quoted, because a package id may carry a hyphen
|
|
173
|
-
// (`sohl-thalorna`) and an unquoted identifier may not.
|
|
174
|
+
// (`sohl-thalorna`) and an unquoted identifier may not. Both views are
|
|
175
|
+
// created, so `FROM sohl.entries` reads a dependency's stubs the same way.
|
|
174
176
|
for (const dep of dependencies) {
|
|
175
177
|
if (!dep?.id || !dep?.file || !fs.existsSync(dep.file)) continue;
|
|
176
178
|
const schema = `"${String(dep.id).replace(/"/g, '""')}"`;
|
|
177
179
|
await connection.run(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
|
|
178
|
-
await connection.
|
|
180
|
+
await createRelations(connection, dep.file, `${schema}.`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The market scale as a relation, so a table prints `village` beside the
|
|
184
|
+
// number a note wrote without a second copy of the scale living in authored
|
|
185
|
+
// content. Three columns, because the scale states three things and a join
|
|
186
|
+
// that could only give back the name would send the next author to copy the
|
|
187
|
+
// rest by hand.
|
|
188
|
+
await connection.run(
|
|
189
|
+
"CREATE VIEW market AS SELECT * FROM (VALUES " +
|
|
190
|
+
MARKET_CLASSES.map(
|
|
191
|
+
(c) =>
|
|
192
|
+
`(${c.value}, '${c.name.replace(/'/g, "''")}', ` +
|
|
193
|
+
`'${c.trade.replace(/'/g, "''")}')`,
|
|
194
|
+
).join(", ") +
|
|
195
|
+
") AS t(value, name, trade)",
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// The stub-inclusive reading of `notes`, in a schema of its own. Putting it
|
|
199
|
+
// on the search path re-runs an authored query with unqualified `notes`
|
|
200
|
+
// resolving to `entries`, which is how a fence that would gain rows from
|
|
201
|
+
// the wider relation is found without parsing anybody's SQL — see
|
|
202
|
+
// {@link prepareSqlTables}.
|
|
203
|
+
await connection.run(`CREATE SCHEMA IF NOT EXISTS ${WITH_STUBS_SCHEMA}`);
|
|
204
|
+
await connection.run(`CREATE VIEW ${WITH_STUBS_SCHEMA}.notes AS SELECT * FROM entries`);
|
|
205
|
+
|
|
206
|
+
// Whether this index holds a stub at all, asked once. A corpus with none
|
|
207
|
+
// pays nothing for the comparison above, which is every package that has
|
|
208
|
+
// not started writing them.
|
|
209
|
+
const hasStubs =
|
|
210
|
+
(
|
|
211
|
+
await connection.runAndReadAll("SELECT count(*) AS n FROM entries WHERE state = 'stub'")
|
|
212
|
+
).getRowObjects()[0].n > 0;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* How many rows a query loses by selecting `FROM notes` rather than
|
|
216
|
+
* `FROM entries`.
|
|
217
|
+
*
|
|
218
|
+
* Answered by **running the author's query again** with unqualified `notes`
|
|
219
|
+
* resolving to the stub-inclusive relation, rather than by reading the
|
|
220
|
+
* `WHERE` clause. Reading it would mean parsing somebody else's SQL to work
|
|
221
|
+
* out which types a fence covers, and being wrong about that is exactly the
|
|
222
|
+
* silent failure the two views exist to prevent. Re-running is exact, and
|
|
223
|
+
* it costs nothing at all for a corpus with no stub, which is the common
|
|
224
|
+
* case and the only one that is asked twice for nothing.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} sql - The query, as authored.
|
|
227
|
+
* @param {number} rows - How many rows it selected as written.
|
|
228
|
+
* @returns {Promise<number>} How many rows it would gain.
|
|
229
|
+
*/
|
|
230
|
+
async function stubsExcluded(sql, rows) {
|
|
231
|
+
if (!hasStubs) return 0;
|
|
232
|
+
try {
|
|
233
|
+
await connection.run(`SET search_path='${WITH_STUBS_SCHEMA}'`);
|
|
234
|
+
const wider = await connection.runAndReadAll(sql);
|
|
235
|
+
return Math.max(0, wider.getRowObjects().length - rows);
|
|
236
|
+
} catch {
|
|
237
|
+
// A query that cannot run under the wider relation has nothing to
|
|
238
|
+
// say about stubs, and the failure it does have is reported by the
|
|
239
|
+
// run that matters.
|
|
240
|
+
return 0;
|
|
241
|
+
} finally {
|
|
242
|
+
await connection.run("RESET search_path");
|
|
243
|
+
}
|
|
179
244
|
}
|
|
180
245
|
|
|
181
246
|
return {
|
|
247
|
+
stubsExcluded,
|
|
182
248
|
async query(sql) {
|
|
183
249
|
const reader = await connection.runAndReadAll(sql);
|
|
184
250
|
return {
|
|
@@ -201,6 +267,78 @@ export async function openNotesDatabase(records, { dir, dependencies = [] } = {}
|
|
|
201
267
|
};
|
|
202
268
|
}
|
|
203
269
|
|
|
270
|
+
/**
|
|
271
|
+
* The schema whose `notes` is the stub-inclusive relation.
|
|
272
|
+
*
|
|
273
|
+
* A name nothing authored would collide with, because it is put on the search
|
|
274
|
+
* path underneath a query somebody else wrote.
|
|
275
|
+
*
|
|
276
|
+
* @type {string}
|
|
277
|
+
*/
|
|
278
|
+
const WITH_STUBS_SCHEMA = "__with_stubs";
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Create the two relations an index is read through, and the ladder one of them
|
|
282
|
+
* derives.
|
|
283
|
+
*
|
|
284
|
+
* **`entries` is every row**, stubs included, plus a derived `state` column:
|
|
285
|
+
*
|
|
286
|
+
* ```text
|
|
287
|
+
* address IS NULL → stub
|
|
288
|
+
* list_contains(tags, 'draft') → draft
|
|
289
|
+
* otherwise → full
|
|
290
|
+
* ```
|
|
291
|
+
*
|
|
292
|
+
* The derivation lives here and nowhere else. Nothing is stored in the index,
|
|
293
|
+
* no note can author it, and no query repeats the `CASE` — the same rule that
|
|
294
|
+
* keeps `_ref` and `_section` ordinary SQL rather than fence options. The stub
|
|
295
|
+
* test is `address IS NULL` because the index carries no bodies, and it is
|
|
296
|
+
* exact: an address is emitted if and only if the body is non-empty on a type
|
|
297
|
+
* an empty body suppresses.
|
|
298
|
+
*
|
|
299
|
+
* **`notes` is `entries` without the stubs**, which is what every authored
|
|
300
|
+
* fence already means by it. A `notes` that silently gained stubs would grow
|
|
301
|
+
* every table selecting over it with nobody deciding, and a build that exits 0
|
|
302
|
+
* and produces the wrong output is the costly kind of failure. Adopting stubs
|
|
303
|
+
* in a given table is a one-word edit an author makes on purpose; the fences
|
|
304
|
+
* that would gain rows are warned about rather than changed.
|
|
305
|
+
*
|
|
306
|
+
* **The columns are asked for rather than assumed.** `union_by_name` infers the
|
|
307
|
+
* union of what the rows carry, so a corpus in which no note carries a tag has
|
|
308
|
+
* no `tags` column at all and naming it would fail to bind — which is a real
|
|
309
|
+
* index, not a hypothetical one. So the shape is described first and each half
|
|
310
|
+
* of the ladder falls back to a constant where its column is absent.
|
|
311
|
+
*
|
|
312
|
+
* @param {object} connection - An open DuckDB connection.
|
|
313
|
+
* @param {string} file - The JSONL to read.
|
|
314
|
+
* @param {string} [prefix] - A schema to qualify the view names with.
|
|
315
|
+
* @returns {Promise<void>}
|
|
316
|
+
*/
|
|
317
|
+
async function createRelations(connection, file, prefix = "") {
|
|
318
|
+
const read = readJsonAuto(file);
|
|
319
|
+
const described = await connection.runAndReadAll(`DESCRIBE ${read}`);
|
|
320
|
+
const columns = new Set(described.getRowObjects().map((row) => String(row.column_name)));
|
|
321
|
+
// A row with no address is a stub. With no address column anywhere, every
|
|
322
|
+
// row is one — which is what an index of nothing but unaddressable notes
|
|
323
|
+
// says, and saying it is more honest than calling them all full.
|
|
324
|
+
const stub = columns.has("address") ? "address IS NULL" : "true";
|
|
325
|
+
// `tags:` is authored by hand: a single tag may be a scalar rather than a
|
|
326
|
+
// list, so both readings are asked.
|
|
327
|
+
const draft =
|
|
328
|
+
columns.has("tags") ?
|
|
329
|
+
"COALESCE(list_contains(TRY_CAST(tags AS VARCHAR[]), 'draft'), false) " +
|
|
330
|
+
"OR COALESCE(TRY_CAST(tags AS VARCHAR) = 'draft', false)"
|
|
331
|
+
: "false";
|
|
332
|
+
await connection.run(
|
|
333
|
+
`CREATE VIEW ${prefix}entries AS SELECT *, ` +
|
|
334
|
+
`CASE WHEN ${stub} THEN 'stub' WHEN ${draft} THEN 'draft' ` +
|
|
335
|
+
`ELSE 'full' END AS state FROM (${read})`,
|
|
336
|
+
);
|
|
337
|
+
await connection.run(
|
|
338
|
+
`CREATE VIEW ${prefix}notes AS SELECT * FROM ${prefix}entries WHERE state <> 'stub'`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
204
342
|
/**
|
|
205
343
|
* The `read_json_auto` clause both the own-notes view and a dependency's use.
|
|
206
344
|
*
|
|
@@ -407,6 +545,11 @@ export async function prepareSqlTables(db, sources, { linkable } = {}) {
|
|
|
407
545
|
sectionLevel: block.sectionLevel,
|
|
408
546
|
}),
|
|
409
547
|
rows: result.rows.length,
|
|
548
|
+
// How many rows this table would gain from the wider
|
|
549
|
+
// relation, so a fence that has not adopted stubs says so
|
|
550
|
+
// rather than quietly listing fewer settlements than the
|
|
551
|
+
// region has.
|
|
552
|
+
stubsExcluded: await db.stubsExcluded?.(block.query, result.rows.length),
|
|
410
553
|
allowEmpty: block.allowEmpty,
|
|
411
554
|
});
|
|
412
555
|
} catch (err) {
|
|
@@ -470,7 +613,17 @@ export async function prepareTreeSqlTables(contentBase, { config, skipDirectorie
|
|
|
470
613
|
if (!sources.length) return undefined;
|
|
471
614
|
// A cell links only where the address it would emit resolves, so a table
|
|
472
615
|
// never ships a link the wikilink pass will then report dead.
|
|
473
|
-
|
|
616
|
+
//
|
|
617
|
+
// A documentation journal is left out, and that is the whole of the stub
|
|
618
|
+
// rule here: its slug is its note's, so a written note supplies it anyway,
|
|
619
|
+
// while a stub's journal would put back the very slug the stub withheld and
|
|
620
|
+
// the cell would link to a page that does not exist.
|
|
621
|
+
const addresses = new Set(
|
|
622
|
+
indexRecords
|
|
623
|
+
.filter((record) => !record.documents)
|
|
624
|
+
.map((record) => record.address?.slug)
|
|
625
|
+
.filter(Boolean),
|
|
626
|
+
);
|
|
474
627
|
// Each declared dependency's published index, attached as its own schema so
|
|
475
628
|
// a table can read `FROM <package>.notes`. Imported here for the
|
|
476
629
|
// same cycle reason the index is, and tolerated when absent: a tree with no
|
|
@@ -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.1",
|
|
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,19 @@ 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 its `id`, and a `foundry`
|
|
108
|
+
* block naming every document that was made — which is every document
|
|
109
|
+
* derived from `data:`, and no journal, because a journal of an empty body
|
|
110
|
+
* would be empty and is not created.
|
|
103
111
|
* @throws {Error} When the note carries a key this module derives, which would
|
|
104
112
|
* otherwise be overwritten without a word. `file` and, where the file was
|
|
105
113
|
* read, `position` ride on the error.
|
|
@@ -205,8 +213,11 @@ export function indexRecordsFor({ contentBase, assetsBase, config, skipDirectori
|
|
|
205
213
|
* `paths.contentIndex`.
|
|
206
214
|
* @param {object} [options.config] - A resolved configuration; loaded when omitted.
|
|
207
215
|
* @returns {{file: string, notes: number, assets: number, records: number,
|
|
208
|
-
* bytes: number
|
|
209
|
-
*
|
|
216
|
+
* bytes: number, full: number, draft: number, stub: number}} Where it was
|
|
217
|
+
* written, how many notes and how many assets it holds, how many records that
|
|
218
|
+
* is in all, its size, and the ladder those notes sit on — so the ratio of
|
|
219
|
+
* written to unwritten is visible on every build rather than discovered in a
|
|
220
|
+
* year.
|
|
210
221
|
* @throws {Error} When the content tree is absent, or when it yields no note at
|
|
211
222
|
* all — an empty index is indistinguishable from a mis-pointed tree, and a
|
|
212
223
|
* reader would take it as the authoritative statement that this package has
|
|
@@ -222,13 +233,17 @@ export function emitContentIndex({ contentBase, outDir, config }?: {
|
|
|
222
233
|
assets: number;
|
|
223
234
|
records: number;
|
|
224
235
|
bytes: number;
|
|
236
|
+
full: number;
|
|
237
|
+
draft: number;
|
|
238
|
+
stub: number;
|
|
225
239
|
};
|
|
226
240
|
import { collectAnchors } from "./anchors.mjs";
|
|
227
241
|
import { authoredFrontmatter } from "./index-records.mjs";
|
|
228
242
|
import { DERIVED_KEYS } from "./index-records.mjs";
|
|
229
243
|
import { isAssetRecord } from "./index-records.mjs";
|
|
230
244
|
import { isNoteRecord } from "./index-records.mjs";
|
|
245
|
+
import { isStub } from "./index-records.mjs";
|
|
231
246
|
import { noteFile } from "./index-records.mjs";
|
|
232
247
|
import { recordPath } from "./index-records.mjs";
|
|
233
248
|
import { sortKeysDeep } from "./index-records.mjs";
|
|
234
|
-
export { collectAnchors, authoredFrontmatter, DERIVED_KEYS, isAssetRecord, isNoteRecord, noteFile, recordPath, sortKeysDeep };
|
|
249
|
+
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>;
|
|
@@ -1,3 +1,11 @@
|
|
|
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[];
|
|
1
9
|
/**
|
|
2
10
|
* Read a local note into a node, or `null` for a note that takes no part.
|
|
3
11
|
*
|
|
@@ -28,10 +36,11 @@ export function foreignHoldingsNodes(foreignIndex: Map<string, object> | undefin
|
|
|
28
36
|
*
|
|
29
37
|
* A shortcode is declared once per type: the first node declaring it wins,
|
|
30
38
|
* so local nodes handed in ahead of fetched ones shadow a dependency's, the
|
|
31
|
-
* way the link resolver answers. A node with no URL
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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.
|
|
35
44
|
*
|
|
36
45
|
* The result holds only pages with at least one entry on at least one list,
|
|
37
46
|
* and a page's block carries only the keys it has entries for — the theme's
|
|
@@ -85,9 +94,11 @@ export type HoldingsEntry = {
|
|
|
85
94
|
title: string;
|
|
86
95
|
/**
|
|
87
96
|
* `<base><slug>/` — the page's address as every
|
|
88
|
-
*
|
|
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.
|
|
89
100
|
*/
|
|
90
|
-
url
|
|
101
|
+
url?: string | undefined;
|
|
91
102
|
/**
|
|
92
103
|
* The note's `type` — `place` or `affiliation`.
|
|
93
104
|
*/
|
|
@@ -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
|
*
|