@heroiclands/package-build 8.1.0 → 10.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 +748 -0
- package/CONTENT.md +273 -13
- package/bin/content-build.mjs +479 -123
- package/bin/package-build.mjs +27 -69
- package/bin/report.mjs +1 -2
- package/bundle.mjs +2 -10
- package/config.mjs +31 -106
- package/container.mjs +13 -57
- package/content-config.mjs +300 -188
- package/coverage.mjs +14 -55
- package/deploy.mjs +4 -13
- package/docs/content-format.md +1418 -0
- package/e2e.mjs +16 -55
- package/engine/address-charset.mjs +62 -0
- package/engine/address-diff.mjs +1 -4
- package/engine/alias-index.mjs +153 -0
- package/engine/base-compiler.mjs +203 -31
- package/engine/code-fences.mjs +4 -13
- package/engine/compendiums.mjs +13 -37
- package/engine/content-address.mjs +6 -10
- package/engine/content-format-check.mjs +570 -0
- package/engine/content-format.mjs +253 -0
- package/engine/content-links.mjs +144 -99
- package/engine/content-lint.mjs +12 -16
- package/engine/content-slug.mjs +2 -6
- package/engine/content-tables.mjs +26 -79
- package/engine/diagnostics.mjs +37 -15
- package/engine/document-subtypes.mjs +440 -0
- package/engine/field-reference.mjs +6 -20
- package/engine/field-spec.mjs +49 -45
- package/engine/foreign-catalog.mjs +7 -22
- package/engine/foreign-manifests.mjs +1 -4
- package/engine/frontmatter-lint.mjs +347 -43
- package/engine/frontmatter.mjs +3 -8
- package/engine/generate.mjs +36 -20
- package/engine/helpers.mjs +54 -81
- package/engine/homepage.mjs +4 -15
- package/engine/ids.mjs +21 -12
- package/engine/index.mjs +15 -0
- package/engine/item-registry.mjs +72 -9
- package/engine/journals.mjs +4 -14
- package/engine/kb-manifest.mjs +41 -24
- package/engine/macros.mjs +2 -10
- package/engine/manifest-emit.mjs +6 -17
- package/engine/map-notes.mjs +53 -87
- package/engine/note-claims.mjs +383 -0
- package/engine/note-package.mjs +1 -4
- package/engine/note-vocabulary.mjs +678 -0
- package/engine/pack-config.mjs +56 -60
- package/engine/pack-router.mjs +18 -8
- package/engine/prose-config.mjs +20 -4
- package/engine/prose-lint.mjs +61 -17
- package/engine/region-events.mjs +1 -3
- package/engine/retired-fields.mjs +117 -3
- package/engine/scene-levels.mjs +8 -22
- package/engine/scenes.mjs +31 -47
- package/engine/schema-check.mjs +348 -7
- package/engine/schema-extract.mjs +11 -39
- package/engine/site-build.mjs +13 -38
- package/engine/site-index.mjs +40 -35
- package/engine/system-block.mjs +513 -0
- package/engine/web-wikilinks.mjs +115 -92
- package/engine/wikilink-syntax.mjs +30 -0
- package/engine/wikilinks.mjs +147 -183
- package/index.mjs +1 -5
- package/lang.mjs +1 -3
- package/manifest.mjs +10 -37
- package/markdownlint-config.mjs +1 -5
- package/package.json +6 -2
- package/sohl/actors.mjs +251 -68
- package/sohl/being-info.mjs +3 -6
- package/sohl/document-subtypes.mjs +82 -0
- package/sohl/index.mjs +4 -6
- package/sohl/item-builders.mjs +1 -3
- package/sohl/item-fields.mjs +16 -34
- package/sohl/items.mjs +111 -17
- package/sohl/kb-passes.mjs +29 -39
- package/sohl/note-schemas.mjs +11 -7
- package/sohl/skill-base.mjs +7 -23
- package/stage.mjs +3 -13
- package/templates.mjs +4 -15
- package/types/bundle.d.mts +1 -1
- package/types/container.d.mts +2 -2
- package/types/content-config.d.mts +48 -4
- package/types/coverage.d.mts +1 -1
- package/types/e2e.d.mts +4 -4
- package/types/engine/address-charset.d.mts +45 -0
- package/types/engine/alias-index.d.mts +122 -0
- package/types/engine/base-compiler.d.mts +132 -4
- package/types/engine/content-address.d.mts +2 -2
- package/types/engine/content-format-check.d.mts +163 -0
- package/types/engine/content-format.d.mts +101 -0
- package/types/engine/content-links.d.mts +16 -1
- package/types/engine/content-lint.d.mts +6 -0
- package/types/engine/diagnostics.d.mts +29 -0
- package/types/engine/document-subtypes.d.mts +233 -0
- package/types/engine/field-spec.d.mts +76 -23
- package/types/engine/frontmatter-lint.d.mts +47 -2
- package/types/engine/generate.d.mts +15 -2
- package/types/engine/helpers.d.mts +22 -14
- package/types/engine/ids.d.mts +10 -0
- package/types/engine/index.d.mts +5 -0
- package/types/engine/item-registry.d.mts +21 -2
- package/types/engine/kb-manifest.d.mts +35 -8
- package/types/engine/map-notes.d.mts +21 -11
- package/types/engine/note-claims.d.mts +113 -0
- package/types/engine/note-vocabulary.d.mts +251 -0
- package/types/engine/pack-config.d.mts +4 -3
- package/types/engine/pack-router.d.mts +4 -4
- package/types/engine/prose-lint.d.mts +6 -2
- package/types/engine/retired-fields.d.mts +73 -2
- package/types/engine/schema-check.d.mts +182 -0
- package/types/engine/schema-extract.d.mts +1 -1
- package/types/engine/site-index.d.mts +1 -1
- package/types/engine/system-block.d.mts +281 -0
- package/types/engine/web-wikilinks.d.mts +23 -12
- package/types/engine/wikilink-syntax.d.mts +29 -0
- package/types/manifest.d.mts +1 -1
- package/types/sohl/actors.d.mts +62 -6
- package/types/sohl/document-subtypes.d.mts +14 -0
- package/types/sohl/index.d.mts +1 -0
- package/types/sohl/items.d.mts +21 -0
|
@@ -0,0 +1,440 @@
|
|
|
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
|
+
* **The note-type → document-subtype map** — the mechanism that stops a build
|
|
16
|
+
* inferring a Foundry document's subtype from the markdown note's `type`
|
|
17
|
+
* (#79).
|
|
18
|
+
*
|
|
19
|
+
* A note's `type` and the subtype of the document it compiles into are two
|
|
20
|
+
* vocabularies, and until now they were the same identifier for one reason
|
|
21
|
+
* only: a builder wrote the same string twice. `sohl/actors.mjs` declared
|
|
22
|
+
* `ACTOR_VAULT_TYPE = "being"` and emitted `type: "being"` several hundred
|
|
23
|
+
* lines below it, under a comment reading _"One content type, named for the
|
|
24
|
+
* Foundry actor it produces."_ Nothing related the two, so changing one and
|
|
25
|
+
* not the other produced a wrongly-typed document in silence — a wrong-output
|
|
26
|
+
* risk with **one** system, not merely with two.
|
|
27
|
+
*
|
|
28
|
+
* **The mechanism is here; the declaration is the system's.** That is the
|
|
29
|
+
* `engine/` ÷ `sohl/` line everywhere else in this package (#36): note-format
|
|
30
|
+
* knowledge here, game-system knowledge there. `sohl/document-subtypes.mjs`
|
|
31
|
+
* declares SoHL's own map, *including its identity rows* — the coincidence of
|
|
32
|
+
* names may never stand in for a mapping, so `skill` → `skill` is written out
|
|
33
|
+
* like any other row rather than derived from the registry's keys.
|
|
34
|
+
*
|
|
35
|
+
* **Three properties the map exists to give:**
|
|
36
|
+
*
|
|
37
|
+
* - _A mapped type compiles to the subtype the row declares_, whatever the
|
|
38
|
+
* note calls itself.
|
|
39
|
+
* - _An unmapped type produces no document for that system_ —
|
|
40
|
+
* {@link documentSubtype} answers `undefined` and the pass claims nothing.
|
|
41
|
+
* Silent and correct: a `place` note is not a SoHL document, and never was.
|
|
42
|
+
* - _A one-to-many row is resolved by the note_, which supplies the
|
|
43
|
+
* discriminator in that system's own block. An absent one is an **error
|
|
44
|
+
* naming the note**, never a default — a default would pick one of the
|
|
45
|
+
* subtypes for the author and be right about half the time.
|
|
46
|
+
*
|
|
47
|
+
* A row is one of exactly two shapes, and declaring both or neither is refused
|
|
48
|
+
* at definition time:
|
|
49
|
+
*
|
|
50
|
+
* ```js
|
|
51
|
+
* skill: { document: "Item", subType: "skill" }
|
|
52
|
+
* being: { document: "Actor", discriminator: "kindOf",
|
|
53
|
+
* subTypes: ["character", "creature"] }
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* **A note is not the only thing that names a type.** A being's frontmatter
|
|
57
|
+
* addresses each of its embedded items by `(type, shortcode)`, and that `type`
|
|
58
|
+
* is the note vocabulary too — while the items it resolves against are
|
|
59
|
+
* *compiled documents*, which carry only the subtype. {@link referencedSubtype}
|
|
60
|
+
* is the translation for that side, and the reason it is separate from
|
|
61
|
+
* {@link documentSubtype} is that a reference has no frontmatter of its own to
|
|
62
|
+
* read a discriminator from (#140).
|
|
63
|
+
*
|
|
64
|
+
* @module
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
import { assertTypeNotRetired } from "./ids.mjs";
|
|
68
|
+
import { locateFrontmatterKey } from "./retired-fields.mjs";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One row of a system's map: what a note of this type becomes.
|
|
72
|
+
*
|
|
73
|
+
* @typedef {object} DocumentSubtypeRow
|
|
74
|
+
* @property {string} document - The Foundry document class — `"Item"`,
|
|
75
|
+
* `"Actor"`, and so on. It must agree with what the engine's own
|
|
76
|
+
* {@link packForType} routes the type to; they are two statements about one
|
|
77
|
+
* fact, and a test holds them together.
|
|
78
|
+
* @property {string} [subType] - The document subtype, for a one-to-one row.
|
|
79
|
+
* Mutually exclusive with `discriminator`.
|
|
80
|
+
* @property {string} [discriminator] - For a one-to-many row: the key the note
|
|
81
|
+
* supplies in this system's own frontmatter block to say which subtype it
|
|
82
|
+
* is.
|
|
83
|
+
* @property {readonly string[]} [subTypes] - The subtypes `discriminator` may
|
|
84
|
+
* name. Required with it, and never empty — a row permitting nothing is a
|
|
85
|
+
* row that can never resolve.
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A system's whole declaration: which system it belongs to, which frontmatter
|
|
90
|
+
* block its notes write, and every note type it maps.
|
|
91
|
+
*
|
|
92
|
+
* @typedef {object} DocumentSubtypeMap
|
|
93
|
+
* @property {string} system - The system this map belongs to (`"sohl"`).
|
|
94
|
+
* @property {string} block - The note frontmatter block this system's notes
|
|
95
|
+
* write, and the only place a discriminator is read from. Defaults to
|
|
96
|
+
* `system`.
|
|
97
|
+
* @property {Readonly<Record<string, DocumentSubtypeRow>>} types - Note type →
|
|
98
|
+
* row.
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Declare one system's note-type → document-subtype map.
|
|
103
|
+
*
|
|
104
|
+
* Every malformed row is refused **here**, at module evaluation, rather than
|
|
105
|
+
* when some note happens to reach it: a map is a small piece of authored data
|
|
106
|
+
* loaded once per build, so a mistake in it should stop the build immediately
|
|
107
|
+
* and name the row, not surface as a missing document a thousand notes later.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} declaration - The system's declaration.
|
|
110
|
+
* @param {string} declaration.system - The system this map belongs to.
|
|
111
|
+
* @param {string} [declaration.block] - The frontmatter block its notes write.
|
|
112
|
+
* Defaults to `system`.
|
|
113
|
+
* @param {Readonly<Record<string, DocumentSubtypeRow>>} declaration.types -
|
|
114
|
+
* Note type → row.
|
|
115
|
+
* @returns {DocumentSubtypeMap} The frozen map, rows and all.
|
|
116
|
+
* @throws {Error} When the declaration names no system, or any row is neither
|
|
117
|
+
* of the two permitted shapes.
|
|
118
|
+
*/
|
|
119
|
+
export function defineDocumentSubtypes({ system, block, types } = /** @type {never} */ ({})) {
|
|
120
|
+
if (typeof system !== "string" || system === "") {
|
|
121
|
+
throw new Error(
|
|
122
|
+
"A document-subtype map must name the `system` it belongs to — it is " +
|
|
123
|
+
"read per system, and its rows are only meaningful against one.",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (types == null || typeof types !== "object") {
|
|
127
|
+
throw new Error(`The "${system}" document-subtype map declares no \`types\`.`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const rows = Object.entries(types).map(([noteType, row]) => [
|
|
131
|
+
noteType,
|
|
132
|
+
frozenRow(system, noteType, row),
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
return Object.freeze({
|
|
136
|
+
system,
|
|
137
|
+
block: typeof block === "string" && block !== "" ? block : system,
|
|
138
|
+
types: Object.freeze(Object.fromEntries(rows)),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Validate one row and freeze it.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} system - The declaring system, named in every message.
|
|
146
|
+
* @param {string} noteType - The note type this row is keyed by.
|
|
147
|
+
* @param {DocumentSubtypeRow} row - The row as declared.
|
|
148
|
+
* @returns {Readonly<DocumentSubtypeRow>} The frozen row.
|
|
149
|
+
* @throws {Error} When the row is neither of the two permitted shapes.
|
|
150
|
+
*/
|
|
151
|
+
function frozenRow(system, noteType, row) {
|
|
152
|
+
const where = `The "${system}" document-subtype map's "${noteType}" row`;
|
|
153
|
+
if (row == null || typeof row !== "object") {
|
|
154
|
+
throw new Error(`${where} is not a row — write \`{ document, subType }\`.`);
|
|
155
|
+
}
|
|
156
|
+
if (typeof row.document !== "string" || row.document === "") {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`${where} names no \`document\` — say which Foundry document class ` +
|
|
159
|
+
`it compiles into ("Item", "Actor", …).`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const oneToOne = typeof row.subType === "string" && row.subType !== "";
|
|
164
|
+
const oneToMany = typeof row.discriminator === "string" && row.discriminator !== "";
|
|
165
|
+
if (oneToOne && oneToMany) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`${where} declares both a \`subType\` and a \`discriminator\` — a row ` +
|
|
168
|
+
`is one or the other, and a fixed subtype cannot also be chosen ` +
|
|
169
|
+
`per note.`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if (!oneToOne && !oneToMany) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`${where} declares neither a \`subType\` nor a \`discriminator\` — an ` +
|
|
175
|
+
`identity row is written out (\`subType: "${noteType}"\`) rather ` +
|
|
176
|
+
`than left to the coincidence of the names matching.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (oneToMany && (!Array.isArray(row.subTypes) || row.subTypes.length === 0)) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`${where} names a \`discriminator\` but no \`subTypes\` — list the ` +
|
|
182
|
+
`values it may take, since nothing else says what a note is ` +
|
|
183
|
+
`allowed to write.`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return Object.freeze({
|
|
188
|
+
document: row.document,
|
|
189
|
+
...(oneToOne ? { subType: row.subType } : {}),
|
|
190
|
+
...(oneToMany ?
|
|
191
|
+
{
|
|
192
|
+
discriminator: row.discriminator,
|
|
193
|
+
subTypes: Object.freeze([...(row.subTypes ?? [])]),
|
|
194
|
+
}
|
|
195
|
+
: {}),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The row a system declares for a note type, or nothing.
|
|
201
|
+
*
|
|
202
|
+
* @param {DocumentSubtypeMap} map - The system's map.
|
|
203
|
+
* @param {string|undefined} noteType - The note's declared `type`.
|
|
204
|
+
* @returns {Readonly<DocumentSubtypeRow>|undefined} The row, or `undefined`
|
|
205
|
+
* where this system maps the type at all.
|
|
206
|
+
*/
|
|
207
|
+
export function subtypeRow(map, noteType) {
|
|
208
|
+
if (!noteType || typeof noteType !== "string") return undefined;
|
|
209
|
+
return map?.types?.[noteType];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Whether a system maps a note type — optionally, onto one document class.
|
|
214
|
+
*
|
|
215
|
+
* This is what a pass asks to decide whether it claims a note, which is the
|
|
216
|
+
* whole of the "no mapping, no document" property: a type the map does not
|
|
217
|
+
* carry is skipped exactly as quietly as the thousands of notes that belong to
|
|
218
|
+
* another pass.
|
|
219
|
+
*
|
|
220
|
+
* @param {DocumentSubtypeMap} map - The system's map.
|
|
221
|
+
* @param {string|undefined} noteType - The note's declared `type`.
|
|
222
|
+
* @param {string} [document] - Restrict the question to one document class.
|
|
223
|
+
* @returns {boolean} True when the map carries a matching row.
|
|
224
|
+
*/
|
|
225
|
+
export function mapsNoteType(map, noteType, document) {
|
|
226
|
+
const row = subtypeRow(map, noteType);
|
|
227
|
+
if (!row) return false;
|
|
228
|
+
return document === undefined || row.document === document;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Every note type a system maps onto one document class, sorted.
|
|
233
|
+
*
|
|
234
|
+
* A pass reports what it skipped by naming what it would have claimed, and
|
|
235
|
+
* that list is the map's — never a constant restating it.
|
|
236
|
+
*
|
|
237
|
+
* @param {DocumentSubtypeMap} map - The system's map.
|
|
238
|
+
* @param {string} document - The Foundry document class.
|
|
239
|
+
* @returns {string[]} The note types, in sorted order.
|
|
240
|
+
*/
|
|
241
|
+
export function noteTypesFor(map, document) {
|
|
242
|
+
return Object.entries(map?.types ?? {})
|
|
243
|
+
.filter(([, row]) => row.document === document)
|
|
244
|
+
.map(([noteType]) => noteType)
|
|
245
|
+
.sort();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The document subtype a note compiles into for one system.
|
|
250
|
+
*
|
|
251
|
+
* @param {DocumentSubtypeMap} map - The system's map.
|
|
252
|
+
* @param {string|undefined} noteType - The note's declared `type`.
|
|
253
|
+
* @param {object} fm - The note's frontmatter, read only for a one-to-many
|
|
254
|
+
* row's discriminator.
|
|
255
|
+
* @param {object} [options] - Options.
|
|
256
|
+
* @param {string} [options.file] - The note's path, appended to the message.
|
|
257
|
+
* Omit it where the caller emits through a diagnostic, which already puts
|
|
258
|
+
* the locator at the start of the line.
|
|
259
|
+
* @param {string} [options.absPath] - The note's file on disk, read only on
|
|
260
|
+
* the failing path to locate the offending line. The position rides on the
|
|
261
|
+
* thrown error as `position`, for a caller that emits a diagnostic.
|
|
262
|
+
* @returns {string|undefined} The subtype, or `undefined` where this system
|
|
263
|
+
* maps nothing for the type — which means no document, not an error.
|
|
264
|
+
* @throws {Error} When a one-to-many row's discriminator is absent, blank, or
|
|
265
|
+
* names a value the row does not permit.
|
|
266
|
+
*/
|
|
267
|
+
export function documentSubtype(map, noteType, fm, { file, absPath } = {}) {
|
|
268
|
+
const row = subtypeRow(map, noteType);
|
|
269
|
+
if (!row) return undefined;
|
|
270
|
+
if (row.subType) return row.subType;
|
|
271
|
+
|
|
272
|
+
const block = map.block;
|
|
273
|
+
const field = `${block}.${row.discriminator}`;
|
|
274
|
+
const permitted = /** @type {readonly string[]} */ (row.subTypes);
|
|
275
|
+
const declared = readDiscriminator(fm, block, /** @type {string} */ (row.discriminator));
|
|
276
|
+
|
|
277
|
+
if (declared === undefined) {
|
|
278
|
+
throw located(
|
|
279
|
+
`a "${noteType}" note compiles into more than one ${map.system} ` +
|
|
280
|
+
`${row.document} subtype, so it must say which: write ` +
|
|
281
|
+
`\`${field}\` as one of ${list(permitted)}`,
|
|
282
|
+
{ file, absPath, key: block },
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
if (!permitted.includes(declared)) {
|
|
286
|
+
throw located(
|
|
287
|
+
`\`${field}: ${declared}\` is not a ${map.system} ${row.document} ` +
|
|
288
|
+
`subtype a "${noteType}" note may compile into — write one of ` +
|
|
289
|
+
`${list(permitted)}`,
|
|
290
|
+
{ file, absPath, key: row.discriminator, value: declared },
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
return declared;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The answer {@link referencedSubtype} gives: a subtype, or why there is none.
|
|
298
|
+
*
|
|
299
|
+
* Exactly one of the two fields is set. A `problem` is a sentence a caller
|
|
300
|
+
* prefixes with its own context and emits as a finding — it never throws,
|
|
301
|
+
* because the caller resolving a reference is walking a list and has to report
|
|
302
|
+
* this one and carry on.
|
|
303
|
+
*
|
|
304
|
+
* @typedef {object} ReferencedSubtype
|
|
305
|
+
* @property {string} [subType] - The document subtype the reference addresses.
|
|
306
|
+
* @property {string} [problem] - Why the reference names no document subtype.
|
|
307
|
+
*/
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The document subtype a `(type, shortcode)` **reference** addresses (#140).
|
|
311
|
+
*
|
|
312
|
+
* A being's frontmatter names each embedded item by the *note's* type — the
|
|
313
|
+
* vocabulary an author writes — while the predefined items it resolves against
|
|
314
|
+
* are compiled documents, which carry only the *subtype*. One side has to
|
|
315
|
+
* translate, and it is this one: the map is a function from note type to
|
|
316
|
+
* subtype by construction, whereas the reverse is not — two note types may
|
|
317
|
+
* compile into one subtype, and a compiled document records nothing about the
|
|
318
|
+
* note that produced it. So the addresses stay keyed on the **document
|
|
319
|
+
* subtype**, which is the only vocabulary both a local pack and an extracted
|
|
320
|
+
* dependency catalogue actually carry, and a reference is translated forward
|
|
321
|
+
* here before it is looked up.
|
|
322
|
+
*
|
|
323
|
+
* Four answers, and only the first resolves:
|
|
324
|
+
*
|
|
325
|
+
* - _A one-to-one row_ → the subtype it declares. `armor` addresses an
|
|
326
|
+
* `armorgear`.
|
|
327
|
+
* - _No row at all_ → the note type itself. A consumer declares its own item
|
|
328
|
+
* types in its `itemBuilders` table rather than in this system's map, and the
|
|
329
|
+
* Item pass stamps such a document with the note type; the reference has to
|
|
330
|
+
* agree, or a consumer's own items would stop resolving the moment a map
|
|
331
|
+
* existed.
|
|
332
|
+
* - _A row for another document class_ → a problem. A being is not an item,
|
|
333
|
+
* however the address is spelled.
|
|
334
|
+
* - _A one-to-many row_ → a problem naming the candidates. The note that owns
|
|
335
|
+
* such a row resolves it from its own frontmatter block; a reference has no
|
|
336
|
+
* block, so nothing here can choose, and choosing anyway would be right about
|
|
337
|
+
* half the time. No system declares a one-to-many **Item** row today, so this
|
|
338
|
+
* is a guard rather than a behaviour — but it is a loud one, which is the
|
|
339
|
+
* whole point of the issue.
|
|
340
|
+
*
|
|
341
|
+
* A **retired** spelling is refused by name before any of that. Without it a
|
|
342
|
+
* reference left behind by a rename would take the unmapped fallback and
|
|
343
|
+
* address a document of the old name — resolving silently, which is precisely
|
|
344
|
+
* what a retirement exists to stop (#78).
|
|
345
|
+
*
|
|
346
|
+
* @param {DocumentSubtypeMap} map - The system's map.
|
|
347
|
+
* @param {string|undefined} noteType - The type the reference names.
|
|
348
|
+
* @param {string} document - The Foundry document class the reference must
|
|
349
|
+
* address — `"Item"` for a being's embedded items.
|
|
350
|
+
* @returns {ReferencedSubtype} The subtype, or why there is none.
|
|
351
|
+
*/
|
|
352
|
+
export function referencedSubtype(map, noteType, document) {
|
|
353
|
+
if (!noteType || typeof noteType !== "string") {
|
|
354
|
+
return { problem: "the reference names no type" };
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
assertTypeNotRetired(noteType);
|
|
358
|
+
} catch (err) {
|
|
359
|
+
return { problem: /** @type {Error} */ (err).message };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const row = subtypeRow(map, noteType);
|
|
363
|
+
// 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.
|
|
365
|
+
if (!row) return { subType: noteType };
|
|
366
|
+
|
|
367
|
+
if (row.document !== document) {
|
|
368
|
+
return {
|
|
369
|
+
problem:
|
|
370
|
+
`${map.system} compiles a "${noteType}" note into a ` +
|
|
371
|
+
`${row.document}, not a ${document}`,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
if (!row.subType) {
|
|
375
|
+
const permitted = /** @type {readonly string[]} */ (row.subTypes);
|
|
376
|
+
return {
|
|
377
|
+
problem:
|
|
378
|
+
`a "${noteType}" note compiles into more than one ${map.system} ` +
|
|
379
|
+
`${document} subtype (${list(permitted)}), and a ` +
|
|
380
|
+
`(type, shortcode) reference cannot say which`,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
return { subType: row.subType };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Read a discriminator out of one system's block, and nowhere else.
|
|
388
|
+
*
|
|
389
|
+
* Deliberately **not** {@link sohlField}, which falls back to the top level: a
|
|
390
|
+
* discriminator is a statement about *this system's* document, so a note that
|
|
391
|
+
* wrote it outside this system's block has not supplied it. Accepting it
|
|
392
|
+
* anyway is how a second system's block would silently answer for the first.
|
|
393
|
+
*
|
|
394
|
+
* @param {object} fm - The note's frontmatter.
|
|
395
|
+
* @param {string} block - The system's block key.
|
|
396
|
+
* @param {string} key - The discriminator key within it.
|
|
397
|
+
* @returns {string|undefined} The declared value, or `undefined` when absent
|
|
398
|
+
* or blank — the two are the same thing to an author.
|
|
399
|
+
*/
|
|
400
|
+
function readDiscriminator(fm, block, key) {
|
|
401
|
+
if (fm == null || typeof fm !== "object") return undefined;
|
|
402
|
+
const declared = /** @type {Record<string, unknown>} */ (fm)[block];
|
|
403
|
+
if (declared == null || typeof declared !== "object") return undefined;
|
|
404
|
+
const value = /** @type {Record<string, unknown>} */ (declared)[key];
|
|
405
|
+
if (value == null) return undefined;
|
|
406
|
+
const text = String(value).trim();
|
|
407
|
+
return text === "" ? undefined : text;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Build an error carrying the position of the frontmatter key it is about.
|
|
412
|
+
*
|
|
413
|
+
* @param {string} message - What is wrong, in one sentence.
|
|
414
|
+
* @param {object} at - Where.
|
|
415
|
+
* @param {string} [at.file] - The note's path, appended to the message.
|
|
416
|
+
* @param {string} [at.absPath] - The note's file, read to locate the key.
|
|
417
|
+
* @param {string} [at.key] - The frontmatter key to locate.
|
|
418
|
+
* @param {string} [at.value] - Prefer the line carrying this value.
|
|
419
|
+
* @returns {Error & {position?: {line?: number, column?: number}}} The error.
|
|
420
|
+
*/
|
|
421
|
+
function located(message, { file, absPath, key, value } = {}) {
|
|
422
|
+
const err = /** @type {Error & {position?: object}} */ (
|
|
423
|
+
new Error(message + (file ? ` — ${file}` : ""))
|
|
424
|
+
);
|
|
425
|
+
const position = key ? locateFrontmatterKey(absPath, key, value) : undefined;
|
|
426
|
+
if (position) err.position = position;
|
|
427
|
+
return err;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* A readable list of permitted values, for a message an author acts on.
|
|
432
|
+
*
|
|
433
|
+
* @param {readonly string[]} values - The values.
|
|
434
|
+
* @returns {string} `"a", "b" or "c"`.
|
|
435
|
+
*/
|
|
436
|
+
function list(values) {
|
|
437
|
+
const quoted = values.map((value) => `"${value}"`);
|
|
438
|
+
if (quoted.length <= 1) return quoted.join("");
|
|
439
|
+
return `${quoted.slice(0, -1).join(", ")} or ${quoted[quoted.length - 1]}`;
|
|
440
|
+
}
|
|
@@ -47,10 +47,8 @@ import { loadPackConfig } from "./pack-config.mjs";
|
|
|
47
47
|
function renderDefault(value) {
|
|
48
48
|
if (value === undefined) return "—";
|
|
49
49
|
if (value === null) return "`null`";
|
|
50
|
-
if (typeof value === "string")
|
|
51
|
-
|
|
52
|
-
if (Array.isArray(value))
|
|
53
|
-
return value.length === 0 ? "`[]`" : `\`${JSON.stringify(value)}\``;
|
|
50
|
+
if (typeof value === "string") return value === "" ? '`""`' : `\`${value}\``;
|
|
51
|
+
if (Array.isArray(value)) return value.length === 0 ? "`[]`" : `\`${JSON.stringify(value)}\``;
|
|
54
52
|
if (typeof value === "object") {
|
|
55
53
|
const json = JSON.stringify(value);
|
|
56
54
|
return json === "{}" ? "`{}`" : `\`${json}\``;
|
|
@@ -83,17 +81,10 @@ function cell(text) {
|
|
|
83
81
|
* @returns {string[]} Markdown lines.
|
|
84
82
|
*/
|
|
85
83
|
function padTable(rows) {
|
|
86
|
-
const widths = rows[0].map((_, column) =>
|
|
87
|
-
|
|
88
|
-
);
|
|
89
|
-
const line = (cells) =>
|
|
90
|
-
`| ${cells.map((c, i) => c.padEnd(widths[i])).join(" | ")} |`;
|
|
84
|
+
const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
|
|
85
|
+
const line = (cells) => `| ${cells.map((c, i) => c.padEnd(widths[i])).join(" | ")} |`;
|
|
91
86
|
const [header, ...body] = rows;
|
|
92
|
-
return [
|
|
93
|
-
line(header),
|
|
94
|
-
`| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`,
|
|
95
|
-
...body.map(line),
|
|
96
|
-
];
|
|
87
|
+
return [line(header), `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`, ...body.map(line)];
|
|
97
88
|
}
|
|
98
89
|
|
|
99
90
|
/**
|
|
@@ -156,12 +147,7 @@ function workedExample(type, fields) {
|
|
|
156
147
|
for (const field of required) {
|
|
157
148
|
lines.push(` ${field.name}: <${field.shape ?? "value"}>`);
|
|
158
149
|
}
|
|
159
|
-
lines.push(
|
|
160
|
-
"---",
|
|
161
|
-
"",
|
|
162
|
-
"The prose here compiles into the item's documentation.",
|
|
163
|
-
"```",
|
|
164
|
-
);
|
|
150
|
+
lines.push("---", "", "The prose here compiles into the item's documentation.", "```");
|
|
165
151
|
return lines;
|
|
166
152
|
}
|
|
167
153
|
|
package/engine/field-spec.mjs
CHANGED
|
@@ -43,14 +43,28 @@
|
|
|
43
43
|
* @module
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
|
-
import {
|
|
46
|
+
import { resolveFieldValue, setPath } from "./system-block.mjs";
|
|
47
|
+
|
|
48
|
+
export { setPath };
|
|
47
49
|
|
|
48
50
|
/**
|
|
49
51
|
* @typedef {object} FieldSpec
|
|
50
|
-
* @property {string} to - Dotted path in the emitted `system` block
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
52
|
+
* @property {string} to - Dotted path in the emitted `system` block — and,
|
|
53
|
+
* since #58, the path a note authors the field at directly:
|
|
54
|
+
* `<system>.system.<to>`.
|
|
55
|
+
* @property {string} [name] - The **shared, top-level property this field draws
|
|
56
|
+
* from** when the note authors no value at `<system>.system.<to>`. Dotted for
|
|
57
|
+
* a path into a shared container (`data.portrait`), which is now the ordinary
|
|
58
|
+
* case: `data:` (#128) puts every type-specific fact under one.
|
|
59
|
+
*
|
|
60
|
+
* It used to mean "frontmatter key under `sohl:`", and that reading is the
|
|
61
|
+
* degenerate case where the shared source and the system destination happen
|
|
62
|
+
* to share a name. They constantly do not — one shared `data.portrait` feeds
|
|
63
|
+
* `sohl.system.portrait` *and* `hm3.system.bioImage` — so the source is
|
|
64
|
+
* declared rather than matched by spelling (#58). The in-block position is
|
|
65
|
+
* still read, second, until #126 moves the corpus off it.
|
|
66
|
+
*
|
|
67
|
+
* Absent means the value is not authored at all — see `value`.
|
|
54
68
|
* @property {string} [shape] - Human-readable shape, for documentation. Comes
|
|
55
69
|
* paired with `read` from one of the coercion constants below.
|
|
56
70
|
* @property {(raw: any, ctx: {fm: object, field: FieldSpec}) => any} [read] -
|
|
@@ -58,6 +72,15 @@ import { sohlField } from "./frontmatter.mjs";
|
|
|
58
72
|
* @property {any} [default] - Emitted when the note does not carry the field.
|
|
59
73
|
* @property {boolean} [required] - Whether a note must carry it. A required
|
|
60
74
|
* field's `read` is expected to throw when it is missing.
|
|
75
|
+
* @property {boolean} [shared] - Whether the field is authored at the note's
|
|
76
|
+
* **top level** rather than inside a system block, because what it states is
|
|
77
|
+
* not system-specific — a map's background art is the same art whichever
|
|
78
|
+
* system reads the note. It changes no reader: the top level is already the
|
|
79
|
+
* third step of {@link module:engine/system-block.resolveFieldValue}'s order,
|
|
80
|
+
* so the value resolves whichever region carries it. What it tells the
|
|
81
|
+
* author-facing surfaces is which of the two is the field's *home*, so a
|
|
82
|
+
* message about it names `img` rather than sending an author to `sohl.img`
|
|
83
|
+
* (#142).
|
|
61
84
|
* @property {"string"|"number"|"boolean"|"list"|"map"} [kind] - The value's
|
|
62
85
|
* shape, for the frontmatter linter (#19). Distinct from `shape`, which is
|
|
63
86
|
* prose for a reader, and from `read`, which is what the compiler does: a
|
|
@@ -139,64 +162,45 @@ export const BLANK_IS_DEFAULT = Object.freeze({
|
|
|
139
162
|
/* Applying a declaration */
|
|
140
163
|
/* --------------------------------------------------------------------- */
|
|
141
164
|
|
|
142
|
-
/**
|
|
143
|
-
* Write `value` at a dotted path, creating the intermediate objects.
|
|
144
|
-
*
|
|
145
|
-
* Insertion order is the emitted JSON's key order, so a declaration's order is
|
|
146
|
-
* the compiled document's order — which is what lets a field list replace a
|
|
147
|
-
* hand-written object literal without changing a single byte of output.
|
|
148
|
-
*
|
|
149
|
-
* @param {object} target - The object to write into (mutated).
|
|
150
|
-
* @param {string} dotted - Path, e.g. `"locations.flexible"`.
|
|
151
|
-
* @param {any} value - The value to set.
|
|
152
|
-
* @returns {object} `target`, for chaining.
|
|
153
|
-
*/
|
|
154
|
-
export function setPath(target, dotted, value) {
|
|
155
|
-
const parts = dotted.split(".");
|
|
156
|
-
const leaf = parts.pop();
|
|
157
|
-
let cursor = target;
|
|
158
|
-
for (const part of parts) {
|
|
159
|
-
if (
|
|
160
|
-
cursor[part] == null ||
|
|
161
|
-
typeof cursor[part] !== "object" ||
|
|
162
|
-
Array.isArray(cursor[part])
|
|
163
|
-
) {
|
|
164
|
-
cursor[part] = {};
|
|
165
|
-
}
|
|
166
|
-
cursor = cursor[part];
|
|
167
|
-
}
|
|
168
|
-
cursor[leaf] = value;
|
|
169
|
-
return target;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
165
|
/**
|
|
173
166
|
* Read one declared field out of a note's frontmatter.
|
|
174
167
|
*
|
|
168
|
+
* The *position* is resolved by {@link resolveFieldValue} — `<system>.system`
|
|
169
|
+
* first, then the legacy in-block key, then the declared shared source, then
|
|
170
|
+
* the default (#58). The **coercion** is applied here, once, wherever the value
|
|
171
|
+
* came from: a field's `read` is a statement about the field, not about where
|
|
172
|
+
* an author happened to write it, so `weight: "7"` reads as `7` at every one of
|
|
173
|
+
* those positions.
|
|
174
|
+
*
|
|
175
175
|
* @param {FieldSpec} field - The declaration.
|
|
176
176
|
* @param {object} fm - The note's frontmatter.
|
|
177
|
+
* @param {object} [options] - Options.
|
|
178
|
+
* @param {string} [options.block="sohl"] - Which system's block to resolve
|
|
179
|
+
* against. The default is the one block every existing tree authors; a
|
|
180
|
+
* second system passes its own.
|
|
177
181
|
* @returns {any} The value to emit.
|
|
178
182
|
*/
|
|
179
|
-
export function readField(field, fm) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
: field.value;
|
|
184
|
-
}
|
|
185
|
-
const raw = sohlField(fm, field.name, field.default);
|
|
186
|
-
return field.read ? field.read(raw, { fm, field }) : raw;
|
|
183
|
+
export function readField(field, fm, { block = "sohl" } = {}) {
|
|
184
|
+
const { value, from } = resolveFieldValue(field, fm, { block });
|
|
185
|
+
if (from === "value") return value;
|
|
186
|
+
return field.read ? field.read(value, { fm, field }) : value;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
/**
|
|
190
190
|
* Turn a field declaration into the builder it declares.
|
|
191
191
|
*
|
|
192
192
|
* @param {readonly FieldSpec[]} fields - The declaration, in emission order.
|
|
193
|
+
* @param {object} [options] - Options.
|
|
194
|
+
* @param {string} [options.block="sohl"] - Which system's block the builder
|
|
195
|
+
* reads. One declaration compiles against any block, which is what lets two
|
|
196
|
+
* systems declare the same shared source and different destinations.
|
|
193
197
|
* @returns {(fm: object) => object} A `system`-block builder.
|
|
194
198
|
*/
|
|
195
|
-
export function buildFromFields(fields) {
|
|
199
|
+
export function buildFromFields(fields, { block = "sohl" } = {}) {
|
|
196
200
|
return function buildDeclaredSystem(fm) {
|
|
197
201
|
const out = {};
|
|
198
202
|
for (const field of fields) {
|
|
199
|
-
setPath(out, field.to, readField(field, fm));
|
|
203
|
+
setPath(out, field.to, readField(field, fm, { block }));
|
|
200
204
|
}
|
|
201
205
|
return out;
|
|
202
206
|
};
|