@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.
@@ -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.run(`CREATE VIEW notes AS ${readJsonAuto(jsonl)}`);
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.run(`CREATE VIEW ${schema}.notes AS ${readJsonAuto(dep.file)}`);
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
- const addresses = new Set(indexRecords.map((record) => record.address?.slug).filter(Boolean));
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.4.7",
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}} Where it was written, how many notes and how many assets it
209
- * holds, how many records that is in all, and its size.
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"`, or `"unresolved"` — and every one of
137
- * them is an **error**: the three resolvers agree on severity for every
138
- * class.
136
+ * `packages`), `"no-content-index"`, `"stub"` (with the stub's path) or
137
+ * `"unresolved"` — and every one of them is an **error**: the three
138
+ * resolvers agree on severity for every class.
139
139
  */
140
140
  export function auditLinks(index: ReturnType<typeof buildLinkIndex>): {
141
141
  deadAnchors: object[];
@@ -105,7 +105,8 @@ export function renderContentTable(spec: object, rows: Array<ContentTableDoc>, l
105
105
  * each came from, so a diagnostic about the expanded body can name an
106
106
  * authored position. An `errors` entry carries the 0-based line of the
107
107
  * directive that failed, for the same reason. `warnings` holds one entry per
108
- * `dataview` directive the body still authors.
108
+ * `dataview` directive the body still authors, and one per `sql` directive
109
+ * that leaves out stubs it would otherwise list.
109
110
  */
110
111
  export function expandContentTables(markdown: string, { docs, linkable, source, self, sqlTables, }?: {
111
112
  docs: Array<ContentTableDoc>;
@@ -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 is nobody's entry and
32
- * carries no lists of its own, since there is no page to write them on. A
33
- * name no node declares is dropped: a `parents` naming nowhere is the map's
34
- * finding, a `domains` naming nowhere the reference check's.
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
- * href this build renders composes it.
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: string;
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
  *