@heroiclands/package-build 22.9.2 → 22.10.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 +50 -0
- package/CONTENT.md +33 -0
- package/bin/content-build.mjs +3 -1
- package/docs/api.md +10 -6
- package/docs/content-format.md +384 -200
- package/engine/actor-compiler.mjs +20 -7
- package/engine/address-fields.mjs +404 -0
- package/engine/address-values.mjs +80 -0
- package/engine/address.mjs +769 -0
- package/engine/art-fields.mjs +112 -97
- package/engine/art-slots.mjs +81 -0
- package/engine/base-compiler.mjs +59 -7
- package/engine/bundle-notes.mjs +5 -2
- package/engine/bundles.mjs +17 -10
- package/engine/content-address.mjs +23 -186
- package/engine/content-embeds.mjs +18 -4
- package/engine/content-format-check.mjs +50 -22
- package/engine/content-index.mjs +85 -43
- package/engine/content-links.mjs +105 -104
- package/engine/content-lint.mjs +11 -1
- package/engine/content-tables.mjs +9 -1
- package/engine/field-spec.mjs +20 -4
- package/engine/folder-notes.mjs +44 -15
- package/engine/foundry-entries.mjs +3 -0
- package/engine/frontmatter-lint.mjs +144 -43
- package/engine/frontmatter.mjs +55 -10
- package/engine/generate.mjs +7 -1
- package/engine/helpers.mjs +37 -9
- package/engine/holdings.mjs +41 -12
- package/engine/index-records.mjs +2 -0
- package/engine/infobox-render.mjs +2 -1
- package/engine/infobox.mjs +26 -6
- package/engine/item-compiler.mjs +5 -0
- package/engine/item-docs.mjs +7 -44
- package/engine/map-dot.mjs +2 -0
- package/engine/map-notes.mjs +7 -2
- package/engine/map-places.mjs +42 -10
- package/engine/metadata-index.mjs +60 -5
- package/engine/note-addresses.mjs +310 -0
- package/engine/note-vocabulary.mjs +145 -26
- package/engine/pdf-build.mjs +1 -1
- package/engine/pdf-toc.mjs +17 -2
- package/engine/place-relations.mjs +160 -50
- package/engine/populations.mjs +17 -9
- package/engine/reference-targets.mjs +54 -0
- package/engine/scenes.mjs +5 -2
- package/engine/shortcode-references.mjs +68 -0
- package/engine/site-build.mjs +39 -15
- package/engine/site-index.mjs +57 -75
- package/engine/sql-tables.mjs +43 -9
- package/engine/system-block.mjs +21 -0
- package/engine/system-vocabulary.mjs +46 -0
- package/engine/web-wikilinks.mjs +11 -9
- package/engine/wikilink-syntax.mjs +21 -0
- package/engine/wikilinks.mjs +92 -306
- package/hm3/actors.mjs +8 -24
- package/hm3/item-fields.mjs +15 -5
- package/package.json +1 -1
- package/sohl/infobox.mjs +80 -17
- package/sohl/item-fields.mjs +114 -41
- package/types/engine/address-fields.d.mts +90 -0
- package/types/engine/address-values.d.mts +34 -0
- package/types/engine/address.d.mts +452 -0
- package/types/engine/art-fields.d.mts +59 -53
- package/types/engine/art-slots.d.mts +40 -0
- package/types/engine/base-compiler.d.mts +24 -2
- package/types/engine/bundle-notes.d.mts +2 -2
- package/types/engine/content-address.d.mts +3 -139
- package/types/engine/content-embeds.d.mts +1 -1
- package/types/engine/content-format-check.d.mts +25 -0
- package/types/engine/content-index.d.mts +2 -2
- package/types/engine/content-links.d.mts +6 -5
- package/types/engine/field-spec.d.mts +44 -9
- package/types/engine/folder-notes.d.mts +11 -10
- package/types/engine/frontmatter-lint.d.mts +7 -97
- package/types/engine/frontmatter.d.mts +16 -1
- package/types/engine/helpers.d.mts +2 -2
- package/types/engine/infobox.d.mts +6 -0
- package/types/engine/item-docs.d.mts +1 -35
- package/types/engine/map-notes.d.mts +2 -2
- package/types/engine/note-addresses.d.mts +41 -0
- package/types/engine/note-vocabulary.d.mts +20 -3
- package/types/engine/place-relations.d.mts +12 -1
- package/types/engine/populations.d.mts +17 -2
- package/types/engine/reference-targets.d.mts +9 -0
- package/types/engine/shortcode-references.d.mts +14 -0
- package/types/engine/site-build.d.mts +3 -3
- package/types/engine/site-index.d.mts +9 -30
- package/types/engine/sql-tables.d.mts +2 -2
- package/types/engine/system-block.d.mts +17 -0
- package/types/engine/system-vocabulary.d.mts +16 -0
- package/types/engine/wikilink-syntax.d.mts +7 -0
- package/types/engine/wikilinks.d.mts +6 -109
- package/types/hm3/item-fields.d.mts +5 -0
- package/types/sohl/infobox.d.mts +4 -5
- package/types/sohl/item-fields.d.mts +8 -0
|
@@ -45,13 +45,12 @@ import { DEFAULT_ADDRESS_SCHEME } from "../content-config.mjs";
|
|
|
45
45
|
// The system vocabulary is the `<system>` segment's own registry, and
|
|
46
46
|
// `engine/systems.mjs` imports nothing but `engine/address-charset.mjs`, so
|
|
47
47
|
// the direction is toward the leaf and cannot close a cycle.
|
|
48
|
-
import { NO_SYSTEM,
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
import {
|
|
54
|
-
import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
|
|
48
|
+
import { NO_SYSTEM, isSystemSegment } from "./systems.mjs";
|
|
49
|
+
|
|
50
|
+
// The address grammar, which sits below this module: a page URL is derived from
|
|
51
|
+
// an address, so the rule for reading and writing one is stated there and
|
|
52
|
+
// applied here.
|
|
53
|
+
import { canonicalKey } from "./address.mjs";
|
|
55
54
|
|
|
56
55
|
// `ids.mjs` is a leaf with no local imports — the module note there says why —
|
|
57
56
|
// so an address may hash itself without any risk of closing a cycle.
|
|
@@ -66,6 +65,21 @@ export { DEFAULT_ADDRESS_SCHEME };
|
|
|
66
65
|
*/
|
|
67
66
|
export { NO_SYSTEM };
|
|
68
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The address grammar, under the names its callers hold it by.
|
|
70
|
+
*
|
|
71
|
+
* One parser, one renderer and one reader, all of them in `engine/address.mjs`:
|
|
72
|
+
* {@link canonicalKey} renders the four segments, `expandAddress` applies a
|
|
73
|
+
* position's defaults to a written value and renders the result, and
|
|
74
|
+
* `readCanonicalKey` reads a rendered address back.
|
|
75
|
+
*/
|
|
76
|
+
export {
|
|
77
|
+
canonicalKey,
|
|
78
|
+
expandAddress,
|
|
79
|
+
readCanonicalKey,
|
|
80
|
+
CANONICAL_KEY_SEGMENTS,
|
|
81
|
+
} from "./address.mjs";
|
|
82
|
+
|
|
69
83
|
/** The knowledgebase's mount within this package's site. */
|
|
70
84
|
export const KB_PREFIX = "kb/";
|
|
71
85
|
|
|
@@ -73,7 +87,7 @@ export const KB_PREFIX = "kb/";
|
|
|
73
87
|
* The single path segment a note is addressed by: `type-shortcode`.
|
|
74
88
|
*
|
|
75
89
|
* Lowercased and hyphen-joined by the same rule as the note's canonical key
|
|
76
|
-
* ({@link canonicalKey}
|
|
90
|
+
* ({@link canonicalKey} lowercases too), so it is that
|
|
77
91
|
* key's **last two segments** — which is what makes a manifest entry's `path`
|
|
78
92
|
* derivable from the key it is filed under rather than transported beside it.
|
|
79
93
|
*
|
|
@@ -152,63 +166,6 @@ export function packageAddress(fm) {
|
|
|
152
166
|
return `${addressSlug(fm)}/`;
|
|
153
167
|
}
|
|
154
168
|
|
|
155
|
-
/**
|
|
156
|
-
* The **canonical** address of a note: fully qualified, one spelling per
|
|
157
|
-
* document, and globally unique.
|
|
158
|
-
*
|
|
159
|
-
* The written form of a link is a **partial** address: it may omit leading
|
|
160
|
-
* segments, and each one it omits is filled in by rule rather than left
|
|
161
|
-
* unconstrained. An omitted package (`[[skill-lang]]`) defaults to the citing
|
|
162
|
-
* note's own, so an unqualified link resolves locally and only locally, and a
|
|
163
|
-
* link into another package must name it. An omitted **system** is a
|
|
164
|
-
* *wildcard*, not a default — most links target items, which belong to a
|
|
165
|
-
* system — and the resolver requires exactly one match: none is a dead link,
|
|
166
|
-
* more than one is an ambiguity reported with every candidate named.
|
|
167
|
-
* Everything internal — index keys, cache keys, every lookup — uses this fully
|
|
168
|
-
* qualified form instead, so no consumer has to know what a short form
|
|
169
|
-
* defaulted to or matched.
|
|
170
|
-
*
|
|
171
|
-
* Global uniqueness is what lets a dependency's index merge straight into a
|
|
172
|
-
* local one: the keys cannot collide by accident, so a key already present on
|
|
173
|
-
* merge is a real conflict rather than an artefact of two packages sharing a
|
|
174
|
-
* namespace. `(type, shortcode)` alone is unique only *within* a package, and
|
|
175
|
-
* two independently authored packages reaching for the same short string is a
|
|
176
|
-
* matter of time.
|
|
177
|
-
*
|
|
178
|
-
* **The system segment.** A package may ship content for more than one
|
|
179
|
-
* system, and one note then compiles into a document per system — an actor in
|
|
180
|
-
* `actors-sohl` *and* an actor in `actors-hm3`. Without a system segment both
|
|
181
|
-
* land on one key, so the address cannot name either of them. `harn-ensemble`
|
|
182
|
-
* carries 2,497 such notes.
|
|
183
|
-
*
|
|
184
|
-
* The value is a system id, or the literal **`none`** for a document no game
|
|
185
|
-
* system defines: a journal, a macro, a scene, and an item's documentation
|
|
186
|
-
* journal — which is `none` however many systems the item itself declares,
|
|
187
|
-
* because it is one journal.
|
|
188
|
-
*
|
|
189
|
-
* `none` rather than `any`: every segment of an address is an exact literal,
|
|
190
|
-
* and `any` reads as a wildcard — "matches under any system" — which is not
|
|
191
|
-
* what it does. A resolver written to that misreading would fail silently,
|
|
192
|
-
* since a lookup miss already returns nothing rather than erroring. And not
|
|
193
|
-
* `null` or `~`, both of which are YAML nulls that parse to an absent value and
|
|
194
|
-
* drop the segment entirely.
|
|
195
|
-
*
|
|
196
|
-
* @param {string} pkg - The owning **content** package (`sohl`, `thalorna`) —
|
|
197
|
-
* not the Foundry package, which varies per compilation target.
|
|
198
|
-
* @param {string} system - The system whose document this addresses, or `none`.
|
|
199
|
-
* @param {string} type - The note's `type`.
|
|
200
|
-
* @param {string} shortcode - The note's `shortcode`.
|
|
201
|
-
* @returns {string} `package-system-type-shortcode`, lowercased.
|
|
202
|
-
*/
|
|
203
|
-
export function canonicalKey(pkg, system, type, shortcode) {
|
|
204
|
-
// Checked where an address is *written*, not where one is read: a fetched
|
|
205
|
-
// index naming a system this build has never heard of is data to report,
|
|
206
|
-
// while emitting one is a defect in this build. The registry is closed, so
|
|
207
|
-
// an unknown value here can only be a typo or a system nobody declared.
|
|
208
|
-
assertSystemSegment(system, `the address of ${type}-${shortcode}`);
|
|
209
|
-
return `${pkg}-${system}-${type}-${shortcode}`.toLowerCase();
|
|
210
|
-
}
|
|
211
|
-
|
|
212
169
|
/**
|
|
213
170
|
* Which system a frontmatter key path is written under.
|
|
214
171
|
*
|
|
@@ -218,7 +175,7 @@ export function canonicalKey(pkg, system, type, shortcode) {
|
|
|
218
175
|
* top-level frontmatter, the shared `data:` container, and body prose, which has
|
|
219
176
|
* no key path at all and passes `undefined`.
|
|
220
177
|
*
|
|
221
|
-
* It is the block rather than the field, so
|
|
178
|
+
* It is the block rather than the field, so an `Address` field needs no opinion
|
|
222
179
|
* about systems and no per-field table has to be kept in step with the schema.
|
|
223
180
|
*
|
|
224
181
|
* The first segment must **be** a declared system, not merely look like one:
|
|
@@ -235,126 +192,6 @@ export function blockSystem(keyPath) {
|
|
|
235
192
|
return isSystemSegment(first) && first !== NO_SYSTEM ? first : NO_SYSTEM;
|
|
236
193
|
}
|
|
237
194
|
|
|
238
|
-
/**
|
|
239
|
-
* Expand a written address to the one canonical address it names.
|
|
240
|
-
*
|
|
241
|
-
* **An omitted segment defaults from where the link is written** — it is
|
|
242
|
-
* not a wildcard, and resolution is not a search. Package omitted means the
|
|
243
|
-
* citing note's own; system omitted means {@link blockSystem} of the key path it
|
|
244
|
-
* was written under. So every short form has exactly one expansion, computed
|
|
245
|
-
* before anything is looked up, and there is no candidate set to disambiguate.
|
|
246
|
-
*
|
|
247
|
-
* **Under `none`, a system-bearing type addresses its documentation journal.**
|
|
248
|
-
* A note's `none` address *is* its `doc<type>` entry — the Item is the one with
|
|
249
|
-
* a system — so a prose `[[affiliation-sirvadar|…]]` names the page, which is
|
|
250
|
-
* almost always what prose means. A link that means the Item states the system
|
|
251
|
-
* and gets it. This is the defaulting rule applied, not an exception carved out
|
|
252
|
-
* of it.
|
|
253
|
-
*
|
|
254
|
-
* **Only a type whose own document carries a system is redirected.** A `macro`
|
|
255
|
-
* and the map types have documentation journals too, but their own documents
|
|
256
|
-
* are core ones and already live at `none` — so `<pkg>-none-macro-x` names the
|
|
257
|
-
* Macro and `<pkg>-none-docmacro-x` its journal, two live addresses that the
|
|
258
|
-
* redirect would collapse into one. The test is the note type's own system,
|
|
259
|
-
* not merely whether it has a doc entry.
|
|
260
|
-
*
|
|
261
|
-
* A `doc<type>` written explicitly is `none` **wherever** it appears, even
|
|
262
|
-
* inside a system block: no game system defines a JournalEntry, so there is no
|
|
263
|
-
* other system for one to belong to.
|
|
264
|
-
*
|
|
265
|
-
* @param {{type: string, shortcode: string, package?: string, system?: string,
|
|
266
|
-
* itemDoc?: boolean}} read - A qualifier, as `readQualifier` returns one.
|
|
267
|
-
* @param {{package: string, system?: string}} where - The citing context: the
|
|
268
|
-
* tree's own content package, and the system of the block the link sits in.
|
|
269
|
-
* @returns {string} The canonical `package-system-type-shortcode`.
|
|
270
|
-
*/
|
|
271
|
-
export function expandAddress(read, where) {
|
|
272
|
-
const pkg = read.package ?? where.package;
|
|
273
|
-
// A documentation journal is a core document, so it is `none` however it was
|
|
274
|
-
// reached, and an **asset** is `none` for a stronger reason: a file belongs
|
|
275
|
-
// to no game system, so the segment is a property of the type rather than of
|
|
276
|
-
// where the reference was written. Without that, a `sohl:` block naming
|
|
277
|
-
// `icon-anvil` would expand to `sohl-sohl-icon-anvil` and resolve to
|
|
278
|
-
// nothing, which is exactly where an embedded item's art is written.
|
|
279
|
-
// Otherwise the block's system, which body prose reports as `none`.
|
|
280
|
-
const system =
|
|
281
|
-
read.itemDoc || isAssetType(read.type) ?
|
|
282
|
-
NO_SYSTEM
|
|
283
|
-
: (read.system ?? where.system ?? NO_SYSTEM);
|
|
284
|
-
const redirected = system === NO_SYSTEM && isSystemBearing(read.type);
|
|
285
|
-
const type = read.itemDoc || redirected ? `doc${read.type}` : read.type;
|
|
286
|
-
return canonicalKey(pkg, system, type, read.shortcode);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Whether a note type's **own** document carries a game system.
|
|
291
|
-
*
|
|
292
|
-
* True for the types some shipped map compiles into an Item or an Actor; false
|
|
293
|
-
* for the core-document types — `doc`, `lore`, `place`, `scenario`, `macro` and
|
|
294
|
-
* the map types — whose documents Foundry itself defines and which therefore
|
|
295
|
-
* already live at `none`.
|
|
296
|
-
*
|
|
297
|
-
* It is what {@link expandAddress} tests rather than {@link hasDocEntry}: a
|
|
298
|
-
* `macro` has a documentation journal *and* a `none` address of its own, so
|
|
299
|
-
* redirecting on "has a doc entry" would collapse two live addresses into one
|
|
300
|
-
* and a `[[macro-autoattack|]]` would stop naming the Macro.
|
|
301
|
-
*
|
|
302
|
-
* @param {string} type - The note type.
|
|
303
|
-
* @returns {boolean} True when the type compiles into a system document.
|
|
304
|
-
*/
|
|
305
|
-
function isSystemBearing(type) {
|
|
306
|
-
return systemOf(type, KNOWN_DOCUMENT_SUBTYPE_MAPS) !== NO_SYSTEM;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* How many segments a canonical key has, and therefore how many the reader
|
|
311
|
-
* below counts.
|
|
312
|
-
*
|
|
313
|
-
* Named rather than written as a literal because it is the *grammar*, not an
|
|
314
|
-
* implementation detail of one function: it is the number a change to the
|
|
315
|
-
* address form would move, and the thing a reader of that change has to find.
|
|
316
|
-
*
|
|
317
|
-
* @type {number}
|
|
318
|
-
*/
|
|
319
|
-
export const CANONICAL_KEY_SEGMENTS = 4;
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* Reads a canonical key back into its parts.
|
|
323
|
-
*
|
|
324
|
-
* Parsing is plain positional counting: split on the separator, require
|
|
325
|
-
* {@link CANONICAL_KEY_SEGMENTS} of them, and assign each position its field.
|
|
326
|
-
* **The charset rule is what makes that sound** — every segment matches
|
|
327
|
-
* `ADDRESS_SEGMENT_PATTERN` (`engine/address-charset.mjs`), so the hyphen is
|
|
328
|
-
* purely a separator and the count alone determines every field. That is
|
|
329
|
-
* enforced at each of the three sources rather than assumed of
|
|
330
|
-
* the data: shortcodes by `content-lint.mjs`, `contentPackage` by
|
|
331
|
-
* `defineConfig`, and types are bare words. Were any of them free to
|
|
332
|
-
* carry a hyphen, no amount of counting would recover the fields and the reader
|
|
333
|
-
* would need a vocabulary to match against instead.
|
|
334
|
-
*
|
|
335
|
-
* **Nothing to read and nothing readable are different answers.** A key that
|
|
336
|
-
* cannot be canonical — `harn-adventures-sohl-skill-melee`, five segments,
|
|
337
|
-
* because the package name carries the separator — yields `null`, while an
|
|
338
|
-
* absent or blank input yields `undefined`. Both are falsy, so
|
|
339
|
-
* every call site (all of which test the result for truthiness) is unaffected;
|
|
340
|
-
* the distinction is there so a caller reporting "this key is unreadable" can
|
|
341
|
-
* tell that it has a key to report about.
|
|
342
|
-
*
|
|
343
|
-
* @param {unknown} key - A canonical key, or nothing.
|
|
344
|
-
* @returns {{package: string, system: string, type: string, shortcode: string}
|
|
345
|
-
* |null|undefined}
|
|
346
|
-
* The parts; `null` when there is a string that is not in canonical form;
|
|
347
|
-
* `undefined` when there is no key at all.
|
|
348
|
-
*/
|
|
349
|
-
export function readCanonicalKey(key) {
|
|
350
|
-
if (key == null || key === "") return undefined;
|
|
351
|
-
const parts = String(key).split("-");
|
|
352
|
-
if (parts.length !== CANONICAL_KEY_SEGMENTS) return null;
|
|
353
|
-
const [pkg, system, type, shortcode] = parts;
|
|
354
|
-
if (!pkg || !system || !type || !shortcode) return null;
|
|
355
|
-
return { package: pkg, system, type, shortcode };
|
|
356
|
-
}
|
|
357
|
-
|
|
358
195
|
/**
|
|
359
196
|
* Where this build serves each package, keyed by package name.
|
|
360
197
|
*
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
* @module
|
|
60
60
|
*/
|
|
61
61
|
|
|
62
|
+
import { parseAddress } from "./address.mjs";
|
|
62
63
|
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
63
64
|
import { parseImageDirective, standsAlone } from "./content-images.mjs";
|
|
64
65
|
import { positionInBody } from "./diagnostics.mjs";
|
|
@@ -118,16 +119,20 @@ export const EMBED_PATTERN = /!\[\[([^\]\n]+)\]\](\{[^}\n]*\})?/g;
|
|
|
118
119
|
* @param {string} body - The note's markdown, without its frontmatter.
|
|
119
120
|
* @returns {ParsedEmbed[]} One entry per embed.
|
|
120
121
|
*/
|
|
121
|
-
export function embedsIn(body) {
|
|
122
|
+
export function embedsIn(body, context) {
|
|
122
123
|
const text = String(body ?? "");
|
|
123
124
|
return matchAllOutsideCode(text, new RegExp(EMBED_PATTERN.source, "g")).map((match) => {
|
|
124
125
|
const index = /** @type {number} */ (match.index);
|
|
125
126
|
const parsed = parseWikilink(match[1] ?? "");
|
|
126
127
|
const length = match[0].length;
|
|
128
|
+
const written = parsed.anchor ? `${parsed.target}#${parsed.anchor}` : parsed.target;
|
|
127
129
|
return {
|
|
128
130
|
all: match[0],
|
|
129
131
|
inner: parsed.inner,
|
|
130
|
-
written
|
|
132
|
+
written,
|
|
133
|
+
...(context ?
|
|
134
|
+
{ target: parseAddress(written, { ...context, type: EMBED_DEFAULT_TYPE }) }
|
|
135
|
+
: {}),
|
|
131
136
|
display: parsed.display,
|
|
132
137
|
labelled: parsed.labelled,
|
|
133
138
|
directive: match[2] ?? "",
|
|
@@ -157,7 +162,11 @@ export function resolveEmbed(index, embed) {
|
|
|
157
162
|
// reported before anything is looked up.
|
|
158
163
|
if (!embed.labelled) return { reason: "unlabelled", target: embed.inner };
|
|
159
164
|
|
|
160
|
-
const read = readAssetAddress(
|
|
165
|
+
const read = readAssetAddress(
|
|
166
|
+
index,
|
|
167
|
+
embed.target?.reason ? embed.written : (embed.target ?? embed.written),
|
|
168
|
+
EMBED_DEFAULT_TYPE,
|
|
169
|
+
);
|
|
161
170
|
if (read.record) {
|
|
162
171
|
return { pathname: read.pathname };
|
|
163
172
|
}
|
|
@@ -245,7 +254,12 @@ export function resolveEmbeds(body, { index }) {
|
|
|
245
254
|
let out = "";
|
|
246
255
|
let last = 0;
|
|
247
256
|
|
|
248
|
-
for (const embed of embedsIn(text
|
|
257
|
+
for (const embed of embedsIn(text, {
|
|
258
|
+
package: index?.contentPackage ?? index?.packageId,
|
|
259
|
+
system: "none",
|
|
260
|
+
types: index?.types,
|
|
261
|
+
packages: index?.packages,
|
|
262
|
+
})) {
|
|
249
263
|
problems.push(...embedProblems(embed));
|
|
250
264
|
const resolved = resolveEmbed(index, embed);
|
|
251
265
|
if (!("pathname" in resolved)) {
|
|
@@ -103,6 +103,7 @@ import { SCHEMA_ARTIFACT_VERSION } from "./schema-check.mjs";
|
|
|
103
103
|
import { authoredFields } from "./field-spec.mjs";
|
|
104
104
|
import { positionInFrontmatter } from "./diagnostics.mjs";
|
|
105
105
|
import { RETIRED_FIELD_ALIASES } from "./retired-fields.mjs";
|
|
106
|
+
import { authoredKey } from "./system-block.mjs";
|
|
106
107
|
|
|
107
108
|
/**
|
|
108
109
|
* Every field path any subtype of a published schema declares.
|
|
@@ -493,11 +494,54 @@ export function fieldDriftMessage({ noteType, source, target, name, to }) {
|
|
|
493
494
|
* A note-level source — the `subType` several tables name — carries no prefix
|
|
494
495
|
* and is returned unchanged.
|
|
495
496
|
*
|
|
497
|
+
* **What that normalization cannot see is whether the declaration reads the
|
|
498
|
+
* source at all.** Holding the two spellings equal is right for the question
|
|
499
|
+
* asked here — do the row and the declaration agree about where the value *goes*
|
|
500
|
+
* — and blind to whether the declaration can reach where it *is*. That is
|
|
501
|
+
* `tests/mapped-data-source-is-read.test.ts`, which compares the same pair on
|
|
502
|
+
* the source rather than the target.
|
|
503
|
+
*
|
|
496
504
|
* @param {string} path - The source, as either side wrote it.
|
|
497
505
|
* @returns {string} The source without its `data.` prefix.
|
|
498
506
|
*/
|
|
499
|
-
|
|
500
|
-
|
|
507
|
+
const sharedSource = authoredKey;
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* The declaration that compiles one mapping row, and what is left over.
|
|
511
|
+
*
|
|
512
|
+
* The longest declared name the row's source sits under: a field declared
|
|
513
|
+
* `impact.die` claims `data.impact.die` ahead of any field declared `impact`,
|
|
514
|
+
* and a field declared `charges` claims `data.charges.max` with `.max` left
|
|
515
|
+
* over. Compared on the normalized spelling, so a field naming its source under
|
|
516
|
+
* `data:` matches at the same length a bare one does.
|
|
517
|
+
*
|
|
518
|
+
* Exported because two readers ask it and must agree: {@link
|
|
519
|
+
* checkDeclaredFields}, which holds the row's target to the declaration's, and
|
|
520
|
+
* the guard that holds the declaration's *source* to the row's — a second copy
|
|
521
|
+
* of the match would let the two disagree about which declaration a row is even
|
|
522
|
+
* about.
|
|
523
|
+
*
|
|
524
|
+
* @param {string} source - The row's shared source, as either side spells it.
|
|
525
|
+
* @param {readonly object[]} fields - The type's authored declarations.
|
|
526
|
+
* @returns {{field: object, rest: string}|undefined} The declaration and the
|
|
527
|
+
* remainder of the source beneath its name, or nothing where no declaration
|
|
528
|
+
* names it.
|
|
529
|
+
*/
|
|
530
|
+
export function declarationFor(source, fields) {
|
|
531
|
+
const path = sharedSource(source);
|
|
532
|
+
let field;
|
|
533
|
+
let rest;
|
|
534
|
+
let matched = "";
|
|
535
|
+
for (const candidate of fields ?? []) {
|
|
536
|
+
const name = sharedSource(candidate.name);
|
|
537
|
+
const remainder = under(path, name);
|
|
538
|
+
if (remainder === undefined) continue;
|
|
539
|
+
if (field && name.length <= matched.length) continue;
|
|
540
|
+
field = candidate;
|
|
541
|
+
matched = name;
|
|
542
|
+
rest = remainder;
|
|
543
|
+
}
|
|
544
|
+
return field === undefined ? undefined : { field, rest: /** @type {string} */ (rest) };
|
|
501
545
|
}
|
|
502
546
|
|
|
503
547
|
/**
|
|
@@ -550,32 +594,16 @@ export function checkDeclaredFields({ format, itemFields, system, severity = "er
|
|
|
550
594
|
|
|
551
595
|
const authored = authoredFields(declared[noteType]);
|
|
552
596
|
for (const claim of claimsFor(format, noteType, system)) {
|
|
553
|
-
const source = sharedSource(claim.source);
|
|
554
597
|
const target = claim.target.replace(/^system\./, "");
|
|
555
|
-
|
|
556
|
-
// declared `impact.die` claims `data.impact.die` ahead of any
|
|
557
|
-
// field declared `impact`. Compared on the normalized spelling, so
|
|
558
|
-
// a field that has moved its source under `data:` is the same
|
|
559
|
-
// length it was.
|
|
560
|
-
let match;
|
|
561
|
-
let rest;
|
|
562
|
-
let matched = "";
|
|
563
|
-
for (const field of authored) {
|
|
564
|
-
const name = sharedSource(field.name);
|
|
565
|
-
const remainder = under(source, name);
|
|
566
|
-
if (remainder === undefined) continue;
|
|
567
|
-
if (match && name.length <= matched.length) continue;
|
|
568
|
-
match = field;
|
|
569
|
-
matched = name;
|
|
570
|
-
rest = remainder;
|
|
571
|
-
}
|
|
598
|
+
const found = declarationFor(claim.source, authored);
|
|
572
599
|
// No declaration names it. That is coverage, not a contradiction —
|
|
573
600
|
// the specification maps fields no builder emits yet, which is the
|
|
574
601
|
// ordinary mid-migration state and what `schema-check.mjs`
|
|
575
602
|
// already reports as unemitted.
|
|
576
|
-
if (!
|
|
603
|
+
if (!found) continue;
|
|
604
|
+
const match = found.field;
|
|
577
605
|
fields += 1;
|
|
578
|
-
if (under(target, match.to) === rest) continue;
|
|
606
|
+
if (under(target, match.to) === found.rest) continue;
|
|
579
607
|
findings.push({
|
|
580
608
|
file: format.file,
|
|
581
609
|
line: claim.line,
|
package/engine/content-index.mjs
CHANGED
|
@@ -86,6 +86,14 @@
|
|
|
86
86
|
* @module
|
|
87
87
|
*/
|
|
88
88
|
|
|
89
|
+
import { isAddressSegment } from "./address-charset.mjs";
|
|
90
|
+
import { positionOfYamlPath } from "./diagnostics.mjs";
|
|
91
|
+
import {
|
|
92
|
+
decodeNoteAddresses,
|
|
93
|
+
decodeIndexAddresses,
|
|
94
|
+
noteAddressContext,
|
|
95
|
+
encodeAddresses,
|
|
96
|
+
} from "./note-addresses.mjs";
|
|
89
97
|
import fs from "node:fs";
|
|
90
98
|
import path from "node:path";
|
|
91
99
|
|
|
@@ -442,11 +450,26 @@ export function buildIndexRecord({
|
|
|
442
450
|
body,
|
|
443
451
|
bodyLine,
|
|
444
452
|
manifest,
|
|
453
|
+
addressContext,
|
|
445
454
|
}) {
|
|
446
455
|
assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage);
|
|
447
456
|
|
|
448
457
|
const posix = relPath.split(path.sep).join("/");
|
|
449
458
|
const folder = posix.includes("/") ? posix.slice(0, posix.lastIndexOf("/")) : "";
|
|
459
|
+
try {
|
|
460
|
+
decodeNoteAddresses(frontmatter, addressContext ?? { package: contentPackage });
|
|
461
|
+
} catch (error) {
|
|
462
|
+
error.file = absPath ?? relPath;
|
|
463
|
+
if (absPath && error.keyPath) {
|
|
464
|
+
const yamlText = fs
|
|
465
|
+
.readFileSync(absPath, "utf8")
|
|
466
|
+
.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
|
|
467
|
+
const position = positionOfYamlPath(yamlText, error.keyPath, { key: error.addressKey });
|
|
468
|
+
if (position.line) position.line++;
|
|
469
|
+
error.position = position;
|
|
470
|
+
}
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
450
473
|
const address = noteAddress(frontmatter, contentPackage);
|
|
451
474
|
// Derived from the address the note *would* hold, because a stub still
|
|
452
475
|
// compiles every document derived from `data:` — an unwritten arcane talent
|
|
@@ -463,45 +486,48 @@ export function buildIndexRecord({
|
|
|
463
486
|
const stub = isStubNote(frontmatter, body);
|
|
464
487
|
|
|
465
488
|
return /** @type {Record<string, any>} */ (
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
489
|
+
decodeIndexAddresses(
|
|
490
|
+
{
|
|
491
|
+
...frontmatter,
|
|
492
|
+
package: contentPackage,
|
|
493
|
+
address: stub ? null : address,
|
|
494
|
+
nameAscii: asciiName(frontmatter?.name?.full),
|
|
495
|
+
aliasesAscii: asciiAliases(frontmatter?.name?.aliases),
|
|
496
|
+
// Each anchor carries the link that reaches it, so a section is
|
|
497
|
+
// addressable from the index without anyone re-deriving how an
|
|
498
|
+
// anchor is spelled — and its file line, so an editor can jump
|
|
499
|
+
// there rather than search for the heading.
|
|
500
|
+
anchors:
|
|
501
|
+
stub ? null : (
|
|
502
|
+
collectAnchors(body, bodyLine).map((a) => ({
|
|
503
|
+
...a,
|
|
504
|
+
link: address ? `${address.slug}#${a.slug}` : null,
|
|
505
|
+
}))
|
|
506
|
+
),
|
|
507
|
+
foundry: foundryBlock(
|
|
508
|
+
entries?.own,
|
|
509
|
+
systemOf(frontmatter?.type, KNOWN_DOCUMENT_SUBTYPE_MAPS),
|
|
482
510
|
),
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
path: posix,
|
|
501
|
-
folder,
|
|
502
|
-
name: path.basename(posix, ".md"),
|
|
511
|
+
// Forward link to the note's documentation journal, which is its
|
|
512
|
+
// own record. Named rather than nested, because the journal is a
|
|
513
|
+
// separate document with its own address — see `buildDocRecord`.
|
|
514
|
+
documentation: entries?.doc?.key ?? null,
|
|
515
|
+
file: {
|
|
516
|
+
// Relative to the content root, and deliberately not absolute.
|
|
517
|
+
// An absolute path is a fact about the machine that built the
|
|
518
|
+
// index, not about the content: it would differ between two
|
|
519
|
+
// checkouts of the same tree, so the file would stop being
|
|
520
|
+
// byte-stable, and a published copy would carry someone's home
|
|
521
|
+
// directory and be wrong for every reader. Anyone holding the
|
|
522
|
+
// index knows the root it was built from, and `root + path` is
|
|
523
|
+
// the absolute form whenever it is wanted.
|
|
524
|
+
path: posix,
|
|
525
|
+
folder,
|
|
526
|
+
name: path.basename(posix, ".md"),
|
|
527
|
+
},
|
|
503
528
|
},
|
|
504
|
-
|
|
529
|
+
addressContext ?? { package: contentPackage },
|
|
530
|
+
)
|
|
505
531
|
);
|
|
506
532
|
}
|
|
507
533
|
|
|
@@ -599,7 +625,7 @@ function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anc
|
|
|
599
625
|
*/
|
|
600
626
|
export function collectContentIndex(
|
|
601
627
|
contentBase,
|
|
602
|
-
{ contentPackage, skipDirectories, assetsBase, manifest, problems },
|
|
628
|
+
{ contentPackage, skipDirectories, assetsBase, manifest, problems, addressContext },
|
|
603
629
|
) {
|
|
604
630
|
const records = [];
|
|
605
631
|
// Passed through rather than defaulted away: an absent scope is the
|
|
@@ -639,8 +665,20 @@ export function collectContentIndex(
|
|
|
639
665
|
body,
|
|
640
666
|
bodyLine,
|
|
641
667
|
manifest,
|
|
668
|
+
addressContext,
|
|
642
669
|
});
|
|
643
670
|
} catch (err) {
|
|
671
|
+
if (fm.shortcode && !isAddressSegment(fm.shortcode) && err.keyPath) {
|
|
672
|
+
err.identity = { type: fm.type, shortcode: fm.shortcode };
|
|
673
|
+
err.keyPath = ["shortcode"];
|
|
674
|
+
err.message = `shortcode "${fm.shortcode}" is not strictly alphanumeric — lowercase letters and digits only`;
|
|
675
|
+
const yamlText = fs
|
|
676
|
+
.readFileSync(absPath, "utf8")
|
|
677
|
+
.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
|
|
678
|
+
err.position = positionOfYamlPath(yamlText, err.keyPath);
|
|
679
|
+
if (err.position.line) err.position.line++;
|
|
680
|
+
}
|
|
681
|
+
err.file = absPath;
|
|
644
682
|
// No `problems` array means the caller wants the old contract: a
|
|
645
683
|
// note that cannot be recorded fails the derivation outright, which
|
|
646
684
|
// is right for the *emitter* — an index quietly missing a note
|
|
@@ -653,10 +691,11 @@ export function collectContentIndex(
|
|
|
653
691
|
...(err.position ?? {}),
|
|
654
692
|
severity: "error",
|
|
655
693
|
message: String(err.message),
|
|
694
|
+
...(err.identity ? { identity: err.identity } : {}),
|
|
656
695
|
});
|
|
657
696
|
continue;
|
|
658
697
|
}
|
|
659
|
-
records.push(record);
|
|
698
|
+
records.push(decodeIndexAddresses(record, addressContext ?? { package: contentPackage }));
|
|
660
699
|
|
|
661
700
|
// An item note is two documents, so it is two records. Derived from
|
|
662
701
|
// the address the note *would* hold rather than from the one its
|
|
@@ -703,13 +742,15 @@ export function collectContentIndex(
|
|
|
703
742
|
records.sort(
|
|
704
743
|
(a, b) =>
|
|
705
744
|
recordPath(a).localeCompare(recordPath(b), "en") ||
|
|
706
|
-
String(a.address?.canonical ?? "").localeCompare(
|
|
707
|
-
String(b.address?.canonical ?? ""),
|
|
745
|
+
String(encodeAddresses(a.address?.canonical) ?? "").localeCompare(
|
|
746
|
+
String(encodeAddresses(b.address?.canonical) ?? ""),
|
|
708
747
|
"en",
|
|
709
748
|
) ||
|
|
710
749
|
String(a.id ?? "").localeCompare(String(b.id ?? ""), "en"),
|
|
711
750
|
);
|
|
712
|
-
return records
|
|
751
|
+
return records.map((record) =>
|
|
752
|
+
decodeIndexAddresses(record, addressContext ?? { package: contentPackage }),
|
|
753
|
+
);
|
|
713
754
|
}
|
|
714
755
|
|
|
715
756
|
/**
|
|
@@ -722,7 +763,7 @@ export function collectContentIndex(
|
|
|
722
763
|
*/
|
|
723
764
|
export function serializeContentIndex(records) {
|
|
724
765
|
if (records.length === 0) return "";
|
|
725
|
-
return `${records.map((r) => JSON.stringify(r)).join("\n")}\n`;
|
|
766
|
+
return `${records.map((r) => JSON.stringify(sortKeysDeep(encodeAddresses(r)))).join("\n")}\n`;
|
|
726
767
|
}
|
|
727
768
|
|
|
728
769
|
/**
|
|
@@ -781,6 +822,7 @@ export function indexRecordsFor({
|
|
|
781
822
|
}
|
|
782
823
|
return collectContentIndex(tree, {
|
|
783
824
|
contentPackage: resolved.contentPackage,
|
|
825
|
+
addressContext: noteAddressContext(resolved),
|
|
784
826
|
skipDirectories: skipDirectories ?? resolved.skipDirectories,
|
|
785
827
|
assetsBase: assetsBase ?? resolved.paths.assets,
|
|
786
828
|
// Only the identities a UUID is a function of — see emitContentIndex.
|