@heroiclands/package-build 17.1.0 → 18.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1773 -0
- package/CONTENT.md +288 -72
- package/README.md +1 -1
- package/bin/content-build.mjs +218 -137
- package/bin/package-build.mjs +43 -0
- package/content-config.mjs +62 -67
- package/docs/content-format.md +768 -58
- package/engine/actor-compiler.mjs +586 -0
- package/engine/address-charset.mjs +11 -8
- package/engine/address-diff.mjs +266 -22
- package/engine/anchored-sections.mjs +83 -0
- package/engine/anchors.mjs +83 -0
- package/engine/base-compiler.mjs +148 -18
- package/engine/bundle-notes.mjs +276 -0
- package/engine/bundles.mjs +307 -0
- package/engine/code-fences.mjs +103 -0
- package/engine/compile-corpus.mjs +89 -0
- package/engine/content-address.mjs +267 -5
- package/engine/content-format-check.mjs +54 -11
- package/engine/content-format.mjs +37 -5
- package/engine/content-index.mjs +394 -111
- package/engine/content-links.mjs +185 -77
- package/engine/content-lint.mjs +232 -14
- package/engine/content-package.mjs +2 -1
- package/engine/content-tables.mjs +99 -3
- package/engine/document-subtypes.mjs +49 -6
- package/engine/field-reference.mjs +1 -1
- package/engine/field-spec.mjs +56 -6
- package/engine/folder-notes.mjs +470 -0
- package/engine/foreign-catalog.mjs +195 -4
- package/engine/{manifest-emit.mjs → foundry-entries.mjs} +78 -81
- package/engine/frontmatter-lint.mjs +245 -19
- package/engine/frontmatter.mjs +88 -8
- package/engine/generate.mjs +304 -13
- package/engine/helpers.mjs +293 -64
- package/engine/ids.mjs +137 -2
- package/engine/index-records.mjs +126 -0
- package/engine/index.mjs +23 -5
- package/engine/item-compiler.mjs +349 -0
- package/engine/item-docs.mjs +7 -1
- package/engine/item-registry.mjs +6 -0
- package/engine/journals.mjs +93 -27
- package/engine/macros.mjs +4 -2
- package/engine/metadata-index.mjs +495 -0
- package/engine/note-claims.mjs +146 -25
- package/engine/note-ids.mjs +112 -0
- package/engine/note-renames.mjs +134 -0
- package/engine/note-schemas.mjs +25 -0
- package/engine/note-vocabulary.mjs +110 -27
- package/engine/pack-config.mjs +4 -0
- package/engine/pack-router.mjs +23 -0
- package/engine/retired-fields.mjs +137 -3
- package/engine/scenes.mjs +66 -34
- package/engine/schema-check.mjs +58 -17
- package/engine/site-build.mjs +71 -12
- package/engine/site-index.mjs +20 -7
- package/engine/sql-tables.mjs +485 -0
- package/engine/subtype-registry.mjs +102 -0
- package/engine/system-block.mjs +96 -12
- package/engine/systems.mjs +201 -0
- package/engine/web-wikilinks.mjs +50 -20
- package/engine/wikilink-syntax.mjs +17 -8
- package/engine/wikilinks.mjs +124 -51
- package/engine/yaml-lint.mjs +282 -0
- package/hm3/actors.mjs +352 -0
- package/hm3/default-item-art.mjs +75 -0
- package/hm3/document-subtypes.mjs +134 -0
- package/hm3/index.mjs +56 -0
- package/hm3/item-builders.mjs +84 -0
- package/hm3/item-fields.mjs +180 -0
- package/hm3/items.mjs +96 -0
- package/hm3/template-priority.mjs +85 -0
- package/labels.mjs +191 -0
- package/manifest.mjs +43 -2
- package/package.json +19 -4
- package/release.mjs +62 -7
- package/sohl/actors.mjs +33 -487
- package/sohl/being-info.mjs +16 -7
- package/sohl/default-item-art.mjs +14 -3
- package/sohl/document-subtypes.mjs +16 -10
- package/sohl/item-builders.mjs +14 -5
- package/sohl/item-fields.mjs +68 -7
- package/sohl/items.mjs +44 -258
- package/sohl/note-schemas.mjs +2 -2
- package/types/content-config.d.mts +21 -42
- package/types/engine/actor-compiler.d.mts +204 -0
- package/types/engine/address-charset.d.mts +11 -8
- package/types/engine/address-diff.d.mts +53 -5
- package/types/engine/anchored-sections.d.mts +21 -0
- package/types/engine/anchors.d.mts +20 -0
- package/types/engine/base-compiler.d.mts +17 -17
- package/types/engine/bundle-notes.d.mts +173 -0
- package/types/engine/bundles.d.mts +60 -0
- package/types/engine/code-fences.d.mts +43 -0
- package/types/engine/compile-corpus.d.mts +32 -0
- package/types/engine/content-address.d.mts +205 -5
- package/types/engine/content-format-check.d.mts +6 -2
- package/types/engine/content-format.d.mts +57 -1
- package/types/engine/content-index.d.mts +64 -80
- package/types/engine/content-links.d.mts +52 -7
- package/types/engine/content-lint.d.mts +10 -1
- package/types/engine/content-package.d.mts +2 -1
- package/types/engine/content-tables.d.mts +21 -39
- package/types/engine/document-subtypes.d.mts +37 -3
- package/types/engine/field-spec.d.mts +76 -5
- package/types/engine/folder-notes.d.mts +159 -0
- package/types/engine/foreign-catalog.d.mts +53 -0
- package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +16 -38
- package/types/engine/frontmatter-lint.d.mts +10 -2
- package/types/engine/frontmatter.d.mts +64 -0
- package/types/engine/generate.d.mts +38 -0
- package/types/engine/helpers.d.mts +94 -30
- package/types/engine/ids.d.mts +96 -0
- package/types/engine/index-records.d.mts +68 -0
- package/types/engine/index.d.mts +9 -3
- package/types/engine/item-compiler.d.mts +131 -0
- package/types/engine/journals.d.mts +47 -9
- package/types/engine/metadata-index.d.mts +226 -0
- package/types/engine/note-claims.d.mts +57 -11
- package/types/engine/note-ids.d.mts +38 -0
- package/types/engine/note-renames.d.mts +102 -0
- package/types/engine/note-vocabulary.d.mts +44 -8
- package/types/engine/retired-fields.d.mts +75 -0
- package/types/engine/scenes.d.mts +3 -2
- package/types/engine/schema-check.d.mts +25 -4
- package/types/engine/site-build.d.mts +4 -4
- package/types/engine/site-index.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +185 -0
- package/types/engine/subtype-registry.d.mts +49 -0
- package/types/engine/system-block.d.mts +40 -1
- package/types/engine/systems.d.mts +106 -0
- package/types/engine/web-wikilinks.d.mts +4 -2
- package/types/engine/wikilink-syntax.d.mts +10 -3
- package/types/engine/wikilinks.d.mts +41 -13
- package/types/engine/yaml-lint.d.mts +107 -0
- package/types/hm3/actors.d.mts +48 -0
- package/types/hm3/default-item-art.d.mts +42 -0
- package/types/hm3/document-subtypes.d.mts +24 -0
- package/types/hm3/index.d.mts +7 -0
- package/types/hm3/item-builders.d.mts +11 -0
- package/types/hm3/item-fields.d.mts +12 -0
- package/types/hm3/items.d.mts +23 -0
- package/types/hm3/template-priority.d.mts +21 -0
- package/types/manifest.d.mts +22 -1
- package/types/release.d.mts +9 -4
- package/types/sohl/actors.d.mts +7 -74
- package/types/sohl/being-info.d.mts +8 -27
- package/types/sohl/default-item-art.d.mts +5 -3
- package/types/sohl/items.d.mts +17 -32
- package/engine/foreign-manifests.mjs +0 -126
- package/engine/kb-manifest.mjs +0 -490
- package/types/engine/foreign-manifests.d.mts +0 -43
- package/types/engine/kb-manifest.d.mts +0 -241
package/engine/system-block.mjs
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* | `<system>.flags` | `document.flags` |
|
|
29
29
|
* | `<system>.pack` | *nothing on the document* — a build directive naming the compendium |
|
|
30
30
|
*
|
|
31
|
-
* Everything else a system declares — `
|
|
31
|
+
* Everything else a system declares — `templatePriority`, `kbcat`, and the
|
|
32
32
|
* *generators* `items` and `attributes`, which expand into embedded documents
|
|
33
33
|
* rather than mapping anywhere — sits directly under the block, which is why it
|
|
34
34
|
* has to be somewhere the schema cannot claim.
|
|
@@ -46,12 +46,35 @@
|
|
|
46
46
|
* order for a system `S` is:
|
|
47
47
|
*
|
|
48
48
|
* 1. `S.system.<to>` — authored directly, wins outright;
|
|
49
|
-
* 2. `S.<
|
|
50
|
-
* until #126 moves it;
|
|
49
|
+
* 2. `S.<legacyKey>` — the legacy in-block position the corpus still writes,
|
|
50
|
+
* kept until #126 moves it;
|
|
51
51
|
* 3. the shared top-level property the field **declares** as its source, which
|
|
52
52
|
* may be a dotted path (`data.portrait`) rather than a sibling key;
|
|
53
53
|
* 4. the field's own default.
|
|
54
54
|
*
|
|
55
|
+
* ## Steps 2 and 3 are two declarations, because they are two positions
|
|
56
|
+
*
|
|
57
|
+
* They used to be one: both were keyed on `name`, which was fine only while the
|
|
58
|
+
* shared source and the in-block key were the same word. `data:` (#128) ended
|
|
59
|
+
* that — a shared source is now a path *into* a container, so `data.species`
|
|
60
|
+
* and `species` are two spellings of two different places, and no single value
|
|
61
|
+
* of `name` reached both. `name: "species"` read `hm3.species` and could not
|
|
62
|
+
* see `data.species`; `name: "data.species"` read the shared source and could
|
|
63
|
+
* not see `hm3.species`. Each yielded the field's **default** wherever only the
|
|
64
|
+
* other position was authored — silently, since the field compiles and the
|
|
65
|
+
* document is emitted with the value simply gone (#305).
|
|
66
|
+
*
|
|
67
|
+
* That made every move into `data:` a flag day. Each of this package's other
|
|
68
|
+
* retirements — `package:`, `image`, `archetype`, `relation` — works because
|
|
69
|
+
* *both spellings are read while the corpus moves*, and one property could not
|
|
70
|
+
* offer that here.
|
|
71
|
+
*
|
|
72
|
+
* So a field declares `legacyKey` beside `name`: the key it is authored at
|
|
73
|
+
* inside the block, which step 2 reads and which **falls back to `name`** when
|
|
74
|
+
* absent, so every declaration written before this resolves unchanged. A field
|
|
75
|
+
* that declares one is mid-sweep by construction, which is what
|
|
76
|
+
* {@link module:engine/field-spec.readsLegacyKey} reports on.
|
|
77
|
+
*
|
|
55
78
|
* ## A name that collides across the two vocabularies skips step 3
|
|
56
79
|
*
|
|
57
80
|
* A field's `name` is both its identity and the shared property it draws from,
|
|
@@ -148,7 +171,7 @@ export const BLOCK_DIRECTIVES = Object.freeze(["pack"]);
|
|
|
148
171
|
/**
|
|
149
172
|
* Every key any system block may carry, whatever the system.
|
|
150
173
|
*
|
|
151
|
-
* A system adds its own on top — `
|
|
174
|
+
* A system adds its own on top — `templatePriority`, `kbcat` and the generators — and
|
|
152
175
|
* names them where it declares its vocabulary; see {@link unknownBlockKeys}.
|
|
153
176
|
*
|
|
154
177
|
* @type {ReadonlySet<string>}
|
|
@@ -312,6 +335,27 @@ export function blockProperty(fm, block, key, defaultValue = undefined) {
|
|
|
312
335
|
return shared === undefined || shared === null ? defaultValue : shared;
|
|
313
336
|
}
|
|
314
337
|
|
|
338
|
+
/**
|
|
339
|
+
* The key a field is authored at **inside** a system block — step 2.
|
|
340
|
+
*
|
|
341
|
+
* `legacyKey` when the field declares one, and `name` otherwise. The fallback
|
|
342
|
+
* is what makes this change invisible to every declaration written before it:
|
|
343
|
+
* a field whose shared source and in-block key are the same word says so once,
|
|
344
|
+
* as it always did.
|
|
345
|
+
*
|
|
346
|
+
* Named and exported rather than spelled inline because three readers ask the
|
|
347
|
+
* question and must agree — the resolver here, the frontmatter lint building
|
|
348
|
+
* the set of keys a block may carry, and the author-facing surfaces naming
|
|
349
|
+
* where a value was written.
|
|
350
|
+
*
|
|
351
|
+
* @param {{name?: string, legacyKey?: string}} field - The declaration.
|
|
352
|
+
* @returns {string|undefined} The in-block key, or `undefined` for a field that
|
|
353
|
+
* is not authored at all.
|
|
354
|
+
*/
|
|
355
|
+
export function legacyKeyOf(field) {
|
|
356
|
+
return field?.legacyKey ?? field?.name;
|
|
357
|
+
}
|
|
358
|
+
|
|
315
359
|
/**
|
|
316
360
|
* Where a declared field's value came from.
|
|
317
361
|
*
|
|
@@ -356,16 +400,19 @@ export function resolveFieldValue(field, fm, { block = "sohl" } = {}) {
|
|
|
356
400
|
if (own !== undefined) return { value: own, from: "system" };
|
|
357
401
|
}
|
|
358
402
|
|
|
359
|
-
// 2. The legacy in-block position
|
|
360
|
-
//
|
|
361
|
-
//
|
|
403
|
+
// 2. The legacy in-block position, keyed on `legacyKey` — the shared
|
|
404
|
+
// source is a path into `data:` and the in-block key is a bare word, so
|
|
405
|
+
// the two are declared separately (#305). Every note in every tree
|
|
406
|
+
// writes here today, and will until #126 moves them; dropping it would
|
|
407
|
+
// be a corpus migration disguised as a mechanism change.
|
|
362
408
|
const declared = systemBlock(fm, block);
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
409
|
+
const legacyKey = legacyKeyOf(field);
|
|
410
|
+
if (declared && legacyKey) {
|
|
411
|
+
if (legacyKey in declared) {
|
|
412
|
+
const value = declared[legacyKey];
|
|
366
413
|
return { value: value ?? field.default, from: "block" };
|
|
367
414
|
}
|
|
368
|
-
const nested = getFrontmatter(declared,
|
|
415
|
+
const nested = getFrontmatter(declared, legacyKey, undefined);
|
|
369
416
|
if (nested !== undefined) return { value: nested, from: "block" };
|
|
370
417
|
}
|
|
371
418
|
|
|
@@ -421,6 +468,24 @@ export function systemDataPaths(data, prefix = "") {
|
|
|
421
468
|
* this is the difference between "the field is lost at load" and "the build
|
|
422
469
|
* told you where".
|
|
423
470
|
*
|
|
471
|
+
* **A declared *leaf* holds values, not fields.** A schema declares a path that
|
|
472
|
+
* has no children of its own for two ordinary reasons — a map with **dynamic
|
|
473
|
+
* keys** (`mystery.skillAptitudes` is skill selector → modifier) and a
|
|
474
|
+
* **TypedSchemaField** (`strikeModes`, discriminated by `type`) — and in both
|
|
475
|
+
* the contents are data an author wrote, not paths the schema names. Walking
|
|
476
|
+
* into one reports every entry as an undeclared field: one finding per skill
|
|
477
|
+
* aptitude, per strike mode, per standing.
|
|
478
|
+
*
|
|
479
|
+
* It stayed invisible while those maps were authored *outside* `<system>.system`
|
|
480
|
+
* and so were never walked. The moment the corpus moves them to the destination
|
|
481
|
+
* (#126) every one of them lights up — 62 findings on `sohl-thalorna` alone,
|
|
482
|
+
* none of them a defect.
|
|
483
|
+
*
|
|
484
|
+
* So descent is conditional on the schema declaring something *beneath* the
|
|
485
|
+
* path. `body.structure` declares `parts` and `zones`, so it is a real
|
|
486
|
+
* container and an undeclared `adjacent` under it is a real finding;
|
|
487
|
+
* `skillAptitudes` declares nothing beneath it, so what is beneath is a value.
|
|
488
|
+
*
|
|
424
489
|
* @param {Record<string, unknown>} data - The authored `system` data.
|
|
425
490
|
* @param {ReadonlySet<string>} declared - Every field path the schema declares
|
|
426
491
|
* for this subtype, inherited ones included.
|
|
@@ -436,7 +501,7 @@ export function undeclaredPaths(data, declared, prefix = "") {
|
|
|
436
501
|
out.push(path);
|
|
437
502
|
continue;
|
|
438
503
|
}
|
|
439
|
-
if (isMapping(value) && Object.keys(value).length) {
|
|
504
|
+
if (isMapping(value) && Object.keys(value).length && declaresChildren(declared, path)) {
|
|
440
505
|
out.push(
|
|
441
506
|
...undeclaredPaths(/** @type {Record<string, unknown>} */ (value), declared, path),
|
|
442
507
|
);
|
|
@@ -445,6 +510,25 @@ export function undeclaredPaths(data, declared, prefix = "") {
|
|
|
445
510
|
return out;
|
|
446
511
|
}
|
|
447
512
|
|
|
513
|
+
/**
|
|
514
|
+
* Whether the schema declares any path beneath this one.
|
|
515
|
+
*
|
|
516
|
+
* The test for "container, not leaf" — see {@link undeclaredPaths}. Asked of
|
|
517
|
+
* the declared set rather than of the authored value, because it is a question
|
|
518
|
+
* about the *schema*: an author can nest a map under either.
|
|
519
|
+
*
|
|
520
|
+
* @param {ReadonlySet<string>} declared - The declared field paths.
|
|
521
|
+
* @param {string} path - The path to test.
|
|
522
|
+
* @returns {boolean} True when something is declared beneath it.
|
|
523
|
+
*/
|
|
524
|
+
function declaresChildren(declared, path) {
|
|
525
|
+
const prefix = `${path}.`;
|
|
526
|
+
for (const candidate of declared) {
|
|
527
|
+
if (candidate.startsWith(prefix)) return true;
|
|
528
|
+
}
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
|
|
448
532
|
/**
|
|
449
533
|
* Keys directly under a system block that neither this format nor the system
|
|
450
534
|
* recognizes.
|
|
@@ -0,0 +1,201 @@
|
|
|
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 closed registry of system ids, and the `none` that stands for no system
|
|
16
|
+
* at all (#59).
|
|
17
|
+
*
|
|
18
|
+
* A canonical address carries the system in a fixed position —
|
|
19
|
+
* `harnadventures-none-being-grod` — so "which systems exist" is a question the
|
|
20
|
+
* address grammar asks on every note, and the issue's answer is that it comes
|
|
21
|
+
* from a **registry rather than from a hardcoded set**: known system ids are
|
|
22
|
+
* declared here, once, an unknown value is an error, and _adding a system is a
|
|
23
|
+
* data change_. Before this module the same fact was spelled in as many places
|
|
24
|
+
* as needed it — a `NO_SYSTEM` constant in the content index, a `map.system`
|
|
25
|
+
* per document-subtype map, a system-keyed table per compiler — none of which
|
|
26
|
+
* could refuse a value none of the others had heard of.
|
|
27
|
+
*
|
|
28
|
+
* **`none` is a word, deliberately.** It is not a YAML null (`null`, `~`, or an
|
|
29
|
+
* empty value), because those spell "nobody filled this in", and a note that
|
|
30
|
+
* belongs to no system has been answered rather than skipped — the distinction
|
|
31
|
+
* the whole address grammar rests on, since a null cannot occupy a segment.
|
|
32
|
+
* And it is not `any`, which reads as a wildcard: a `place` does not compile
|
|
33
|
+
* into every system's document, it compiles into a Foundry core document that
|
|
34
|
+
* belongs to none of them.
|
|
35
|
+
*
|
|
36
|
+
* **A leaf, so that anything may read it.** The only import is the address
|
|
37
|
+
* charset, itself a leaf with no local imports, so this module can be named by
|
|
38
|
+
* the configuration loader, by a compiler, and by a lint without closing a
|
|
39
|
+
* cycle around any of them. In particular the ids are *declared* here rather
|
|
40
|
+
* than derived from `KNOWN_DOCUMENT_SUBTYPE_MAPS` — deriving them would drag
|
|
41
|
+
* `sohl/` and `hm3/` behind every import of this file, for a list of two
|
|
42
|
+
* words. `tests/systems.test.ts` holds the two in step instead, which is where
|
|
43
|
+
* the agreement between a registry and its implementations belongs.
|
|
44
|
+
*
|
|
45
|
+
* @module
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
// The one charset, read rather than restated — a system id is an address
|
|
49
|
+
// segment like any other, and a second spelling of the pattern is how the
|
|
50
|
+
// disagreements in #202/#203 happened.
|
|
51
|
+
import { ADDRESS_SEGMENT_PATTERN, isAddressSegment } from "./address-charset.mjs";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The `<system>` segment of a note that belongs to no system.
|
|
55
|
+
*
|
|
56
|
+
* Most notes are that: a `doc`, a `place`, a `macro` or a map compiles into a
|
|
57
|
+
* JournalEntry, a Scene or a Macro, which Foundry defines and no game system
|
|
58
|
+
* does. `none` is the format's word for it, in the address and in the content
|
|
59
|
+
* index alike, so the two say "no system" the same way.
|
|
60
|
+
*
|
|
61
|
+
* @type {string}
|
|
62
|
+
*/
|
|
63
|
+
export const NO_SYSTEM = "none";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Every game system this toolchain compiles for.
|
|
67
|
+
*
|
|
68
|
+
* The registry, and the only place the list is written down. Adding a system
|
|
69
|
+
* is an edit to this set plus the map and compilers that make it real — a data
|
|
70
|
+
* change, not a search for every place a name was spelled.
|
|
71
|
+
*
|
|
72
|
+
* `none` is **not** a member: it marks the absence of a system, and a caller
|
|
73
|
+
* asking "is this a system" and a caller asking "is this a well-formed
|
|
74
|
+
* segment" want different answers. See {@link isSystemId} and
|
|
75
|
+
* {@link isSystemSegment}.
|
|
76
|
+
*
|
|
77
|
+
* @type {ReadonlySet<string>}
|
|
78
|
+
*/
|
|
79
|
+
export const SYSTEM_IDS = Object.freeze(new Set(["sohl", "hm3"]));
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Everything the `<system>` segment of an address may say.
|
|
83
|
+
*
|
|
84
|
+
* The systems, plus {@link NO_SYSTEM}. Derived from the registry rather than
|
|
85
|
+
* listed again, so the two cannot drift.
|
|
86
|
+
*
|
|
87
|
+
* @type {ReadonlySet<string>}
|
|
88
|
+
*/
|
|
89
|
+
export const SYSTEM_SEGMENTS = Object.freeze(new Set([...SYSTEM_IDS, NO_SYSTEM]));
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether a value names a game system this toolchain knows.
|
|
93
|
+
*
|
|
94
|
+
* `none` is rejected: it is a real answer to "which system", but it is not a
|
|
95
|
+
* system, and a pack, a document-subtype map or a compiler table keyed by it
|
|
96
|
+
* would be keyed by nothing.
|
|
97
|
+
*
|
|
98
|
+
* @param {unknown} value - The candidate id.
|
|
99
|
+
* @returns {boolean} `true` when {@link SYSTEM_IDS} declares it.
|
|
100
|
+
*/
|
|
101
|
+
export function isSystemId(value) {
|
|
102
|
+
return typeof value === "string" && SYSTEM_IDS.has(value);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether a value is something the `<system>` segment may hold.
|
|
107
|
+
*
|
|
108
|
+
* The predicate an address parser wants: a declared system, or `none`.
|
|
109
|
+
*
|
|
110
|
+
* @param {unknown} value - The candidate segment.
|
|
111
|
+
* @returns {boolean} `true` when {@link SYSTEM_SEGMENTS} declares it.
|
|
112
|
+
*/
|
|
113
|
+
export function isSystemSegment(value) {
|
|
114
|
+
return typeof value === "string" && SYSTEM_SEGMENTS.has(value);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* What a caller writing an unknown system is told.
|
|
119
|
+
*
|
|
120
|
+
* Separate from {@link assertSystemSegment} so that a pass collecting findings
|
|
121
|
+
* can report the same sentence it would have thrown — the message is the part
|
|
122
|
+
* worth sharing, and a reporter that had to invent its own wording is how two
|
|
123
|
+
* halves of a build come to explain one rule two ways.
|
|
124
|
+
*
|
|
125
|
+
* The nullish case is called out by name because it is the likely mistake and
|
|
126
|
+
* the least legible failure: a YAML `null`, a `~`, or a key written with no
|
|
127
|
+
* value at all reaches here as `undefined`, and "expected one of sohl, hm3,
|
|
128
|
+
* none" would leave a reader hunting for the value they cannot see.
|
|
129
|
+
*
|
|
130
|
+
* @param {unknown} value - The offending value.
|
|
131
|
+
* @param {string} [where] - What carried it, for the message — a note path, a
|
|
132
|
+
* configuration key, an address.
|
|
133
|
+
* @returns {string} The message, with no trailing period, in the style of the
|
|
134
|
+
* other vocabulary messages.
|
|
135
|
+
*/
|
|
136
|
+
export function unknownSystemMessage(value, where = "the system segment") {
|
|
137
|
+
const known = [...SYSTEM_SEGMENTS].map((id) => `\`${id}\``).join(", ");
|
|
138
|
+
if (value == null || value === "") {
|
|
139
|
+
return (
|
|
140
|
+
`${where} names no system — an absent value is not how "no system" ` +
|
|
141
|
+
`is written. A note that belongs to no system says so, with ` +
|
|
142
|
+
`\`${NO_SYSTEM}\`: the segment is positional, so a null cannot ` +
|
|
143
|
+
`occupy it, and "answered as none" and "nobody filled this in" ` +
|
|
144
|
+
`have to stay distinguishable. Known: ${known}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return (
|
|
148
|
+
`${where} names \`${String(value)}\`, which is not a known system. ` +
|
|
149
|
+
`Known systems come from a closed registry, so adding one is a data ` +
|
|
150
|
+
`change rather than a value that starts working: ${known}. \`any\` is ` +
|
|
151
|
+
`not among them either — it reads as a wildcard, and a note outside ` +
|
|
152
|
+
`every system compiles into a core document rather than into all of them`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Refuse a value the `<system>` segment may not hold.
|
|
158
|
+
*
|
|
159
|
+
* Throws rather than returning a finding, matching the nearest neighbour —
|
|
160
|
+
* `assertVocabularyCharset` in `engine/note-vocabulary.mjs`, and the
|
|
161
|
+
* `configFromData` validators — because every caller of this is validating a
|
|
162
|
+
* declaration rather than surveying content. A note's bad value belongs in a
|
|
163
|
+
* report; a build asking to route, address or compile an undeclared system has
|
|
164
|
+
* nowhere to continue to.
|
|
165
|
+
*
|
|
166
|
+
* @param {unknown} value - The candidate segment.
|
|
167
|
+
* @param {string} [where] - What carried it, for the message.
|
|
168
|
+
* @returns {string} The value, unchanged, so a caller may validate inline.
|
|
169
|
+
* @throws {Error} Naming the value and the whole known vocabulary.
|
|
170
|
+
*/
|
|
171
|
+
export function assertSystemSegment(value, where = "the system segment") {
|
|
172
|
+
if (!isSystemSegment(value)) throw new Error(`${unknownSystemMessage(value, where)}.`);
|
|
173
|
+
return /** @type {string} */ (value);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Refuse a registry declaring an id that could not be an address segment.
|
|
178
|
+
*
|
|
179
|
+
* Run over {@link SYSTEM_SEGMENTS} as this module loads, so a declaration that
|
|
180
|
+
* breaks the charset cannot be imported, let alone shipped. Same reasoning as
|
|
181
|
+
* `assertVocabularyCharset`: a bad value in one note is one author's mistake,
|
|
182
|
+
* while a bad *declaration* puts an unreadable segment into every address that
|
|
183
|
+
* names the system.
|
|
184
|
+
*
|
|
185
|
+
* @param {Iterable<string>} segments - The declared ids, including `none`.
|
|
186
|
+
* @param {string} [where] - What declares them, for the message.
|
|
187
|
+
* @throws {Error} Naming every offending id at once — a reader fixing a list
|
|
188
|
+
* wants the whole list.
|
|
189
|
+
*/
|
|
190
|
+
export function assertSystemCharset(segments, where = "the system registry") {
|
|
191
|
+
const bad = [...(segments ?? [])].filter((id) => !isAddressSegment(id));
|
|
192
|
+
if (!bad.length) return;
|
|
193
|
+
throw new Error(
|
|
194
|
+
`${where} declares ${bad.map((id) => `"${id}"`).join(", ")}, which ` +
|
|
195
|
+
`${bad.length === 1 ? "is" : "are"} not ${ADDRESS_SEGMENT_PATTERN.source}. ` +
|
|
196
|
+
`A system id is a segment of every address that names it, and the ` +
|
|
197
|
+
`hyphen separates segments rather than occurring inside one.`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
assertSystemCharset(SYSTEM_SEGMENTS);
|
package/engine/web-wikilinks.mjs
CHANGED
|
@@ -43,9 +43,10 @@
|
|
|
43
43
|
// name (#1409).
|
|
44
44
|
import { readQualifier } from "./wikilinks.mjs";
|
|
45
45
|
import { replaceOutsideCode } from "./code-fences.mjs";
|
|
46
|
-
// The canonical `package-type-shortcode` key, so a package-qualified
|
|
47
|
-
// is
|
|
48
|
-
|
|
46
|
+
// The canonical `package-system-type-shortcode` key, so a package-qualified
|
|
47
|
+
// address is matched the way a vendored manifest publishes it — by the
|
|
48
|
+
// segments the target supplies, with the system wildcarded unless stated (#59).
|
|
49
|
+
import { canonicalKey, readCanonicalKey } from "./content-address.mjs";
|
|
49
50
|
// The one rule about a link's shape both builds share: it carries a label, and
|
|
50
51
|
// {@link unlabelledLinkMessage} is the one place that says so (#180).
|
|
51
52
|
import { unlabelledLinkMessage } from "./wikilink-syntax.mjs";
|
|
@@ -84,14 +85,32 @@ import { authoredLabel, WIKILINK, isSamePage, parseWikilink } from "./wikilink-s
|
|
|
84
85
|
* @param {object|null} read - From {@link readQualifier}.
|
|
85
86
|
* @returns {string | null} The index key, or `null` when not an address.
|
|
86
87
|
*/
|
|
87
|
-
function
|
|
88
|
-
if (!read || read.reason) return
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
function lookupRead(index, read) {
|
|
89
|
+
if (!read || read.reason) return undefined;
|
|
90
|
+
// An unqualified address stays the system-blind short key, which is the
|
|
91
|
+
// wildcard an author writing `[[skill-melee]]` means, and resolves within
|
|
92
|
+
// this package only.
|
|
93
|
+
if (!read.package) return index.get(`${read.type}/${read.shortcode}`.toLowerCase());
|
|
94
|
+
|
|
95
|
+
// A package-qualified one is matched by the segments it supplies, with the
|
|
96
|
+
// system wildcarded unless stated (#59) — an exact `get` cannot express
|
|
97
|
+
// that, and would silently miss every address whose system it did not
|
|
98
|
+
// guess. Exactly one hit resolves; two are an ambiguity for the caller to
|
|
99
|
+
// report rather than a pick to make here.
|
|
100
|
+
const type = String(read.type).toLowerCase();
|
|
101
|
+
const shortcode = String(read.shortcode).toLowerCase();
|
|
102
|
+
const pkg = String(read.package).toLowerCase();
|
|
103
|
+
let found;
|
|
104
|
+
for (const [key, value] of index) {
|
|
105
|
+
const parts = readCanonicalKey(key);
|
|
106
|
+
if (!parts) continue;
|
|
107
|
+
if (parts.package !== pkg) continue;
|
|
108
|
+
if (read.system && parts.system !== String(read.system).toLowerCase()) continue;
|
|
109
|
+
if (parts.type !== type || parts.shortcode !== shortcode) continue;
|
|
110
|
+
if (found) return undefined;
|
|
111
|
+
found = value;
|
|
112
|
+
}
|
|
113
|
+
return found;
|
|
95
114
|
}
|
|
96
115
|
|
|
97
116
|
/**
|
|
@@ -242,8 +261,10 @@ function isPlainMap(value) {
|
|
|
242
261
|
*
|
|
243
262
|
* **Every target is an address**, parsed by {@link readQualifier} and looked up
|
|
244
263
|
* case-insensitively in the KB-wide `ctx.index` (the canonical
|
|
245
|
-
* `package-type-shortcode`, `type/shortcode`, and the site's own
|
|
246
|
-
* `section/slug`), then in the vendored `ctx.foreign` manifests. A
|
|
264
|
+
* `package-system-type-shortcode`, `type/shortcode`, and the site's own
|
|
265
|
+
* `section/slug`), then in the vendored `ctx.foreign` manifests. A target is a
|
|
266
|
+
* *partial* address: an omitted package means this package, an omitted system
|
|
267
|
+
* is a wildcard, and exactly one match resolves — two are `ambiguous`. A link written
|
|
247
268
|
* without a label addresses nothing at all and is reported as such (#180) —
|
|
248
269
|
* there is no second namespace left for it to name.
|
|
249
270
|
*
|
|
@@ -343,10 +364,9 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
343
364
|
// recognised. `null` here means the target is not an address at all,
|
|
344
365
|
// which is a defect: there is no other namespace to try.
|
|
345
366
|
const read = readQualifier(target, ctx.contentTypes ?? new Set(), ctx.packages);
|
|
346
|
-
const hyphenKey = keyOfRead(read);
|
|
347
367
|
const rawKey = target.toLowerCase();
|
|
348
368
|
const hit =
|
|
349
|
-
(
|
|
369
|
+
lookupRead(ctx.index, read) ??
|
|
350
370
|
// `section/slug` is the site's own address for a page, and it is in
|
|
351
371
|
// the same map. Admitted only when the target carries a slash, so
|
|
352
372
|
// a page's bare slug cannot answer for an address.
|
|
@@ -355,7 +375,7 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
355
375
|
// local one (#1446), so a cross-package hit needs no special case
|
|
356
376
|
// below. Local wins: a live build is authoritative and a vendored
|
|
357
377
|
// manifest can only be staler.
|
|
358
|
-
(
|
|
378
|
+
(ctx.foreign ? lookupRead(ctx.foreign, read) : undefined);
|
|
359
379
|
if (hit) {
|
|
360
380
|
// An address with an *empty* label has no prose to show (a
|
|
361
381
|
// shortcode is not display text), so the document's **current**
|
|
@@ -384,12 +404,12 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
384
404
|
// above already consulted. It parses as no `type/shortcode`, but it did
|
|
385
405
|
// address something and nothing answered — so it is unresolved, not
|
|
386
406
|
// unaddressable. (A prefix that is a content *type* never reaches here:
|
|
387
|
-
// it
|
|
407
|
+
// it parses as an address.)
|
|
388
408
|
const siteAddress = prefix !== null && ctx.sections.has(prefix);
|
|
389
409
|
|
|
390
410
|
// **An address resolving nowhere is a failure, unconditionally** (#184).
|
|
391
411
|
//
|
|
392
|
-
// It was gated on
|
|
412
|
+
// It was gated on a manifest-completeness check — while any linkable package was
|
|
393
413
|
// invisible here, `Rules/Bestiary.md` addressing `being-grkrahk` in the
|
|
394
414
|
// `thalorna` package was indistinguishable from a typo, so the form
|
|
395
415
|
// stayed unguarded rather than fail correct content. The gate has
|
|
@@ -401,9 +421,19 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
401
421
|
// manifest calls for is in the message instead — {@link
|
|
402
422
|
// unresolvedAddressMessage} names both corrections — which informs the
|
|
403
423
|
// author without excusing the link.
|
|
424
|
+
// The bucket an ambiguity is recorded under is the *short* address —
|
|
425
|
+
// the only form two packages can collide on, since a qualified one
|
|
426
|
+
// names its package. A qualified target that matched more than one
|
|
427
|
+
// document is reported by the matcher instead, not by this set.
|
|
428
|
+
const collideKey =
|
|
429
|
+
read && !read.reason && !read.package ?
|
|
430
|
+
`${read.type}/${read.shortcode}`.toLowerCase()
|
|
431
|
+
: rawKey;
|
|
404
432
|
const reason =
|
|
405
|
-
ctx.collide?.has(
|
|
406
|
-
|
|
433
|
+
ctx.collide?.has(collideKey) ? "ambiguous"
|
|
434
|
+
// "It parsed as an address" is a property of the parse, not of
|
|
435
|
+
// a key: a partial address has no single key to be non-null.
|
|
436
|
+
: (read && !read.reason) || siteAddress ? "unresolved"
|
|
407
437
|
: read?.reason === "unknown-type" ? "unknown-type"
|
|
408
438
|
// Every link is an address, and this is not one. Distinct from
|
|
409
439
|
// a dead address, because the fix is different: a name has to
|
|
@@ -194,11 +194,11 @@ export const LINK_FINDING_REASONS = Object.freeze(
|
|
|
194
194
|
*
|
|
195
195
|
* **Both corrections, because the author cannot tell which applies.** An
|
|
196
196
|
* address lands nowhere either because the shortcode is wrong or because the
|
|
197
|
-
* package publishing it
|
|
198
|
-
* looks identical in
|
|
197
|
+
* package publishing it is not a declared dependency — or is one whose index
|
|
198
|
+
* has not been fetched — and the link itself looks identical in every case.
|
|
199
199
|
*
|
|
200
200
|
* This used to be a **warning** in the checker and, in the site build, nothing
|
|
201
|
-
* at all until every linkable package
|
|
201
|
+
* at all until every linkable package's manifest was accounted for — on the
|
|
202
202
|
* reasoning that a bare `[[Name]]` might be a placeholder for a note nobody had
|
|
203
203
|
* written yet. That reasoning was a property of the bare form, which is retired
|
|
204
204
|
* (#180); the intent behind it now has a real spelling, a `draft`-tagged note
|
|
@@ -211,8 +211,8 @@ export const LINK_FINDING_REASONS = Object.freeze(
|
|
|
211
211
|
export function unresolvedAddressMessage(target) {
|
|
212
212
|
return (
|
|
213
213
|
`address [[${target}]] resolves to no note — no package publishes ` +
|
|
214
|
-
`it. Fix the shortcode, or
|
|
215
|
-
`
|
|
214
|
+
`it. Fix the shortcode, or declare the package that does as a ` +
|
|
215
|
+
`dependency and run \`content-build deps fetch\``
|
|
216
216
|
);
|
|
217
217
|
}
|
|
218
218
|
|
|
@@ -225,6 +225,13 @@ export function unresolvedAddressMessage(target) {
|
|
|
225
225
|
* message names the claimants so the author can choose between them without
|
|
226
226
|
* going looking.
|
|
227
227
|
*
|
|
228
|
+
* **The correction is the canonical form**, all four segments (#59). Omission
|
|
229
|
+
* runs strictly left to right, so there is no `package-type-shortcode` to offer
|
|
230
|
+
* — naming a package means naming the system before the type. That used to be
|
|
231
|
+
* the correction here, back when a written target could state three segments at
|
|
232
|
+
* most; the grammar is positional now, and a three-segment target names a
|
|
233
|
+
* *system*, not a package.
|
|
234
|
+
*
|
|
228
235
|
* @param {string} target - The address as authored.
|
|
229
236
|
* @param {Iterable<string>} [packages] - The packages that publish it.
|
|
230
237
|
* @returns {string} The message.
|
|
@@ -234,8 +241,8 @@ export function ambiguousAddressMessage(target, packages = []) {
|
|
|
234
241
|
return (
|
|
235
242
|
`address [[${target}]] is published by ` +
|
|
236
243
|
(named.length ? `${named.join(" and ")}` : `more than one package`) +
|
|
237
|
-
`, so it names neither — write the
|
|
238
|
-
`[[package-type-shortcode|Text]]`
|
|
244
|
+
`, so it names neither — write the fully qualified ` +
|
|
245
|
+
`[[package-system-type-shortcode|Text]]`
|
|
239
246
|
);
|
|
240
247
|
}
|
|
241
248
|
|
|
@@ -265,7 +272,9 @@ export function linkFindingMessage({ reason, target, packages, anchor }) {
|
|
|
265
272
|
case "not-an-address":
|
|
266
273
|
return (
|
|
267
274
|
`"${target}" is not an address — the "|" says one was meant, ` +
|
|
268
|
-
`so write [[type-shortcode|Text]]`
|
|
275
|
+
`so write [[type-shortcode|Text]], or ` +
|
|
276
|
+
`[[package-system-type-shortcode|Text]] for a note in another ` +
|
|
277
|
+
`package`
|
|
269
278
|
);
|
|
270
279
|
case "unknown-type":
|
|
271
280
|
return `address [[${target}]] names no known content type`;
|