@heroiclands/package-build 17.1.0 → 18.0.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 +1773 -0
- package/CONTENT.md +288 -72
- package/README.md +1 -1
- package/bin/content-build.mjs +218 -137
- package/bin/package-build.mjs +43 -0
- package/content-config.mjs +62 -67
- package/docs/content-format.md +768 -58
- package/engine/actor-compiler.mjs +586 -0
- package/engine/address-charset.mjs +11 -8
- package/engine/address-diff.mjs +266 -22
- package/engine/anchored-sections.mjs +83 -0
- package/engine/anchors.mjs +83 -0
- package/engine/base-compiler.mjs +148 -18
- package/engine/bundle-notes.mjs +276 -0
- package/engine/bundles.mjs +307 -0
- package/engine/code-fences.mjs +103 -0
- package/engine/compile-corpus.mjs +89 -0
- package/engine/content-address.mjs +267 -5
- package/engine/content-format-check.mjs +54 -11
- package/engine/content-format.mjs +37 -5
- package/engine/content-index.mjs +394 -111
- package/engine/content-links.mjs +185 -77
- package/engine/content-lint.mjs +232 -14
- package/engine/content-package.mjs +2 -1
- package/engine/content-tables.mjs +99 -3
- package/engine/document-subtypes.mjs +49 -6
- package/engine/field-reference.mjs +1 -1
- package/engine/field-spec.mjs +56 -6
- package/engine/folder-notes.mjs +470 -0
- package/engine/foreign-catalog.mjs +195 -4
- package/engine/{manifest-emit.mjs → foundry-entries.mjs} +78 -81
- package/engine/frontmatter-lint.mjs +245 -19
- package/engine/frontmatter.mjs +88 -8
- package/engine/generate.mjs +304 -13
- package/engine/helpers.mjs +293 -64
- package/engine/ids.mjs +137 -2
- package/engine/index-records.mjs +126 -0
- package/engine/index.mjs +23 -5
- package/engine/item-compiler.mjs +349 -0
- package/engine/item-docs.mjs +7 -1
- package/engine/item-registry.mjs +6 -0
- package/engine/journals.mjs +93 -27
- package/engine/macros.mjs +4 -2
- package/engine/metadata-index.mjs +495 -0
- package/engine/note-claims.mjs +146 -25
- package/engine/note-ids.mjs +112 -0
- package/engine/note-renames.mjs +134 -0
- package/engine/note-schemas.mjs +25 -0
- package/engine/note-vocabulary.mjs +110 -27
- package/engine/pack-config.mjs +4 -0
- package/engine/pack-router.mjs +23 -0
- package/engine/retired-fields.mjs +137 -3
- package/engine/scenes.mjs +66 -34
- package/engine/schema-check.mjs +58 -17
- package/engine/site-build.mjs +71 -12
- package/engine/site-index.mjs +20 -7
- package/engine/sql-tables.mjs +485 -0
- package/engine/subtype-registry.mjs +102 -0
- package/engine/system-block.mjs +96 -12
- package/engine/systems.mjs +201 -0
- package/engine/web-wikilinks.mjs +50 -20
- package/engine/wikilink-syntax.mjs +17 -8
- package/engine/wikilinks.mjs +124 -51
- package/engine/yaml-lint.mjs +282 -0
- package/hm3/actors.mjs +352 -0
- package/hm3/default-item-art.mjs +75 -0
- package/hm3/document-subtypes.mjs +134 -0
- package/hm3/index.mjs +56 -0
- package/hm3/item-builders.mjs +84 -0
- package/hm3/item-fields.mjs +180 -0
- package/hm3/items.mjs +96 -0
- package/hm3/template-priority.mjs +85 -0
- package/labels.mjs +191 -0
- package/manifest.mjs +43 -2
- package/package.json +19 -4
- package/release.mjs +62 -7
- package/sohl/actors.mjs +33 -487
- package/sohl/being-info.mjs +16 -7
- package/sohl/default-item-art.mjs +14 -3
- package/sohl/document-subtypes.mjs +16 -10
- package/sohl/item-builders.mjs +14 -5
- package/sohl/item-fields.mjs +68 -7
- package/sohl/items.mjs +44 -258
- package/sohl/note-schemas.mjs +2 -2
- package/types/content-config.d.mts +21 -42
- package/types/engine/actor-compiler.d.mts +204 -0
- package/types/engine/address-charset.d.mts +11 -8
- package/types/engine/address-diff.d.mts +53 -5
- package/types/engine/anchored-sections.d.mts +21 -0
- package/types/engine/anchors.d.mts +20 -0
- package/types/engine/base-compiler.d.mts +17 -17
- package/types/engine/bundle-notes.d.mts +173 -0
- package/types/engine/bundles.d.mts +60 -0
- package/types/engine/code-fences.d.mts +43 -0
- package/types/engine/compile-corpus.d.mts +32 -0
- package/types/engine/content-address.d.mts +205 -5
- package/types/engine/content-format-check.d.mts +6 -2
- package/types/engine/content-format.d.mts +57 -1
- package/types/engine/content-index.d.mts +64 -80
- package/types/engine/content-links.d.mts +52 -7
- package/types/engine/content-lint.d.mts +10 -1
- package/types/engine/content-package.d.mts +2 -1
- package/types/engine/content-tables.d.mts +21 -39
- package/types/engine/document-subtypes.d.mts +37 -3
- package/types/engine/field-spec.d.mts +76 -5
- package/types/engine/folder-notes.d.mts +159 -0
- package/types/engine/foreign-catalog.d.mts +53 -0
- package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +16 -38
- package/types/engine/frontmatter-lint.d.mts +10 -2
- package/types/engine/frontmatter.d.mts +64 -0
- package/types/engine/generate.d.mts +38 -0
- package/types/engine/helpers.d.mts +94 -30
- package/types/engine/ids.d.mts +96 -0
- package/types/engine/index-records.d.mts +68 -0
- package/types/engine/index.d.mts +9 -3
- package/types/engine/item-compiler.d.mts +131 -0
- package/types/engine/journals.d.mts +47 -9
- package/types/engine/metadata-index.d.mts +226 -0
- package/types/engine/note-claims.d.mts +57 -11
- package/types/engine/note-ids.d.mts +38 -0
- package/types/engine/note-renames.d.mts +102 -0
- package/types/engine/note-vocabulary.d.mts +44 -8
- package/types/engine/retired-fields.d.mts +75 -0
- package/types/engine/scenes.d.mts +3 -2
- package/types/engine/schema-check.d.mts +25 -4
- package/types/engine/site-build.d.mts +4 -4
- package/types/engine/site-index.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +185 -0
- package/types/engine/subtype-registry.d.mts +49 -0
- package/types/engine/system-block.d.mts +40 -1
- package/types/engine/systems.d.mts +106 -0
- package/types/engine/web-wikilinks.d.mts +4 -2
- package/types/engine/wikilink-syntax.d.mts +10 -3
- package/types/engine/wikilinks.d.mts +41 -13
- package/types/engine/yaml-lint.d.mts +107 -0
- package/types/hm3/actors.d.mts +48 -0
- package/types/hm3/default-item-art.d.mts +42 -0
- package/types/hm3/document-subtypes.d.mts +24 -0
- package/types/hm3/index.d.mts +7 -0
- package/types/hm3/item-builders.d.mts +11 -0
- package/types/hm3/item-fields.d.mts +12 -0
- package/types/hm3/items.d.mts +23 -0
- package/types/hm3/template-priority.d.mts +21 -0
- package/types/manifest.d.mts +22 -1
- package/types/release.d.mts +9 -4
- package/types/sohl/actors.d.mts +7 -74
- package/types/sohl/being-info.d.mts +8 -27
- package/types/sohl/default-item-art.d.mts +5 -3
- package/types/sohl/items.d.mts +17 -32
- package/engine/foreign-manifests.mjs +0 -126
- package/engine/kb-manifest.mjs +0 -490
- package/types/engine/foreign-manifests.d.mts +0 -43
- package/types/engine/kb-manifest.d.mts +0 -241
package/engine/content-lint.mjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* disagree without anything detecting it, which the canonical-separator
|
|
23
23
|
* handling already did once on each side.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
25
|
+
* Four rules, all about a note's identity:
|
|
26
26
|
*
|
|
27
27
|
* 1. **Shape** — a `shortcode` is strictly ASCII-alphanumeric. It is the
|
|
28
28
|
* identity key referenced from saved world data, and it is half of the
|
|
@@ -34,6 +34,11 @@
|
|
|
34
34
|
* reason the other two do: it is a statement about which note holds which
|
|
35
35
|
* address, it needs no `site:` configuration to decide, and a package with
|
|
36
36
|
* no front page is misconfigured whether or not anyone runs a site build.
|
|
37
|
+
* 4. **Vacated addresses** — a `renamedFrom:` entry names an address this note
|
|
38
|
+
* used to hold and nothing holds now (#278). It is the same statement as
|
|
39
|
+
* rule 2 read backwards, and it needs the same whole-tree view: an entry can
|
|
40
|
+
* only be checked against every *other* note's address, and two notes
|
|
41
|
+
* claiming one predecessor is the uniqueness rule applied to the past.
|
|
37
42
|
*
|
|
38
43
|
* **Nothing here writes.** A check reports and an author fixes.
|
|
39
44
|
*
|
|
@@ -61,8 +66,11 @@ import path from "node:path";
|
|
|
61
66
|
|
|
62
67
|
import { ADDRESS_SEGMENT_PATTERN } from "./address-charset.mjs";
|
|
63
68
|
import { positionInFrontmatter } from "./diagnostics.mjs";
|
|
64
|
-
import {
|
|
69
|
+
import { assertStatedScope } from "./helpers.mjs";
|
|
70
|
+
// The corpus, read from the one pass that derives it (#243).
|
|
71
|
+
import { authoredFrontmatter, indexRecordsFor, isNoteRecord, noteFile } from "./content-index.mjs";
|
|
65
72
|
import { checkHomepageCount, isHomepage } from "./homepage.mjs";
|
|
73
|
+
import { declaresRenamedFrom, renamedFrom, renamedFromEntries } from "./note-renames.mjs";
|
|
66
74
|
|
|
67
75
|
/**
|
|
68
76
|
* The shape every `shortcode` must match: ASCII letters and digits only.
|
|
@@ -98,33 +106,164 @@ export function isValidShortcode(value) {
|
|
|
98
106
|
}
|
|
99
107
|
|
|
100
108
|
/**
|
|
101
|
-
* Collect the notes a lint pass reasons about.
|
|
109
|
+
* Collect the notes a lint pass reasons about, from the content index.
|
|
102
110
|
*
|
|
103
111
|
* Only notes carrying a `type` are content notes. Vault scaffolding —
|
|
104
112
|
* `Templates/`, a `README`, a repository's own `CLAUDE.md` — has no type, is
|
|
105
113
|
* neither addressed nor addressable, and would fail rules it can never satisfy.
|
|
106
114
|
*
|
|
115
|
+
* **Read from the index, not from a walk of this pass's own** (#243). The
|
|
116
|
+
* `lint` command already derives the index — its link check and its `sql`
|
|
117
|
+
* tables are built from it — and then walked the tree a second time to get
|
|
118
|
+
* here, so one command held two answers to "which files are the corpus?" and
|
|
119
|
+
* compared findings drawn from both. It now holds one: the records are derived
|
|
120
|
+
* once by the command and handed to every pass, this one included.
|
|
121
|
+
*
|
|
122
|
+
* The frontmatter is the note's own, recovered with
|
|
123
|
+
* {@link module:engine/content-index.authoredFrontmatter} — a lint of what an
|
|
124
|
+
* author wrote must not be handed the keys the index derived, or it would
|
|
125
|
+
* report `address:` and `anchors:` as fields nobody may write.
|
|
126
|
+
*
|
|
107
127
|
* @param {string} contentBase - Root of the content tree.
|
|
108
128
|
* @param {object} [opts]
|
|
109
|
-
* @param {readonly string[]} [opts.skipDirectories] -
|
|
129
|
+
* @param {readonly string[]} [opts.skipDirectories] - The corpus scope, stated
|
|
130
|
+
* by the caller.
|
|
131
|
+
* @param {object} [opts.config] - The resolved build configuration.
|
|
132
|
+
* @param {readonly object[]} [opts.records] - Records the caller derived.
|
|
133
|
+
* @param {object[]} [opts.problems] - Collects the notes the index cannot
|
|
134
|
+
* record, so one of them does not silence the lint.
|
|
110
135
|
* @returns {Array<{fm: object, absPath: string, file: string}>} The notes, in
|
|
111
136
|
* path order so findings read top to bottom.
|
|
112
137
|
*/
|
|
113
|
-
function collectNotes(contentBase, { skipDirectories } = {}) {
|
|
138
|
+
function collectNotes(contentBase, { skipDirectories, config, records, problems } = {}) {
|
|
114
139
|
const notes = [];
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
140
|
+
const corpus =
|
|
141
|
+
records ??
|
|
142
|
+
(assertStatedScope(skipDirectories, "collectNotes"),
|
|
143
|
+
fs.existsSync(contentBase) ?
|
|
144
|
+
indexRecordsFor({ contentBase, config, skipDirectories, problems })
|
|
145
|
+
: []);
|
|
146
|
+
|
|
147
|
+
for (const record of corpus) {
|
|
148
|
+
// A documentation journal is a document this tree emits, not a note in
|
|
149
|
+
// it: it has no authored frontmatter for a lint to reason about.
|
|
150
|
+
if (!isNoteRecord(record) || !record.type) continue;
|
|
151
|
+
const absPath = noteFile(contentBase, record);
|
|
118
152
|
notes.push({
|
|
119
|
-
fm,
|
|
153
|
+
fm: authoredFrontmatter(record),
|
|
120
154
|
absPath,
|
|
121
155
|
file: path.relative(process.cwd(), absPath),
|
|
122
156
|
});
|
|
123
157
|
}
|
|
158
|
+
// The records already come in content-path order, so this only re-states
|
|
159
|
+
// the guarantee findings depend on: they read top to bottom.
|
|
124
160
|
notes.sort((a, b) => (a.absPath < b.absPath ? -1 : 1));
|
|
125
161
|
return notes;
|
|
126
162
|
}
|
|
127
163
|
|
|
164
|
+
/**
|
|
165
|
+
* What one note's `renamedFrom:` says, checked against itself (#278).
|
|
166
|
+
*
|
|
167
|
+
* The entries a note can be wrong about on its own: a value that is not a
|
|
168
|
+
* shortcode, one naming the address the note holds *now*, one written twice.
|
|
169
|
+
* Every one of them is silent without a check — a malformed entry is skipped by
|
|
170
|
+
* the diff, so the author who wrote it sees the rename they were trying to
|
|
171
|
+
* announce reported as a withdrawal anyway, with nothing saying why.
|
|
172
|
+
*
|
|
173
|
+
* The cross-note questions are not here, because one note cannot answer them:
|
|
174
|
+
* whether an entry names an address some *other* note still publishes, and
|
|
175
|
+
* whether two notes claim one predecessor, both need the whole tree and are
|
|
176
|
+
* asked in {@link lintContentTree} once it has one.
|
|
177
|
+
*
|
|
178
|
+
* @param {object} note - The note, as {@link collectNotes} yields it.
|
|
179
|
+
* @param {() => string} raw - Reads the file, deferred so a clean note costs
|
|
180
|
+
* nothing.
|
|
181
|
+
* @returns {Array<object>} The findings.
|
|
182
|
+
*/
|
|
183
|
+
function checkRenamedFrom({ fm, file }, raw) {
|
|
184
|
+
if (!declaresRenamedFrom(fm)) return [];
|
|
185
|
+
const findings = [];
|
|
186
|
+
const at = (value) => positionInFrontmatter(raw(), "renamedFrom", value);
|
|
187
|
+
const shortcode = typeof fm.shortcode === "string" ? fm.shortcode.trim() : "";
|
|
188
|
+
|
|
189
|
+
// A rename is a statement about where this note's address moved *to*, so a
|
|
190
|
+
// note with no address of its own has made no such statement. Reported
|
|
191
|
+
// before the entries: telling the author their entries are fine would be
|
|
192
|
+
// the less useful half of the answer.
|
|
193
|
+
if (!shortcode) {
|
|
194
|
+
return [
|
|
195
|
+
{
|
|
196
|
+
file,
|
|
197
|
+
...at(undefined),
|
|
198
|
+
severity: "error",
|
|
199
|
+
message:
|
|
200
|
+
"`renamedFrom` names the address this note used to hold, " +
|
|
201
|
+
"but the note declares no `shortcode`, so it holds none now " +
|
|
202
|
+
"and nothing was renamed",
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const seen = new Set();
|
|
208
|
+
for (const entry of renamedFromEntries(fm)) {
|
|
209
|
+
// Not `String(entry)`: the point is that the author wrote something
|
|
210
|
+
// that is not a shortcode, and rendering a list as `a,b` would show
|
|
211
|
+
// them a string they never typed.
|
|
212
|
+
if (typeof entry !== "string" || !entry.trim()) {
|
|
213
|
+
findings.push({
|
|
214
|
+
file,
|
|
215
|
+
...at(undefined),
|
|
216
|
+
severity: "error",
|
|
217
|
+
message:
|
|
218
|
+
`\`renamedFrom\` takes shortcodes, and one entry is ` +
|
|
219
|
+
`${entry === "" || (typeof entry === "string" && !entry.trim()) ? "blank" : `a ${typeof entry}`}; ` +
|
|
220
|
+
`it is skipped, so the rename it was meant to announce is ` +
|
|
221
|
+
`still reported as a withdrawal`,
|
|
222
|
+
});
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const value = entry.trim();
|
|
226
|
+
if (!isValidShortcode(value)) {
|
|
227
|
+
findings.push({
|
|
228
|
+
file,
|
|
229
|
+
...at(value),
|
|
230
|
+
severity: "error",
|
|
231
|
+
message:
|
|
232
|
+
`\`renamedFrom: ${value}\` is not strictly alphanumeric, so ` +
|
|
233
|
+
`it is not an address this package ever published — a ` +
|
|
234
|
+
`shortcode is held to one charset whether it is current or past`,
|
|
235
|
+
});
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (value === shortcode) {
|
|
239
|
+
findings.push({
|
|
240
|
+
file,
|
|
241
|
+
...at(value),
|
|
242
|
+
severity: "error",
|
|
243
|
+
message:
|
|
244
|
+
`\`renamedFrom: ${value}\` is this note's own shortcode, so ` +
|
|
245
|
+
`it declares a rename from itself; name the shortcode it ` +
|
|
246
|
+
`was published under before, or drop the key`,
|
|
247
|
+
});
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (seen.has(value)) {
|
|
251
|
+
findings.push({
|
|
252
|
+
file,
|
|
253
|
+
...at(value),
|
|
254
|
+
// The declaration still works — the reader de-duplicates — so
|
|
255
|
+
// this is tidiness, and failing a build over it would red a
|
|
256
|
+
// tree whose renames are all correctly announced.
|
|
257
|
+
severity: "warning",
|
|
258
|
+
message: `\`renamedFrom: ${value}\` is listed twice; the repeat says nothing new`,
|
|
259
|
+
});
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
seen.add(value);
|
|
263
|
+
}
|
|
264
|
+
return findings;
|
|
265
|
+
}
|
|
266
|
+
|
|
128
267
|
/**
|
|
129
268
|
* Lint every address in a content tree.
|
|
130
269
|
*
|
|
@@ -134,26 +273,58 @@ function collectNotes(contentBase, { skipDirectories } = {}) {
|
|
|
134
273
|
* ignores. Defaults to the configured list.
|
|
135
274
|
* @param {string} [opts.contentPackage] - The package this tree builds, for the
|
|
136
275
|
* homepage rule. Dropped from that finding when unknown rather than guessed.
|
|
276
|
+
* @param {object} [opts.config] - The resolved build configuration, which the
|
|
277
|
+
* corpus is derived against.
|
|
278
|
+
* @param {readonly object[]} [opts.records] - Index records the caller already
|
|
279
|
+
* derived, so a command reads one corpus (#243).
|
|
280
|
+
* @param {object[]} [opts.problems] - Collects the notes the index cannot
|
|
281
|
+
* record, instead of letting one of them silence the lint.
|
|
137
282
|
* @returns {{findings: Array<{file: string, line?: number, column?: number,
|
|
138
283
|
* severity: "error"|"warning", message: string}>, notes: number,
|
|
139
284
|
* keys: number}} The findings, and what was inspected to produce them.
|
|
140
285
|
*/
|
|
141
|
-
export function lintContentTree(
|
|
286
|
+
export function lintContentTree(
|
|
287
|
+
contentBase,
|
|
288
|
+
{ skipDirectories, contentPackage, config, records, problems } = {},
|
|
289
|
+
) {
|
|
142
290
|
const findings = [];
|
|
143
|
-
const notes = collectNotes(contentBase, { skipDirectories });
|
|
291
|
+
const notes = collectNotes(contentBase, { skipDirectories, config, records, problems });
|
|
144
292
|
|
|
145
293
|
/** @type {Map<string, Array<{file: string, absPath: string}>>} */
|
|
146
294
|
const byKey = new Map();
|
|
295
|
+
/** @type {Map<string, Array<{file: string, absPath: string, shortcode: string}>>} */
|
|
296
|
+
const claimedPredecessors = new Map();
|
|
147
297
|
|
|
148
|
-
for (const
|
|
298
|
+
for (const note of notes) {
|
|
299
|
+
const { fm, absPath, file } = note;
|
|
149
300
|
const shortcode = fm.shortcode;
|
|
150
|
-
// Folder documents and keyless entries carry no address at all.
|
|
151
|
-
if (!shortcode) continue;
|
|
152
301
|
|
|
153
302
|
// Read only when there is something to say about the note, so a clean
|
|
154
303
|
// tree costs one pass rather than two.
|
|
155
304
|
const raw = () => fs.readFileSync(absPath, "utf8");
|
|
156
305
|
|
|
306
|
+
// Before the keyless `continue` below, because a note declaring a
|
|
307
|
+
// rename while carrying no address of its own is exactly one of the
|
|
308
|
+
// things this reports — and reaching it after the skip would mean it
|
|
309
|
+
// never ran on the case that needs it most.
|
|
310
|
+
findings.push(...checkRenamedFrom(note, raw));
|
|
311
|
+
if (shortcode) {
|
|
312
|
+
// The de-duplicated reader, not the raw entries: a note that listed
|
|
313
|
+
// one predecessor twice has made one claim, and indexing it twice
|
|
314
|
+
// would make the note collide with itself and be reported as two
|
|
315
|
+
// notes claiming one address.
|
|
316
|
+
for (const value of renamedFrom(fm)) {
|
|
317
|
+
if (value === shortcode) continue;
|
|
318
|
+
const claim = `${fm.type}:${value}`;
|
|
319
|
+
const seen = claimedPredecessors.get(claim);
|
|
320
|
+
if (seen) seen.push({ file, absPath, shortcode });
|
|
321
|
+
else claimedPredecessors.set(claim, [{ file, absPath, shortcode }]);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Folder documents and keyless entries carry no address at all.
|
|
326
|
+
if (!shortcode) continue;
|
|
327
|
+
|
|
157
328
|
const key = `${fm.type}:${shortcode}`;
|
|
158
329
|
const seen = byKey.get(key);
|
|
159
330
|
if (seen) seen.push({ file, absPath });
|
|
@@ -230,5 +401,52 @@ export function lintContentTree(contentBase, { skipDirectories, contentPackage }
|
|
|
230
401
|
}
|
|
231
402
|
}
|
|
232
403
|
|
|
404
|
+
// The two questions about a declared rename that need the whole tree
|
|
405
|
+
// (#278). Both are the uniqueness rule above, applied to the past: an
|
|
406
|
+
// address has one holder, so it has one successor and it cannot be both
|
|
407
|
+
// vacated and occupied.
|
|
408
|
+
for (const [claim, claimants] of claimedPredecessors) {
|
|
409
|
+
const live = byKey.get(claim);
|
|
410
|
+
if (live) {
|
|
411
|
+
for (const { file, absPath } of claimants) {
|
|
412
|
+
findings.push({
|
|
413
|
+
file,
|
|
414
|
+
...positionInFrontmatter(
|
|
415
|
+
fs.readFileSync(absPath, "utf8"),
|
|
416
|
+
"renamedFrom",
|
|
417
|
+
claim.slice(claim.indexOf(":") + 1),
|
|
418
|
+
),
|
|
419
|
+
severity: "error",
|
|
420
|
+
message:
|
|
421
|
+
`\`renamedFrom\` claims "${claim}", which ` +
|
|
422
|
+
`${live.map((f) => f.file).join(", ")} still publishes; ` +
|
|
423
|
+
`that address was never vacated, so nothing was renamed ` +
|
|
424
|
+
`away from it`,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (claimants.length < 2) continue;
|
|
429
|
+
// Named on every claimant rather than once on the address, for the same
|
|
430
|
+
// reason a duplicate address is: each is a file an author has to open,
|
|
431
|
+
// and a finding naming only the address sends them hunting for the rest.
|
|
432
|
+
for (const { file, absPath, shortcode } of claimants) {
|
|
433
|
+
const others = claimants.filter((c) => c.file !== file);
|
|
434
|
+
findings.push({
|
|
435
|
+
file,
|
|
436
|
+
...positionInFrontmatter(
|
|
437
|
+
fs.readFileSync(absPath, "utf8"),
|
|
438
|
+
"renamedFrom",
|
|
439
|
+
claim.slice(claim.indexOf(":") + 1),
|
|
440
|
+
),
|
|
441
|
+
severity: "error",
|
|
442
|
+
message:
|
|
443
|
+
`"${claim}" is claimed as a predecessor by more than one ` +
|
|
444
|
+
`note — this one (now "${shortcode}") and ` +
|
|
445
|
+
`${others.map((c) => `${c.file} (now "${c.shortcode}")`).join(", ")}; ` +
|
|
446
|
+
`an address had one holder, so it has one successor`,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
233
451
|
return { findings, notes: notes.length, keys: byKey.size };
|
|
234
452
|
}
|
|
@@ -28,7 +28,8 @@ import { loadPackConfig } from "./pack-config.mjs";
|
|
|
28
28
|
* The **content** package: the distribution unit this repository's notes belong
|
|
29
29
|
* to, and the **address namespace** every one of them is published under.
|
|
30
30
|
*
|
|
31
|
-
* It is the first segment of every canonical key
|
|
31
|
+
* It is the first segment of every canonical key
|
|
32
|
+
* (`package-system-type-shortcode`, so `sohl-none-doc-gear`), the name
|
|
32
33
|
* of the link manifest this build emits (`sohl.json`), and the package a
|
|
33
34
|
* cross-package wikilink writes to reach one of these notes. So it is the
|
|
34
35
|
* repository's identity in the address space, not a switch — and never dead
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* TABLE WITHOUT ID
|
|
24
24
|
* link(file.path, name.full) AS "Name",
|
|
25
25
|
* sohl.weight AS "Weight"
|
|
26
|
-
* WHERE type = "
|
|
26
|
+
* WHERE type = "armor" and sohl.material = "Cloth"
|
|
27
27
|
* SORT name.full ASC
|
|
28
28
|
* ```
|
|
29
29
|
*
|
|
@@ -1087,11 +1087,42 @@ export function renderContentTable(spec, rows, linkable, self) {
|
|
|
1087
1087
|
* body can name an authored position (#17). An `errors` entry carries the
|
|
1088
1088
|
* 0-based line of the directive that failed, for the same reason.
|
|
1089
1089
|
*/
|
|
1090
|
+
/**
|
|
1091
|
+
* The `WHERE` clause of a query, as authored, for a message that has to name
|
|
1092
|
+
* what matched nothing.
|
|
1093
|
+
*
|
|
1094
|
+
* The parsed spec holds an expression tree, and rendering that back to text
|
|
1095
|
+
* would be a second dialect of the query language to keep in step. The authored
|
|
1096
|
+
* line is what the author wrote and what they will edit, so it is what the
|
|
1097
|
+
* finding quotes.
|
|
1098
|
+
*
|
|
1099
|
+
* @param {string} query - The fence's contents.
|
|
1100
|
+
* @returns {string} The clause, or "" when the query has none.
|
|
1101
|
+
*/
|
|
1102
|
+
function whereText(query) {
|
|
1103
|
+
const match = /^\s*WHERE\s+(.+?)\s*$/im.exec(String(query ?? ""));
|
|
1104
|
+
return match ? `\`${match[1]}\`` : "";
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1090
1107
|
export function expandContentTables(
|
|
1091
1108
|
markdown,
|
|
1092
|
-
{
|
|
1109
|
+
{
|
|
1110
|
+
docs = [],
|
|
1111
|
+
linkable = () => false,
|
|
1112
|
+
source = "",
|
|
1113
|
+
self = undefined,
|
|
1114
|
+
sqlTables = undefined,
|
|
1115
|
+
} = {},
|
|
1093
1116
|
) {
|
|
1094
1117
|
const errors = [];
|
|
1118
|
+
// How many `sql` directives have been seen, which is how a prepared
|
|
1119
|
+
// result is found: the passes disagree about a body's leading whitespace,
|
|
1120
|
+
// so a line number would not survive the trip, and an ordinal does.
|
|
1121
|
+
let sqlOrdinal = 0;
|
|
1122
|
+
// One entry per `dataview` directive still authored, so a caller can say how
|
|
1123
|
+
// much of the corpus is still on the retiring language without this pass
|
|
1124
|
+
// failing a build over it (#246).
|
|
1125
|
+
const warnings = [];
|
|
1095
1126
|
const lines = String(markdown ?? "").split("\n");
|
|
1096
1127
|
const out = [];
|
|
1097
1128
|
// Which authored line each emitted line came from, so a later pass can
|
|
@@ -1125,17 +1156,82 @@ export function expandContentTables(
|
|
|
1125
1156
|
const closer = new RegExp(`^[ \\t]*${marker[0]}{${marker.length},}[ \\t]*$`);
|
|
1126
1157
|
while (close < lines.length && !closer.test(lines[close])) close++;
|
|
1127
1158
|
const isQuery = /^dataview\b/i.test(info.trim());
|
|
1159
|
+
const isSql = /^sql\b/i.test(info.trim());
|
|
1128
1160
|
const block = lines.slice(i, Math.min(close + 1, lines.length));
|
|
1161
|
+
|
|
1162
|
+
// A `sql` directive was run before this pass began — DuckDB is async and
|
|
1163
|
+
// this is not (see `prepareSqlTables`). What is left here is splicing
|
|
1164
|
+
// the rendered table in at the directive's own position.
|
|
1165
|
+
if (isSql && close < lines.length) {
|
|
1166
|
+
const prepared = sqlTables?.[sqlOrdinal];
|
|
1167
|
+
sqlOrdinal += 1;
|
|
1168
|
+
const failure =
|
|
1169
|
+
!prepared ?
|
|
1170
|
+
"sql content table was not prepared — this pass was given no " +
|
|
1171
|
+
"`sqlTables`, so the query could not be run"
|
|
1172
|
+
: prepared.reason ? prepared.reason
|
|
1173
|
+
: prepared.rows === 0 && !prepared.allowEmpty ?
|
|
1174
|
+
"sql query selects no notes — write ```sql allow-empty if " + "that is intended"
|
|
1175
|
+
: undefined;
|
|
1176
|
+
if (failure) {
|
|
1177
|
+
errors.push({
|
|
1178
|
+
source,
|
|
1179
|
+
directive: block.join("\n"),
|
|
1180
|
+
reason: failure,
|
|
1181
|
+
line: i,
|
|
1182
|
+
column: indent.length + 1,
|
|
1183
|
+
});
|
|
1184
|
+
block.forEach((text, k) => emit(text, i + k));
|
|
1185
|
+
i = close;
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
if (out.length > 0 && out[out.length - 1].trim() !== "") emit("", i, true);
|
|
1189
|
+
for (const row of prepared.markdown.split("\n")) emit(`${indent}${row}`, i, true);
|
|
1190
|
+
if (close + 1 < lines.length && lines[close + 1].trim() !== "") emit("", i, true);
|
|
1191
|
+
i = close;
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1129
1195
|
if (!isQuery || close >= lines.length) {
|
|
1130
1196
|
block.forEach((text, k) => emit(text, i + k));
|
|
1131
1197
|
i = close;
|
|
1132
1198
|
continue;
|
|
1133
1199
|
}
|
|
1134
1200
|
const query = lines.slice(i + 1, close).join("\n");
|
|
1201
|
+
warnings.push({
|
|
1202
|
+
source,
|
|
1203
|
+
line: i,
|
|
1204
|
+
column: indent.length + 1,
|
|
1205
|
+
reason:
|
|
1206
|
+
"`dataview` content tables are being replaced by `sql` over the " +
|
|
1207
|
+
"content index (#246)",
|
|
1208
|
+
});
|
|
1209
|
+
// `dataview allow-empty` says a table selecting nothing is the intended
|
|
1210
|
+
// state. Spelled on the fence rather than in the query, because it is a
|
|
1211
|
+
// statement about this *directive* and not part of the query language.
|
|
1212
|
+
const allowEmpty = /\ballow-empty\b/i.test(info);
|
|
1135
1213
|
let table;
|
|
1136
1214
|
try {
|
|
1137
1215
|
const spec = parseDataviewQuery(query);
|
|
1138
1216
|
const rows = selectRows(spec, docs, self);
|
|
1217
|
+
// A table that selects nothing is almost always a stale query — a
|
|
1218
|
+
// renamed type, a retired category, a typo'd path — and publishing
|
|
1219
|
+
// a header with nothing under it makes that indistinguishable from
|
|
1220
|
+
// a category that is legitimately empty. Eight tables in `sohl`'s
|
|
1221
|
+
// `Rules/Bestiary.md` published that way for months after the
|
|
1222
|
+
// `creature` → `being` rename, and no build said a word (#223).
|
|
1223
|
+
if (rows.length === 0 && !allowEmpty) {
|
|
1224
|
+
errors.push({
|
|
1225
|
+
source,
|
|
1226
|
+
directive: block.join("\n"),
|
|
1227
|
+
reason:
|
|
1228
|
+
`dataview query selects no notes` +
|
|
1229
|
+
`${whereText(query) ? `: nothing matches ${whereText(query)}` : ""}` +
|
|
1230
|
+
` — write \`\`\`dataview allow-empty if that is intended`,
|
|
1231
|
+
line: i,
|
|
1232
|
+
column: indent.length + 1,
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1139
1235
|
table = renderContentTable(spec, rows, linkable, self);
|
|
1140
1236
|
} catch (err) {
|
|
1141
1237
|
errors.push({
|
|
@@ -1159,5 +1255,5 @@ export function expandContentTables(
|
|
|
1159
1255
|
}
|
|
1160
1256
|
i = close;
|
|
1161
1257
|
}
|
|
1162
|
-
return { markdown: out.join("\n"), errors, lineMap };
|
|
1258
|
+
return { markdown: out.join("\n"), errors, warnings, lineMap };
|
|
1163
1259
|
}
|
|
@@ -64,7 +64,10 @@
|
|
|
64
64
|
* @module
|
|
65
65
|
*/
|
|
66
66
|
|
|
67
|
-
import { assertTypeNotRetired } from "./ids.mjs";
|
|
67
|
+
import { assertTypeNotRetired, currentType } from "./ids.mjs";
|
|
68
|
+
import { NO_SYSTEM } from "./systems.mjs";
|
|
69
|
+
|
|
70
|
+
export { NO_SYSTEM };
|
|
68
71
|
import { locateFrontmatterKey } from "./retired-fields.mjs";
|
|
69
72
|
|
|
70
73
|
/**
|
|
@@ -199,6 +202,13 @@ function frozenRow(system, noteType, row) {
|
|
|
199
202
|
/**
|
|
200
203
|
* The row a system declares for a note type, or nothing.
|
|
201
204
|
*
|
|
205
|
+
* The **one** place a map is indexed, so the retirement window for a renamed
|
|
206
|
+
* type is honoured everywhere a row is asked for — {@link documentSubtype},
|
|
207
|
+
* {@link mapsNoteType}, {@link noteTypesFor}'s consumers and
|
|
208
|
+
* {@link systemOf} all arrive here. A note still spelling `armorgear` finds the
|
|
209
|
+
* `armor` row, compiles into the `armorgear` document it always did, and keeps
|
|
210
|
+
* the address it publishes at; only the *report* tells it to move (#78).
|
|
211
|
+
*
|
|
202
212
|
* @param {DocumentSubtypeMap} map - The system's map.
|
|
203
213
|
* @param {string|undefined} noteType - The note's declared `type`.
|
|
204
214
|
* @returns {Readonly<DocumentSubtypeRow>|undefined} The row, or `undefined`
|
|
@@ -206,7 +216,7 @@ function frozenRow(system, noteType, row) {
|
|
|
206
216
|
*/
|
|
207
217
|
export function subtypeRow(map, noteType) {
|
|
208
218
|
if (!noteType || typeof noteType !== "string") return undefined;
|
|
209
|
-
return map?.types?.[noteType];
|
|
219
|
+
return map?.types?.[currentType(noteType)];
|
|
210
220
|
}
|
|
211
221
|
|
|
212
222
|
/**
|
|
@@ -339,9 +349,17 @@ export function documentSubtype(map, noteType, fm, { file, absPath } = {}) {
|
|
|
339
349
|
* whole point of the issue.
|
|
340
350
|
*
|
|
341
351
|
* A **retired** spelling is refused by name before any of that. Without it a
|
|
342
|
-
* reference left behind by a
|
|
343
|
-
*
|
|
344
|
-
*
|
|
352
|
+
* reference left behind by a merge would take the unmapped fallback and address
|
|
353
|
+
* a document of the old name — resolving silently, which is precisely what a
|
|
354
|
+
* retirement exists to stop.
|
|
355
|
+
*
|
|
356
|
+
* A **renamed** spelling is the opposite case and resolves normally, through
|
|
357
|
+
* the same {@link currentType} every other lookup goes through (#78). It has to
|
|
358
|
+
* be this side as much as the note's own `type:`: the overwhelming majority of
|
|
359
|
+
* the 31,000 occurrences of the old names are `(type, shortcode)` references
|
|
360
|
+
* inside a being's `items:` list, so a window that resolved notes but not
|
|
361
|
+
* references would silently drop 30,000 embedded items rather than compile them
|
|
362
|
+
* unchanged.
|
|
345
363
|
*
|
|
346
364
|
* @param {DocumentSubtypeMap} map - The system's map.
|
|
347
365
|
* @param {string|undefined} noteType - The type the reference names.
|
|
@@ -361,7 +379,9 @@ export function referencedSubtype(map, noteType, document) {
|
|
|
361
379
|
|
|
362
380
|
const row = subtypeRow(map, noteType);
|
|
363
381
|
// No row: the type is the consumer's own, and its document is stamped with
|
|
364
|
-
// the note type. See the note on the unmapped fallback above.
|
|
382
|
+
// the note type. See the note on the unmapped fallback above. Returned as
|
|
383
|
+
// *authored*, not normalised — the Item pass stamps an unmapped document
|
|
384
|
+
// with `fm.type` verbatim, and the two answers have to agree.
|
|
365
385
|
if (!row) return { subType: noteType };
|
|
366
386
|
|
|
367
387
|
if (row.document !== document) {
|
|
@@ -438,3 +458,26 @@ function list(values) {
|
|
|
438
458
|
if (quoted.length <= 1) return quoted.join("");
|
|
439
459
|
return `${quoted.slice(0, -1).join(", ")} or ${quoted[quoted.length - 1]}`;
|
|
440
460
|
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Which system defines the document a note of this type compiles into.
|
|
464
|
+
*
|
|
465
|
+
* A journal, a macro, a scene and an item's documentation journal belong to no
|
|
466
|
+
* system and answer {@link NO_SYSTEM}; an item or an actor belongs to whichever
|
|
467
|
+
* system's map claims its note type.
|
|
468
|
+
*
|
|
469
|
+
* `maps` is required rather than defaulted, so this stays reachable from any
|
|
470
|
+
* pass: the registry of maps lives in `note-claims.mjs`, which imports half the
|
|
471
|
+
* engine, and defaulting to it here would drag that in wherever an address is
|
|
472
|
+
* built.
|
|
473
|
+
*
|
|
474
|
+
* @param {string} type - The note's `type`.
|
|
475
|
+
* @param {readonly object[]} maps - The document-subtype maps this build ships.
|
|
476
|
+
* @returns {string} The system id, or {@link NO_SYSTEM}.
|
|
477
|
+
*/
|
|
478
|
+
export function systemOf(type, maps) {
|
|
479
|
+
for (const map of maps ?? []) {
|
|
480
|
+
if (subtypeRow(map, type)) return map.system;
|
|
481
|
+
}
|
|
482
|
+
return NO_SYSTEM;
|
|
483
|
+
}
|
|
@@ -170,7 +170,7 @@ function workedExample(type, fields) {
|
|
|
170
170
|
// this example is the smallest note that compiles.
|
|
171
171
|
"id: <16-character id>",
|
|
172
172
|
"sohl:",
|
|
173
|
-
"
|
|
173
|
+
" templatePriority: null",
|
|
174
174
|
];
|
|
175
175
|
for (const field of required) {
|
|
176
176
|
lines.push(` ${field.name}: <${field.shape ?? "value"}>`);
|