@heroiclands/package-build 20.0.0 → 20.2.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 +476 -0
- package/CONTENT.md +185 -25
- package/README.md +43 -4
- package/bin/content-build.mjs +87 -2
- package/config.mjs +9 -1
- package/content-config.mjs +89 -18
- package/e2e.mjs +297 -3
- package/engine/content-charset.mjs +434 -0
- package/engine/content-icons.mjs +388 -0
- package/engine/foreign-catalog.mjs +109 -7
- package/engine/frontmatter-lint.mjs +191 -29
- package/engine/generate.mjs +5 -2
- package/engine/helpers.mjs +10 -1
- package/engine/index.mjs +6 -0
- package/engine/note-claims.mjs +208 -5
- package/engine/pack-config.mjs +102 -12
- package/engine/pack-router.mjs +0 -0
- package/engine/prose-config.mjs +42 -0
- package/engine/prose-lint.mjs +126 -0
- package/engine/schema-extract.mjs +13 -0
- package/package.json +1 -1
- package/types/config.d.mts +7 -0
- package/types/e2e.d.mts +130 -3
- package/types/engine/content-charset.d.mts +127 -0
- package/types/engine/content-icons.d.mts +151 -0
- package/types/engine/foreign-catalog.d.mts +38 -2
- package/types/engine/frontmatter-lint.d.mts +146 -28
- package/types/engine/helpers.d.mts +8 -0
- package/types/engine/index.d.mts +2 -0
- package/types/engine/note-claims.d.mts +67 -0
- package/types/engine/pack-config.d.mts +35 -0
- package/types/engine/prose-config.d.mts +41 -0
- package/types/engine/prose-lint.d.mts +36 -0
|
@@ -121,24 +121,152 @@ export const UNIVERSAL_KEYS = Object.freeze(
|
|
|
121
121
|
* The system blocks a build checks, and what each accepts beyond the shared
|
|
122
122
|
* vocabulary.
|
|
123
123
|
*
|
|
124
|
-
* One entry, because one system is what every existing tree declares — and
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
124
|
+
* One entry, because one system is what every existing tree declares — and it
|
|
125
|
+
* is a *fallback*, not the rule. {@link systemBlocksFor} derives the map from
|
|
126
|
+
* the configuration, which is what makes the block a package actually ships for
|
|
127
|
+
* the block that gets checked; this is what a caller holding no configuration
|
|
128
|
+
* gets, which in practice is a unit test.
|
|
129
|
+
*
|
|
130
|
+
* A block nothing declares is not checked, because nothing can say what it may
|
|
131
|
+
* carry, and inventing a rule for it would report a correct tree red.
|
|
132
|
+
*
|
|
133
|
+
* Three ways a block may state its vocabulary, and a spec declares at most one:
|
|
134
|
+
*
|
|
135
|
+
* - `known` — an explicit list of keys, for a caller stating them outright.
|
|
136
|
+
* - `fieldVocabulary` — the note type's own declared field names, as the
|
|
137
|
+
* caller's `schemas` state them, are keys of this block. That holds for the
|
|
138
|
+
* **one** system a single-registry tree ships for, where `schemas` *is* that
|
|
139
|
+
* system's vocabulary, and in general holds for no other.
|
|
140
|
+
* - `fields` — type → that system's own declared fields, from the registry the
|
|
141
|
+
* system declares. What a second system's block is checked against, because a
|
|
142
|
+
* second system's notes write a second system's fields and the note-type
|
|
143
|
+
* schemas describe somebody else's.
|
|
144
|
+
*
|
|
145
|
+
* @type {Readonly<Record<string, SystemBlockSpec>>}
|
|
137
146
|
*/
|
|
138
147
|
export const DEFAULT_SYSTEM_BLOCKS = Object.freeze({
|
|
139
148
|
sohl: Object.freeze({ fieldVocabulary: true }),
|
|
140
149
|
});
|
|
141
150
|
|
|
151
|
+
/**
|
|
152
|
+
* What one system block accepts beyond the keys every block carries.
|
|
153
|
+
*
|
|
154
|
+
* @typedef {object} SystemBlockSpec
|
|
155
|
+
* @property {readonly string[]} [known] - Keys stated outright.
|
|
156
|
+
* @property {boolean} [fieldVocabulary] - Whether the note type's declared field
|
|
157
|
+
* names, as the caller's `schemas` state them, are keys of this block.
|
|
158
|
+
* @property {Readonly<Record<string, readonly object[]>>} [fields] - Type → this
|
|
159
|
+
* system's own declared fields. A type it does not name is a type this system
|
|
160
|
+
* says nothing about, and its block is left unchecked on such a note rather
|
|
161
|
+
* than reported wholesale.
|
|
162
|
+
*/
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Every system a configuration says its tree carries (#58).
|
|
166
|
+
*
|
|
167
|
+
* **Which systems a package ships for is already declared**, in three places
|
|
168
|
+
* that answer different questions, so this reads all three rather than asking a
|
|
169
|
+
* new one:
|
|
170
|
+
*
|
|
171
|
+
* - `systems:` (#48) declares them without requiring one, which is how a
|
|
172
|
+
* package ships for several;
|
|
173
|
+
* - a **pack's** `system:` is the same statement made per pack, and it is the
|
|
174
|
+
* one some trees make: `harn-ensemble` declares an `actors-sohl` and an
|
|
175
|
+
* `actors-hm3` and nothing else about either system. It is already
|
|
176
|
+
* authoritative elsewhere — `eligibleFor` fails a note for want of the block
|
|
177
|
+
* a pack's `system:` names — so a lint that did not read it would refuse a
|
|
178
|
+
* note at compile for a block it never checked;
|
|
179
|
+
* - `stats.systemId` is the package-wide answer where there is one, and it has
|
|
180
|
+
* already absorbed every way of spelling that: a system package is its own
|
|
181
|
+
* system, and a module takes `requiresSystem`, its lone `systems:` entry, or
|
|
182
|
+
* its lone system relationship.
|
|
183
|
+
*
|
|
184
|
+
* A package naming a system in none of them is system-agnostic on purpose — its
|
|
185
|
+
* packs are core document types carrying no system data — so it carries no
|
|
186
|
+
* system block and naming one would invent it.
|
|
187
|
+
*
|
|
188
|
+
* @param {object} [config] - A resolved configuration from `defineConfig`.
|
|
189
|
+
* @returns {string[]} The system ids, deduplicated, in declared order.
|
|
190
|
+
*/
|
|
191
|
+
export function declaredSystems(config) {
|
|
192
|
+
const out = [];
|
|
193
|
+
for (const system of Object.keys(config?.systems ?? {})) {
|
|
194
|
+
if (!out.includes(system)) out.push(system);
|
|
195
|
+
}
|
|
196
|
+
for (const pack of config?.packs ?? []) {
|
|
197
|
+
const system = pack?.system;
|
|
198
|
+
if (typeof system === "string" && system && !out.includes(system)) out.push(system);
|
|
199
|
+
}
|
|
200
|
+
if (out.length) return out;
|
|
201
|
+
const packageWide = config?.stats?.systemId;
|
|
202
|
+
return typeof packageWide === "string" && packageWide ? [packageWide] : [];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The system blocks a configuration says its tree carries, and what each
|
|
207
|
+
* accepts (#58).
|
|
208
|
+
*
|
|
209
|
+
* The lint checks the blocks its caller names, and for as long as there was one
|
|
210
|
+
* system the only caller named none — so every tree took the `sohl:` of
|
|
211
|
+
* {@link DEFAULT_SYSTEM_BLOCKS}, a constant, in a module whose whole discipline
|
|
212
|
+
* is that it states no vocabulary of its own. That is wrong in both directions
|
|
213
|
+
* the moment a second system exists, and the second direction is the worse:
|
|
214
|
+
*
|
|
215
|
+
* - a package shipping for `hm3` had its `hm3:` block **never looked at**, so
|
|
216
|
+
* every key in it was discarded at compile without a word — the silent-drop
|
|
217
|
+
* family this check exists to close;
|
|
218
|
+
* - and the block that *was* checked was named after a system that package does
|
|
219
|
+
* not ship for, so the one finding it could make was about nothing.
|
|
220
|
+
*
|
|
221
|
+
* **A block's vocabulary has two sources, and a system may have both.**
|
|
222
|
+
*
|
|
223
|
+
* - The **note schemas** the caller hands in as `schemas`. Those belong to one
|
|
224
|
+
* system — the CLI imports `sohl/note-schemas.mjs` — and `schemaSystem` is the
|
|
225
|
+
* caller naming which, because only the caller knows. It is the only source
|
|
226
|
+
* that reaches a type no item registry declares, which is to say `being`: the
|
|
227
|
+
* 2,512 notes `harn-ensemble` is made of, and the reason this is not an
|
|
228
|
+
* optional refinement.
|
|
229
|
+
* - The system's **own registry**, `itemFieldsBySystem`, keyed by system and
|
|
230
|
+
* until now read by nothing. This is what a *second* system's block is held
|
|
231
|
+
* to, since the note schemas describe its neighbour.
|
|
232
|
+
*
|
|
233
|
+
* A system with neither is left out: nothing can state what its block may
|
|
234
|
+
* carry, and holding it to an empty vocabulary would report every key in a
|
|
235
|
+
* correct tree. **That is a check that does not run**, which is
|
|
236
|
+
* indistinguishable from one that passed, so the caller says it out loud —
|
|
237
|
+
* {@link declaredSystems} is the other half of that comparison. `harn-ensemble`
|
|
238
|
+
* is the tree it names: two systems, and an `itemBuilders` registry for
|
|
239
|
+
* neither, so its `hm3:` block is unchecked until it declares one.
|
|
240
|
+
*
|
|
241
|
+
* An earlier draft of this took the note schemas for a system's vocabulary only
|
|
242
|
+
* where the package declared **one** system, on the reasoning that with several
|
|
243
|
+
* there is nothing to say which one they describe. There is: the caller, which
|
|
244
|
+
* chose them. The guess cost `harn-ensemble` its whole `sohl:` check — two
|
|
245
|
+
* systems declared, so the fallback never fired — which is the coverage this
|
|
246
|
+
* change exists to widen rather than narrow.
|
|
247
|
+
*
|
|
248
|
+
* @param {object} [config] - A resolved configuration from `defineConfig`.
|
|
249
|
+
* @param {object} [options] - Options.
|
|
250
|
+
* @param {string} [options.schemaSystem] - The system whose vocabulary the
|
|
251
|
+
* caller's `schemas` state. There are two systems, not an open set, so this is
|
|
252
|
+
* one word from the caller rather than a mechanism.
|
|
253
|
+
* @returns {Readonly<Record<string, SystemBlockSpec>>} The blocks to check, in
|
|
254
|
+
* declared order. A system nothing states the vocabulary of is absent.
|
|
255
|
+
*/
|
|
256
|
+
export function systemBlocksFor(config, { schemaSystem } = {}) {
|
|
257
|
+
const byName = config?.itemFieldsBySystem ?? {};
|
|
258
|
+
/** @type {Record<string, SystemBlockSpec>} */
|
|
259
|
+
const blocks = {};
|
|
260
|
+
for (const system of declaredSystems(config)) {
|
|
261
|
+
/** @type {SystemBlockSpec} */
|
|
262
|
+
const spec = {};
|
|
263
|
+
if (system === schemaSystem) spec.fieldVocabulary = true;
|
|
264
|
+
if (byName[system]) spec.fields = byName[system];
|
|
265
|
+
if (Object.keys(spec).length) blocks[system] = Object.freeze(spec);
|
|
266
|
+
}
|
|
267
|
+
return Object.freeze(blocks);
|
|
268
|
+
}
|
|
269
|
+
|
|
142
270
|
/**
|
|
143
271
|
* Edit distance, capped — enough to answer "did you mean".
|
|
144
272
|
*
|
|
@@ -631,6 +759,27 @@ const ART_FIELDS = Object.freeze([
|
|
|
631
759
|
* @returns {Set<string>} The in-block keys that are not the note-level field of
|
|
632
760
|
* the same name.
|
|
633
761
|
*/
|
|
762
|
+
/**
|
|
763
|
+
* The keys a field declaration is authored at **inside a system block**.
|
|
764
|
+
*
|
|
765
|
+
* The first segment of each field's in-block key: `impact.die` is authored as
|
|
766
|
+
* `impact`, and a field whose shared source moved under `data:` is authored at
|
|
767
|
+
* the `legacyKey` it declares rather than at its dotted name (#305). Keying on
|
|
768
|
+
* the name instead would report `sohl.species` as a property no `being` has,
|
|
769
|
+
* against exactly the notes the sweep has not reached yet.
|
|
770
|
+
*
|
|
771
|
+
* Written once and read twice: the note type's own declaration answers for the
|
|
772
|
+
* system whose vocabulary the caller's `schemas` are, and a second system's
|
|
773
|
+
* registry answers for its block (#58). Two derivations of one thing would be
|
|
774
|
+
* free to disagree about which position a note authors.
|
|
775
|
+
*
|
|
776
|
+
* @param {readonly object[]|null|undefined} schema - A type's declarations.
|
|
777
|
+
* @returns {Set<string>} The in-block keys.
|
|
778
|
+
*/
|
|
779
|
+
function inBlockKeys(schema) {
|
|
780
|
+
return new Set(authoredFields(schema ?? []).map((f) => legacyKeyOf(f).split(".")[0]));
|
|
781
|
+
}
|
|
782
|
+
|
|
634
783
|
function collidingBlockKeys(schema) {
|
|
635
784
|
const keys = new Set();
|
|
636
785
|
if (!Array.isArray(schema)) return keys;
|
|
@@ -778,9 +927,11 @@ function checkEmbeddedShortcodes(note, blockName) {
|
|
|
778
927
|
* Supplied by the caller like `schemas`, so this module states no list of
|
|
779
928
|
* iconless types of its own; absent it, an inert `img:` goes unreported
|
|
780
929
|
* rather than every note's being (#349).
|
|
781
|
-
* @param {Readonly<Record<string,
|
|
782
|
-
* The system blocks to check, and what each accepts.
|
|
783
|
-
*
|
|
930
|
+
* @param {Readonly<Record<string, SystemBlockSpec>>} [opts.systems]
|
|
931
|
+
* The system blocks to check, and what each accepts. Supplied by the caller
|
|
932
|
+
* for the same reason `schemas` is — a build derives them from its
|
|
933
|
+
* configuration through {@link systemBlocksFor}, and this module states no
|
|
934
|
+
* system name of its own. See {@link DEFAULT_SYSTEM_BLOCKS} for the fallback.
|
|
784
935
|
* @param {readonly string[]} [opts.packs] - The pack names this package
|
|
785
936
|
* declares, for a `data:` field whose map is keyed by pack. Supplied by the
|
|
786
937
|
* caller like `schemas` and `vocabulary`, and absent it no claim is made
|
|
@@ -1169,15 +1320,8 @@ export function lintNote(
|
|
|
1169
1320
|
}
|
|
1170
1321
|
|
|
1171
1322
|
const fields = authoredFields(schema);
|
|
1172
|
-
/**
|
|
1173
|
-
|
|
1174
|
-
* `impact.die` is authored as `impact`, and a field whose shared source
|
|
1175
|
-
* moved under `data:` is authored at the `legacyKey` it declares rather
|
|
1176
|
-
* than at its dotted name (#305). Keying this on the name would report
|
|
1177
|
-
* `sohl.species` as a property no `being` has, against exactly the notes
|
|
1178
|
-
* the sweep has not reached yet.
|
|
1179
|
-
*/
|
|
1180
|
-
const declared = new Set(fields.map((f) => legacyKeyOf(f).split(".")[0]));
|
|
1323
|
+
/** The keys this type's own declaration is authored at inside a block. */
|
|
1324
|
+
const declared = inBlockKeys(schema);
|
|
1181
1325
|
|
|
1182
1326
|
// The retired spelling of a field this type declares → what to write now.
|
|
1183
1327
|
// Built from the type's own vocabulary, so a renamed field is retired
|
|
@@ -1209,16 +1353,33 @@ export function lintNote(
|
|
|
1209
1353
|
// Every declared system's block, each against its own vocabulary (#58). A
|
|
1210
1354
|
// block carries the shared keys any system's does — `system`, `type`,
|
|
1211
1355
|
// `img`, `effects`, `flags`, `pack` — plus whatever that system declares:
|
|
1212
|
-
//
|
|
1213
|
-
//
|
|
1356
|
+
// the note type's own field names for the system those schemas describe,
|
|
1357
|
+
// and a second system's own registry for its block. Which systems arrive
|
|
1358
|
+
// here is the configuration's answer, not this module's; see
|
|
1359
|
+
// {@link systemBlocksFor}.
|
|
1214
1360
|
for (const [blockName, spec] of Object.entries(systems ?? {})) {
|
|
1215
1361
|
// Two embedded items denoting one entity (#228). Per block, because
|
|
1216
1362
|
// `items` is a block key and a second system's actor carries its own.
|
|
1363
|
+
// Before the `continue` below, because it is a statement about the
|
|
1364
|
+
// block's *shape* and holds whether or not this system declares a
|
|
1365
|
+
// vocabulary for the note's type.
|
|
1217
1366
|
findings.push(...checkEmbeddedShortcodes(note, blockName));
|
|
1367
|
+
// A block is checked only where its system speaks about this type. A
|
|
1368
|
+
// type a system's registry does not name is a type it says nothing
|
|
1369
|
+
// about — SoHL's `mysticalability` is not an HM3 type at all — and
|
|
1370
|
+
// holding the block to an empty vocabulary would report every key in
|
|
1371
|
+
// it, which is the correct tree reported red.
|
|
1372
|
+
//
|
|
1373
|
+
// `fieldVocabulary` reaches types no registry declares, `being` above
|
|
1374
|
+
// all, so a spec carrying it always speaks. Only a spec whose *sole*
|
|
1375
|
+
// statement is `fields` can fall silent here.
|
|
1376
|
+
const own = spec?.fields;
|
|
1377
|
+
if (own && !own[type] && !spec?.fieldVocabulary && !spec?.known) continue;
|
|
1218
1378
|
const accepted = new Set([
|
|
1219
1379
|
...UNIVERSAL_KEYS,
|
|
1220
1380
|
...(spec?.known ?? []),
|
|
1221
1381
|
...(spec?.fieldVocabulary ? declared : []),
|
|
1382
|
+
...(own ? inBlockKeys(own[type]) : []),
|
|
1222
1383
|
]);
|
|
1223
1384
|
for (const key of unknownBlockKeys(fm, blockName, { known: accepted })) {
|
|
1224
1385
|
// Reported above, with what to write instead — a retired spelling
|
|
@@ -1347,8 +1508,9 @@ export function lintNote(
|
|
|
1347
1508
|
* @param {Record<string, object>} [opts.vocabulary] - Type → the closed regions
|
|
1348
1509
|
* it declares (#128); see {@link lintNote}.
|
|
1349
1510
|
* @param {boolean} [opts.references=true] - Whether to check references.
|
|
1350
|
-
* @param {Readonly<Record<string,
|
|
1351
|
-
* The system blocks to check
|
|
1511
|
+
* @param {Readonly<Record<string, SystemBlockSpec>>} [opts.systems]
|
|
1512
|
+
* The system blocks to check; see {@link lintNote} and
|
|
1513
|
+
* {@link systemBlocksFor}.
|
|
1352
1514
|
* @param {readonly string[]} [opts.packs] - The declared pack names; see
|
|
1353
1515
|
* {@link lintNote}.
|
|
1354
1516
|
* @param {(type: string) => {document: string|null, art: readonly string[]}|null} [opts.emittedArt]
|
package/engine/generate.mjs
CHANGED
|
@@ -497,8 +497,11 @@ async function generatePack(
|
|
|
497
497
|
// not contain, for a repository that authors beings without
|
|
498
498
|
// holding the items they are assembled from. Cache-only: a cold
|
|
499
499
|
// cache throws naming `content-build deps fetch` rather than
|
|
500
|
-
// downloading inside a compile.
|
|
501
|
-
|
|
500
|
+
// downloading inside a compile. Scoped to this pack's system for the
|
|
501
|
+
// reason the local half is (#58): both halves answer one lookup, so a
|
|
502
|
+
// dependency shipping two systems' items would otherwise supply the
|
|
503
|
+
// wrong vocabulary's document for an address that exists in both.
|
|
504
|
+
foreignSourceDirs: foreignItemCatalogDirs(config, system ?? null),
|
|
502
505
|
// The bundles pass resolves each Adventure's members against the output
|
|
503
506
|
// of every pass that produces one. Stated from the configured pack list
|
|
504
507
|
// for the same reason `itemsSourceDirs` is (#1508), and scoped to this
|
package/engine/helpers.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import path from "path";
|
|
|
31
31
|
import yaml from "yaml";
|
|
32
32
|
import unidecode from "unidecode";
|
|
33
33
|
import markdownit from "markdown-it";
|
|
34
|
+
import { iconPlugin } from "./content-icons.mjs";
|
|
34
35
|
import log from "loglevel";
|
|
35
36
|
|
|
36
37
|
import { loadPackConfig } from "./pack-config.mjs";
|
|
@@ -68,7 +69,15 @@ export {
|
|
|
68
69
|
parseValueDesc,
|
|
69
70
|
} from "./frontmatter.mjs";
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
/**
|
|
73
|
+
* The markdown renderer every surface shares.
|
|
74
|
+
*
|
|
75
|
+
* `html: true` is long-standing and load-bearing — notes carry raw blocks — and
|
|
76
|
+
* it is also why {@link module:engine/content-icons} exists rather than an
|
|
77
|
+
* instruction to write `<i class="fa-solid …">` by hand: that would render on
|
|
78
|
+
* the two HTML surfaces and be silently dropped by the third (#378).
|
|
79
|
+
*/
|
|
80
|
+
export const md = markdownit({ html: true }).use(iconPlugin());
|
|
72
81
|
|
|
73
82
|
/**
|
|
74
83
|
* Parses a markdown file with YAML frontmatter.
|
package/engine/index.mjs
CHANGED
|
@@ -116,6 +116,12 @@ export * as siteBuild from "./site-build.mjs";
|
|
|
116
116
|
/** Address rules every content tree is linted against: shape, uniqueness, alias. */
|
|
117
117
|
export * as contentLint from "./content-lint.mjs";
|
|
118
118
|
|
|
119
|
+
/** The charset authored content is held to, so a book can choose its face. */
|
|
120
|
+
export * as contentCharset from "./content-charset.mjs";
|
|
121
|
+
|
|
122
|
+
/** Naming an interface icon in a note, without drawing one there. */
|
|
123
|
+
export * as contentIcons from "./content-icons.mjs";
|
|
124
|
+
|
|
119
125
|
/** Resolving every link in a tree, and the ones that land nowhere. */
|
|
120
126
|
export * as contentLinks from "./content-links.mjs";
|
|
121
127
|
|
package/engine/note-claims.mjs
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* **Which note types a configuration compiles at all** —
|
|
16
|
-
*
|
|
15
|
+
* **Which note types a configuration compiles at all** — the finding for a note
|
|
16
|
+
* whose type nothing claims (#146), and the one for a note that loses a
|
|
17
|
+
* document while the rest of it compiles (#152).
|
|
17
18
|
*
|
|
18
19
|
* Every compile pass answers one question about a note: _is this mine?_ A note
|
|
19
20
|
* every pass answers "no" to is skipped as quietly as the thousands that
|
|
@@ -50,6 +51,27 @@
|
|
|
50
51
|
* who invented a word. Collapsing the two would send `harn-ensemble` to correct
|
|
51
52
|
* five perfectly good notes.
|
|
52
53
|
*
|
|
54
|
+
* ## The partial case is a third condition, and it was invisible (#152)
|
|
55
|
+
*
|
|
56
|
+
* The table above asks whether a note is compiled *at all*, and a note that
|
|
57
|
+
* compiles one of its two documents answers yes. But a note produces more than
|
|
58
|
+
* one document as a matter of course — an item note an Item and the
|
|
59
|
+
* JournalEntry its prose becomes, a map note a Scene and a JournalEntry, an
|
|
60
|
+
* actor note an Actor and a JournalEntry since #337 — so a configuration
|
|
61
|
+
* missing a pack for *one* of them dropped that document while the rest of the
|
|
62
|
+
* note compiled into a pack that does exist. The build succeeded and shipped
|
|
63
|
+
* half of what was written.
|
|
64
|
+
*
|
|
65
|
+
* | condition | what it means | whose fix |
|
|
66
|
+
* | --- | --- | --- |
|
|
67
|
+
* | some documents have a pack, one does not | this note compiles, and one of its documents is lost | configuration |
|
|
68
|
+
*
|
|
69
|
+
* {@link documentClassesFor} is the question this needs and the type-level
|
|
70
|
+
* table could not answer: not "is anything claiming this note" but "which
|
|
71
|
+
* documents does this note produce", asked per note, because documentation is
|
|
72
|
+
* per note — a doc-carrying note with an empty body produces no JournalEntry at
|
|
73
|
+
* all, and `Journals.skipNote` is where that is decided.
|
|
74
|
+
*
|
|
53
75
|
* ## The claim table mirrors `selects`, and a test holds them together
|
|
54
76
|
*
|
|
55
77
|
* Which note types a pass claims is stated by that pass's `selects`, and the
|
|
@@ -61,7 +83,7 @@
|
|
|
61
83
|
* @module
|
|
62
84
|
*/
|
|
63
85
|
|
|
64
|
-
import { assertSuppliedCorpus } from "./helpers.mjs";
|
|
86
|
+
import { assertSuppliedCorpus, parseMarkdownFile } from "./helpers.mjs";
|
|
65
87
|
// The record accessors only: this module is imported by the content index, so
|
|
66
88
|
// importing the index back would close a cycle (#243).
|
|
67
89
|
import { authoredFrontmatter, isNoteRecord, noteFile } from "./index-records.mjs";
|
|
@@ -252,6 +274,115 @@ export function noteTypesClaimedBy(docType, sources) {
|
|
|
252
274
|
}
|
|
253
275
|
}
|
|
254
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Every Foundry document class {@link noteTypesClaimedBy} answers for.
|
|
279
|
+
*
|
|
280
|
+
* The switch above, read the other way round. It is written out rather than
|
|
281
|
+
* derived because a `switch` cannot be enumerated — and
|
|
282
|
+
* `tests/unclaimed-note-types.test.ts` holds the two together by checking that
|
|
283
|
+
* no class outside this list claims anything, so a row added there and not here
|
|
284
|
+
* fails rather than going quiet.
|
|
285
|
+
*
|
|
286
|
+
* Order is the order a reader meets them in a message, not a precedence.
|
|
287
|
+
*
|
|
288
|
+
* @type {readonly string[]}
|
|
289
|
+
*/
|
|
290
|
+
export const CLAIMABLE_DOCUMENT_TYPES = Object.freeze([
|
|
291
|
+
"Item",
|
|
292
|
+
"Actor",
|
|
293
|
+
"JournalEntry",
|
|
294
|
+
"Macro",
|
|
295
|
+
"Scene",
|
|
296
|
+
"Adventure",
|
|
297
|
+
]);
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Every document class a note of one type compiles into (#152).
|
|
301
|
+
*
|
|
302
|
+
* **A note produces more than one document, and that is the ordinary case.** An
|
|
303
|
+
* item note compiles an Item *and* the JournalEntry its prose becomes; a map
|
|
304
|
+
* note a Scene and a JournalEntry; since #337 an actor note an Actor and a
|
|
305
|
+
* JournalEntry too. {@link claimedNoteTypes} unions over the configured packs
|
|
306
|
+
* and so answers "is this note compiled *at all*", which is #146's question and
|
|
307
|
+
* cannot see a note that compiles one of its two documents and loses the other.
|
|
308
|
+
*
|
|
309
|
+
* Asked of the **claim table** rather than of a list of its own, so the set of
|
|
310
|
+
* documents a type produces and the set of passes that claim it are one
|
|
311
|
+
* statement. A pass that starts claiming a type starts producing its document
|
|
312
|
+
* here, with nothing to remember.
|
|
313
|
+
*
|
|
314
|
+
* **Union across systems, never per system.** A type one system maps and
|
|
315
|
+
* another does not appears once, because the `Item` and `Actor` rows already
|
|
316
|
+
* fold the maps together — so this cannot report a document class a system
|
|
317
|
+
* deliberately declines to produce, which is the silence #79 requires.
|
|
318
|
+
*
|
|
319
|
+
* ## The JournalEntry row is the one that is per *note*
|
|
320
|
+
*
|
|
321
|
+
* Every other row is a property of the type: a `macro` note produces a Macro, a
|
|
322
|
+
* map note a Scene, whatever either says. Documentation is not. `Journals`
|
|
323
|
+
* declines a doc-carrying note whose body is empty — *"an item with no prose
|
|
324
|
+
* gets no doc, and the items pass leaves its description empty rather than
|
|
325
|
+
* pointing at nothing"* — so whether an item note produces a JournalEntry is
|
|
326
|
+
* decided by the note, not by its type.
|
|
327
|
+
*
|
|
328
|
+
* That distinction is the whole difference between a useful finding and a
|
|
329
|
+
* useless one. `sohl-kethira-basic` declares no JournalEntry pack and ships 393
|
|
330
|
+
* notes whose descriptions are *deliberately* empty, under the Fan Material
|
|
331
|
+
* Guidelines its configuration explains at length. A type-level answer would
|
|
332
|
+
* report every one of them for losing a document none of them produces. Asking
|
|
333
|
+
* per note, it reports none, and still reports `harn-ensemble`'s 2,517 beings,
|
|
334
|
+
* whose `{#appearance}` and `{#dossier}` prose is real and is lost.
|
|
335
|
+
*
|
|
336
|
+
* `hasProse` is therefore how the caller answers that, and it is a **thunk** so
|
|
337
|
+
* that the file is read only where the answer could change the outcome. Omitted,
|
|
338
|
+
* the answer is the type's full potential — every document such a note *could*
|
|
339
|
+
* produce — which is what a caller asking about a type rather than a note wants.
|
|
340
|
+
*
|
|
341
|
+
* @param {string} type - The note's declared `type`, current spelling.
|
|
342
|
+
* @param {ClaimSources} [sources] - What to answer from.
|
|
343
|
+
* @param {object} [opts] - Options.
|
|
344
|
+
* @param {(() => boolean)|boolean} [opts.hasProse] - Whether *this note* carries
|
|
345
|
+
* a body. Omitted, the type's potential is reported.
|
|
346
|
+
* @returns {string[]} The document classes, in {@link CLAIMABLE_DOCUMENT_TYPES}
|
|
347
|
+
* order. Empty for a type nothing compiles.
|
|
348
|
+
*/
|
|
349
|
+
export function documentClassesFor(type, sources, { hasProse } = {}) {
|
|
350
|
+
const resolved = resolveSources(sources);
|
|
351
|
+
return CLAIMABLE_DOCUMENT_TYPES.filter((docType) => {
|
|
352
|
+
if (!noteTypesClaimedBy(docType, resolved).has(type)) return false;
|
|
353
|
+
if (docType !== "JournalEntry") return true;
|
|
354
|
+
// A type whose *whole* document is the journal always produces one;
|
|
355
|
+
// there is no body condition, because the body is the document.
|
|
356
|
+
if (JOURNAL_TYPES.has(type)) return true;
|
|
357
|
+
if (hasProse === undefined) return true;
|
|
358
|
+
return Boolean(typeof hasProse === "function" ? hasProse() : hasProse);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Whether a note carries a body at all — the condition `Journals.skipNote`
|
|
364
|
+
* applies, asked from the outside (#152).
|
|
365
|
+
*
|
|
366
|
+
* Read from the file rather than from the index record, because a record
|
|
367
|
+
* carries a note's frontmatter and its derived address and not its prose. The
|
|
368
|
+
* walk that calls this is already reading the same file to locate the `type:`
|
|
369
|
+
* key for a finding's position, so this is the same cost in the same place —
|
|
370
|
+
* and it is called only for a note whose documentation would otherwise be
|
|
371
|
+
* reported as lost.
|
|
372
|
+
*
|
|
373
|
+
* @param {string} absPath - The note's path.
|
|
374
|
+
* @returns {boolean} True when the body has content.
|
|
375
|
+
*/
|
|
376
|
+
function noteHasProse(absPath) {
|
|
377
|
+
try {
|
|
378
|
+
return Boolean(parseMarkdownFile(absPath).body);
|
|
379
|
+
} catch {
|
|
380
|
+
// Unreadable here means unreadable for the compile too, which reports
|
|
381
|
+
// it with a message about the file rather than about its documentation.
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
255
386
|
/**
|
|
256
387
|
* Every note type some pack in a configuration would compile.
|
|
257
388
|
*
|
|
@@ -418,6 +549,44 @@ function specifiedMessage(type) {
|
|
|
418
549
|
);
|
|
419
550
|
}
|
|
420
551
|
|
|
552
|
+
/**
|
|
553
|
+
* The **partial** finding: the note compiles, and one of its documents does not
|
|
554
|
+
* (#152).
|
|
555
|
+
*
|
|
556
|
+
* #146's question is "does anything claim this note", and the answer is yes —
|
|
557
|
+
* which is exactly why this went unreported. A note produces more than one
|
|
558
|
+
* document, and a configuration missing a pack for one of them drops that
|
|
559
|
+
* document while the rest of the note compiles into a pack that does exist. The
|
|
560
|
+
* build succeeds, the compendium ships, and the missing half is discoverable
|
|
561
|
+
* only by noticing it is not there.
|
|
562
|
+
*
|
|
563
|
+
* The message names the note, the document class with no pack, and the class
|
|
564
|
+
* that *did* compile — the last because it is what distinguishes this from
|
|
565
|
+
* #146's finding at a glance: the note is not unclaimed, it is half-claimed, and
|
|
566
|
+
* the fix is a pack rather than a `type:`.
|
|
567
|
+
*
|
|
568
|
+
* @param {string} type - The note's declared `type`.
|
|
569
|
+
* @param {readonly string[]} missing - Document classes with no pack.
|
|
570
|
+
* @param {readonly string[]} compiled - Document classes that do have one.
|
|
571
|
+
* @returns {string} The message.
|
|
572
|
+
*/
|
|
573
|
+
function partialMessage(type, missing, compiled) {
|
|
574
|
+
// `Item`, `Actor` and `Adventure` take "an". Spelled out rather than left
|
|
575
|
+
// to read as a typo in a message an author meets at the moment they are
|
|
576
|
+
// being told something went wrong.
|
|
577
|
+
const article = (name) => (/^[AEIOU]/.test(name) ? "an" : "a");
|
|
578
|
+
const list = (classes) => classes.map((name) => `${article(name)} ${name}`).join(" and ");
|
|
579
|
+
const names = (classes) => classes.join(" and ");
|
|
580
|
+
return (
|
|
581
|
+
`a note of type "${type}" compiles into ${list(missing)} as well as ` +
|
|
582
|
+
`${list(compiled)}, and \`packs:\` declares no ${names(missing)} pack — ` +
|
|
583
|
+
`so the ${names(missing)} is dropped with no error while the rest of the ` +
|
|
584
|
+
`note compiles. Declare ${list(missing)} pack in ` +
|
|
585
|
+
`package-build.config.yaml, or accept the loss deliberately by not ` +
|
|
586
|
+
`authoring what it would have carried.`
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
421
590
|
/**
|
|
422
591
|
* The **authoring** finding: nothing anywhere knows the type.
|
|
423
592
|
*
|
|
@@ -455,9 +624,13 @@ function authoringMessage(type) {
|
|
|
455
624
|
*/
|
|
456
625
|
export function unclaimedNoteFindings(config = loadPackConfig(), sources, { records } = {}) {
|
|
457
626
|
const resolved = resolveSources(sources);
|
|
458
|
-
const claimed = claimedNoteTypes(config, resolved);
|
|
459
627
|
const vocabulary = noteTypeVocabulary(resolved);
|
|
460
628
|
const findings = [];
|
|
629
|
+
// The document classes this configuration can actually receive a compiled
|
|
630
|
+
// document into. A **prebuilt** pack is not one of them, for the reason
|
|
631
|
+
// {@link claimedNoteTypes} states: its JSON is checked in, it has no pass,
|
|
632
|
+
// and no note is routed into it.
|
|
633
|
+
const configured = new Set((config.packs ?? []).filter((p) => !p.prebuilt).map((p) => p.type));
|
|
461
634
|
|
|
462
635
|
// The corpus this compile derived once (#243), required rather than
|
|
463
636
|
// derived here: this module is imported *by* the content index, so it
|
|
@@ -481,7 +654,37 @@ export function unclaimedNoteFindings(config = loadPackConfig(), sources, { reco
|
|
|
481
654
|
// authored one (#78). The rename itself is reported by the frontmatter
|
|
482
655
|
// lint, which can say what to write instead.
|
|
483
656
|
const current = currentType(type);
|
|
484
|
-
|
|
657
|
+
|
|
658
|
+
// Every document this note produces, against the classes this
|
|
659
|
+
// configuration has a pack for. Three outcomes, and the middle one is
|
|
660
|
+
// #152's — it was invisible while the question was only "is anything
|
|
661
|
+
// claiming this note", because the answer there is yes.
|
|
662
|
+
const produces = documentClassesFor(current, resolved, {
|
|
663
|
+
// Lazy: only a doc-carrying type whose JournalEntry has nowhere to
|
|
664
|
+
// go asks, so a tree with a JournalEntry pack — which is most of
|
|
665
|
+
// them — reads no bodies at all.
|
|
666
|
+
hasProse: () => noteHasProse(absPath),
|
|
667
|
+
});
|
|
668
|
+
const missing = produces.filter((docType) => !configured.has(docType));
|
|
669
|
+
const compiled = produces.filter((docType) => configured.has(docType));
|
|
670
|
+
|
|
671
|
+
// Every document it produces has somewhere to go.
|
|
672
|
+
if (produces.length && !missing.length) continue;
|
|
673
|
+
|
|
674
|
+
// Some do and some do not: the note compiles, and one of its documents
|
|
675
|
+
// is dropped in silence. A type nothing produces at all falls past this
|
|
676
|
+
// to the #146 messages below, where `produces` being empty is itself
|
|
677
|
+
// part of the answer.
|
|
678
|
+
if (compiled.length) {
|
|
679
|
+
findings.push({
|
|
680
|
+
file: absPath,
|
|
681
|
+
...locateFrontmatterKey(absPath, "type", type),
|
|
682
|
+
severity: /** @type {"error"} */ ("error"),
|
|
683
|
+
type,
|
|
684
|
+
message: partialMessage(type, missing, compiled),
|
|
685
|
+
});
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
485
688
|
|
|
486
689
|
findings.push({
|
|
487
690
|
file: absPath,
|