@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
package/engine/pdf-toc.mjs
CHANGED
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
* @module
|
|
49
49
|
*/
|
|
50
50
|
|
|
51
|
+
import { encodeAddresses } from "./address-values.mjs";
|
|
51
52
|
import { slugify } from "./content-slug.mjs";
|
|
52
53
|
import { positionOfYamlPath } from "./diagnostics.mjs";
|
|
53
54
|
|
|
@@ -464,8 +465,18 @@ export function planDocument(nodes, { selections = new Map() } = {}) {
|
|
|
464
465
|
sections: entries.filter((e) => e.kind === "section").length,
|
|
465
466
|
prose: entries.filter((e) => e.kind === "prose").length,
|
|
466
467
|
notes: entries.filter((e) => e.kind === "note").length,
|
|
467
|
-
distinct:
|
|
468
|
-
|
|
468
|
+
distinct: new Set(
|
|
469
|
+
entries
|
|
470
|
+
.filter((e) => e.kind === "note")
|
|
471
|
+
.map((e) => e.record.address?.slug ?? e.record.shortcode),
|
|
472
|
+
).size,
|
|
473
|
+
repeated:
|
|
474
|
+
selected -
|
|
475
|
+
new Set(
|
|
476
|
+
entries
|
|
477
|
+
.filter((e) => e.kind === "note")
|
|
478
|
+
.map((e) => e.record.address?.slug ?? e.record.shortcode),
|
|
479
|
+
).size,
|
|
469
480
|
skipped: nodes.length - entries.filter((e) => e.kind === "section").length,
|
|
470
481
|
},
|
|
471
482
|
};
|
|
@@ -521,6 +532,10 @@ function emitSection(node, nodes, ctx) {
|
|
|
521
532
|
// First occurrence wins the address: however many times the book
|
|
522
533
|
// prints an entry, `[[weapongear-dagger]]` reaches one page.
|
|
523
534
|
if (!ctx.links.has(slug)) ctx.links.set(slug, anchor);
|
|
535
|
+
for (const address of [record?.address?.canonical, record?.documentation]) {
|
|
536
|
+
if (address && !ctx.links.has(encodeAddresses(address)))
|
|
537
|
+
ctx.links.set(encodeAddresses(address), anchor);
|
|
538
|
+
}
|
|
524
539
|
ctx.entries.push({
|
|
525
540
|
kind: "note",
|
|
526
541
|
record,
|
|
@@ -18,11 +18,21 @@
|
|
|
18
18
|
* two structural relations are stated here: `data.borders`, the places it
|
|
19
19
|
* shares a frontier with, and `data.routes`, the journeys from its centre to
|
|
20
20
|
* another place. Both are lists of entries naming the other place by
|
|
21
|
-
*
|
|
21
|
+
* **Address**, and both are stated **from each end**: A at `NE` of B is the same
|
|
22
22
|
* fact as B at `SW` of A, so a note stating one side of it and a neighbour
|
|
23
23
|
* stating a different side is a contradiction the lint reports rather than a
|
|
24
24
|
* discovery made while writing a third note.
|
|
25
25
|
*
|
|
26
|
+
* **`to` is an Address, written at whatever length says what it means.** Its
|
|
27
|
+
* `<type>` segment defaults to `place` and `place` is the whole of its accepted
|
|
28
|
+
* set, so `vylar`, `place-vylar` and `thalorna-none-place-vylar` name one place
|
|
29
|
+
* and a `to` naming anything else is an error. The `<package>` segment defaults
|
|
30
|
+
* to **the package doing the building** — wherever the value was written, and
|
|
31
|
+
* whether it was authored here or carried back by a fetched index — so a short
|
|
32
|
+
* `to` always names a place in the package being built, naming a dependency's
|
|
33
|
+
* place is what the fully qualified form is for, and two places sharing a
|
|
34
|
+
* shortcode in different packages never answer for each other.
|
|
35
|
+
*
|
|
26
36
|
* **The value sets are closed and stated once.** The eight bearings, the three
|
|
27
37
|
* travel modes, the days markers and the terrain registry are the constants
|
|
28
38
|
* below, read by the lint, compared against the specification's tables by the
|
|
@@ -45,17 +55,36 @@
|
|
|
45
55
|
* through the same resolver a wikilink does and a dependency's places take
|
|
46
56
|
* part: a fetched index entry carries the relation the record stated, and a
|
|
47
57
|
* border across a package boundary is checked from both ends like any other.
|
|
58
|
+
* Across that boundary the far side states the frontier in the fully qualified
|
|
59
|
+
* form, which is the only form that names this package's place from over there —
|
|
60
|
+
* a short `to` in a dependency's record names the package being built, so the
|
|
61
|
+
* long form is not optional for a cross-package border.
|
|
48
62
|
*
|
|
49
63
|
* @module
|
|
50
64
|
*/
|
|
51
65
|
|
|
66
|
+
import { encodeAddresses } from "./address-values.mjs";
|
|
67
|
+
|
|
52
68
|
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
53
|
-
import {
|
|
69
|
+
import { acceptsType, parseAddress, renderAddress } from "./address.mjs";
|
|
70
|
+
import { NO_SYSTEM } from "./document-subtypes.mjs";
|
|
54
71
|
|
|
55
72
|
/* --------------------------------------------------------------------- */
|
|
56
73
|
/* The closed sets */
|
|
57
74
|
/* --------------------------------------------------------------------- */
|
|
58
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The type a border's or a route's `to` names.
|
|
78
|
+
*
|
|
79
|
+
* It is both the default its `<type>` segment takes when an author omits it and
|
|
80
|
+
* the whole of the set the position accepts, which is what makes `vylar` and
|
|
81
|
+
* `place-vylar` the same Address and a `to` naming a `lore` note an error rather
|
|
82
|
+
* than a lookup that quietly finds nothing.
|
|
83
|
+
*
|
|
84
|
+
* @type {string}
|
|
85
|
+
*/
|
|
86
|
+
export const RELATION_TYPE = "place";
|
|
87
|
+
|
|
59
88
|
/**
|
|
60
89
|
* The eight compass bearings, clockwise from north.
|
|
61
90
|
*
|
|
@@ -160,7 +189,7 @@ export function oppositeBearing(bearing) {
|
|
|
160
189
|
* directly, copied from the record when the index was loaded.
|
|
161
190
|
*
|
|
162
191
|
* @param {object} hit - What the index resolved.
|
|
163
|
-
* @returns {{type: string, borders: unknown, routes: unknown, parents: unknown
|
|
192
|
+
* @returns {{type: string, borders: unknown, routes: unknown, parents: unknown}}
|
|
164
193
|
* The relation it states.
|
|
165
194
|
*/
|
|
166
195
|
function relationsOf(hit) {
|
|
@@ -171,7 +200,6 @@ function relationsOf(hit) {
|
|
|
171
200
|
borders: data.borders,
|
|
172
201
|
routes: data.routes,
|
|
173
202
|
parents: data.parents,
|
|
174
|
-
name: String(hit.fm.shortcode ?? ""),
|
|
175
203
|
};
|
|
176
204
|
}
|
|
177
205
|
return {
|
|
@@ -179,53 +207,84 @@ function relationsOf(hit) {
|
|
|
179
207
|
borders: hit?.borders,
|
|
180
208
|
routes: hit?.routes,
|
|
181
209
|
parents: hit?.parents,
|
|
182
|
-
name: "",
|
|
183
210
|
};
|
|
184
211
|
}
|
|
185
212
|
|
|
186
213
|
/**
|
|
187
|
-
* The
|
|
214
|
+
* The Address defaults a place's relation values are read against.
|
|
215
|
+
*
|
|
216
|
+
* The vocabularies are the tree's, so every position reads one grammar, and
|
|
217
|
+
* there is **one** set of defaults: the package is always the one being built.
|
|
218
|
+
* That is the whole of the cross-package rule, and it holds for both ends of a
|
|
219
|
+
* frontier — a note's own relations and the relations a fetched index carries
|
|
220
|
+
* back are read the same way, so a short value in a dependency's record names a
|
|
221
|
+
* place in the package being built and a dependency names a place of its own by
|
|
222
|
+
* writing the Address in full. `place` is the type an omitted `<type>` segment
|
|
223
|
+
* takes, and a place is a core document, so the system is `none`.
|
|
224
|
+
*
|
|
225
|
+
* @param {object} [index] - The link index, for the tree's vocabularies and the
|
|
226
|
+
* package being built.
|
|
227
|
+
* @returns {object} The defaults {@link parseAddress} takes.
|
|
228
|
+
*/
|
|
229
|
+
function addressDefaults(index) {
|
|
230
|
+
return {
|
|
231
|
+
types: index?.types ?? new Set([RELATION_TYPE]),
|
|
232
|
+
packages: index?.packages,
|
|
233
|
+
noIndexPackages: index?.noIndexPackages,
|
|
234
|
+
package: index?.contentPackage,
|
|
235
|
+
system: NO_SYSTEM,
|
|
236
|
+
type: RELATION_TYPE,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The canonical Address a written relation value names, or `undefined` where it
|
|
242
|
+
* names no place.
|
|
188
243
|
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
244
|
+
* Every comparison between two ends of a relation goes through this, so the two
|
|
245
|
+
* are compared as the Addresses they name rather than as the strings two authors
|
|
246
|
+
* happened to type.
|
|
192
247
|
*
|
|
193
|
-
* @param {unknown}
|
|
194
|
-
* @
|
|
248
|
+
* @param {unknown} written - The value as authored.
|
|
249
|
+
* @param {object} defaults - What {@link addressDefaults} returns.
|
|
250
|
+
* @returns {string|undefined} The canonical `package-none-place-shortcode`.
|
|
195
251
|
*/
|
|
196
|
-
function
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
252
|
+
function placeAddress(written, defaults) {
|
|
253
|
+
if (!defaults.package) return undefined;
|
|
254
|
+
const read = parseAddress(written, defaults);
|
|
255
|
+
if (read.reason || !acceptsType(read, [RELATION_TYPE])) return undefined;
|
|
256
|
+
return renderAddress(read);
|
|
201
257
|
}
|
|
202
258
|
|
|
203
259
|
/**
|
|
204
260
|
* Whether one place's `parents` names another.
|
|
205
261
|
*
|
|
206
262
|
* @param {unknown} parents - The `parents` list.
|
|
207
|
-
* @param {string}
|
|
263
|
+
* @param {string|undefined} address - The place asked about, as its Address.
|
|
264
|
+
* @param {object} defaults - What {@link addressDefaults} returns.
|
|
208
265
|
* @returns {boolean} Whether it is named.
|
|
209
266
|
*/
|
|
210
|
-
function hasParent(parents,
|
|
211
|
-
|
|
267
|
+
function hasParent(parents, address, defaults) {
|
|
268
|
+
if (!address || !Array.isArray(parents)) return false;
|
|
269
|
+
return parents.some((p) => placeAddress(p, defaults) === address);
|
|
212
270
|
}
|
|
213
271
|
|
|
214
272
|
/**
|
|
215
273
|
* The entries of a relation list that name one place.
|
|
216
274
|
*
|
|
217
275
|
* @param {unknown} list - A `borders` or `routes` value.
|
|
218
|
-
* @param {string}
|
|
276
|
+
* @param {string|undefined} address - The place they name, as its Address.
|
|
277
|
+
* @param {object} defaults - What {@link addressDefaults} returns.
|
|
219
278
|
* @returns {Record<string, unknown>[]} The entries.
|
|
220
279
|
*/
|
|
221
|
-
function entriesTo(list,
|
|
222
|
-
if (!Array.isArray(list)) return [];
|
|
280
|
+
function entriesTo(list, address, defaults) {
|
|
281
|
+
if (!address || !Array.isArray(list)) return [];
|
|
223
282
|
return list.filter(
|
|
224
283
|
(e) =>
|
|
225
284
|
e &&
|
|
226
285
|
typeof e === "object" &&
|
|
227
286
|
!Array.isArray(e) &&
|
|
228
|
-
|
|
287
|
+
placeAddress(e.to, defaults) === address,
|
|
229
288
|
);
|
|
230
289
|
}
|
|
231
290
|
|
|
@@ -289,6 +348,19 @@ function checkRelation(note, { field, index }) {
|
|
|
289
348
|
|
|
290
349
|
const raw = note.raw ?? "";
|
|
291
350
|
const self = String(fm.shortcode ?? "").toLowerCase();
|
|
351
|
+
// One set of defaults, read by both ends: an omitted `<package>` segment is
|
|
352
|
+
// always the package being built.
|
|
353
|
+
const here = addressDefaults(index);
|
|
354
|
+
/** This place, as the Address the far end has to name it by. */
|
|
355
|
+
const selfAddress =
|
|
356
|
+
here.package && self ?
|
|
357
|
+
renderAddress({
|
|
358
|
+
package: here.package,
|
|
359
|
+
system: NO_SYSTEM,
|
|
360
|
+
type: RELATION_TYPE,
|
|
361
|
+
shortcode: self,
|
|
362
|
+
})
|
|
363
|
+
: undefined;
|
|
292
364
|
const isRoute = field === "routes";
|
|
293
365
|
const keys = isRoute ? ROUTE_KEYS : BORDER_KEYS;
|
|
294
366
|
const at = (i, key) =>
|
|
@@ -327,21 +399,46 @@ function checkRelation(note, { field, index }) {
|
|
|
327
399
|
);
|
|
328
400
|
}
|
|
329
401
|
|
|
330
|
-
// `to`: a
|
|
402
|
+
// `to`: an Address naming a place, and one a place declares.
|
|
331
403
|
const to = entry.to;
|
|
332
404
|
let target;
|
|
333
405
|
let toIsValid = false;
|
|
406
|
+
/** The one canonical Address `to` names, once it parses. */
|
|
407
|
+
let toAddress;
|
|
408
|
+
const read = parseAddress(to, here);
|
|
334
409
|
if (to === undefined || to === null || to === "") {
|
|
335
410
|
error(i, undefined, `${label(i)} must name the other place in \`to\``);
|
|
336
|
-
} else if (
|
|
411
|
+
} else if (
|
|
412
|
+
read.reason === "no-content-index" ||
|
|
413
|
+
(!read.reason && here.noIndexPackages?.has(read.package))
|
|
414
|
+
) {
|
|
415
|
+
error(
|
|
416
|
+
i,
|
|
417
|
+
"to",
|
|
418
|
+
`${label(i, "to")} names package "${read.package}", which is declared ` +
|
|
419
|
+
`\`contentIndex: false\` — a Foundry dependency only, with no content ` +
|
|
420
|
+
`index fetched for it, so nothing it publishes can be named here`,
|
|
421
|
+
);
|
|
422
|
+
} else if (read.reason) {
|
|
423
|
+
error(
|
|
424
|
+
i,
|
|
425
|
+
"to",
|
|
426
|
+
`${label(i, "to")} must name a place — the other place's \`shortcode\`, or ` +
|
|
427
|
+
`its address as \`${RELATION_TYPE}-<shortcode>\` or ` +
|
|
428
|
+
`\`<package>-${NO_SYSTEM}-${RELATION_TYPE}-<shortcode>\` for a place in ` +
|
|
429
|
+
`another package — but reads ${JSON.stringify(encodeAddresses(to))}`,
|
|
430
|
+
);
|
|
431
|
+
} else if (!acceptsType(read, [RELATION_TYPE])) {
|
|
337
432
|
error(
|
|
338
433
|
i,
|
|
339
434
|
"to",
|
|
340
|
-
`${label(i, "to")}
|
|
341
|
-
|
|
435
|
+
`${label(i, "to")} names a \`${read.type}\`, and a ` +
|
|
436
|
+
`${isRoute ? "route" : "border"} is between places, so it names a ` +
|
|
437
|
+
`\`${RELATION_TYPE}\` — but reads ${JSON.stringify(encodeAddresses(to))}`,
|
|
342
438
|
);
|
|
343
439
|
} else if (index) {
|
|
344
|
-
|
|
440
|
+
toAddress = renderAddress(read);
|
|
441
|
+
const hit = index.addressHit(toAddress);
|
|
345
442
|
if (hit) {
|
|
346
443
|
target = relationsOf(hit);
|
|
347
444
|
toIsValid = true;
|
|
@@ -350,14 +447,18 @@ function checkRelation(note, { field, index }) {
|
|
|
350
447
|
// slip, and naming it says what was found rather than only
|
|
351
448
|
// what was not.
|
|
352
449
|
const other = (index.notes ?? []).find(
|
|
353
|
-
(n) =>
|
|
450
|
+
(n) =>
|
|
451
|
+
String(n.fm?.shortcode ?? "").toLowerCase() === read.shortcode &&
|
|
452
|
+
String(n.type ?? "") !== RELATION_TYPE,
|
|
354
453
|
);
|
|
355
454
|
error(
|
|
356
455
|
i,
|
|
357
456
|
"to",
|
|
358
|
-
`${label(i, "to")} names "${to}", and no place
|
|
359
|
-
`
|
|
360
|
-
(other ?
|
|
457
|
+
`${label(i, "to")} names "${encodeAddresses(to)}", and no place at ${toAddress} is ` +
|
|
458
|
+
`declared by this package or by a fetched index` +
|
|
459
|
+
(other ?
|
|
460
|
+
`; the ${other.type} note "${read.shortcode}" is not a place`
|
|
461
|
+
: ""),
|
|
361
462
|
);
|
|
362
463
|
}
|
|
363
464
|
} else {
|
|
@@ -463,7 +564,11 @@ function checkRelation(note, { field, index }) {
|
|
|
463
564
|
}
|
|
464
565
|
|
|
465
566
|
if (!toIsValid) return;
|
|
466
|
-
|
|
567
|
+
// The pair is keyed on the Address rather than on the written form, so
|
|
568
|
+
// one place named once bare and once qualified is the one pair it is.
|
|
569
|
+
// With no index there is no package to complete the Address with, and
|
|
570
|
+
// the shortcode is all the note itself states.
|
|
571
|
+
const toKey = toAddress ?? read.shortcode;
|
|
467
572
|
|
|
468
573
|
// Once per pair — per mode, on a route.
|
|
469
574
|
const modes = seen.get(toKey) ?? new Set();
|
|
@@ -473,9 +578,9 @@ function checkRelation(note, { field, index }) {
|
|
|
473
578
|
i,
|
|
474
579
|
undefined,
|
|
475
580
|
isRoute ?
|
|
476
|
-
`${label(i)} lists "${to}" by ${mode} twice; a pair appears in \`routes\` ` +
|
|
581
|
+
`${label(i)} lists "${encodeAddresses(to)}" by ${mode} twice; a pair appears in \`routes\` ` +
|
|
477
582
|
`once per mode`
|
|
478
|
-
: `${label(i)} lists "${to}" twice; a pair appears in \`borders\` once`,
|
|
583
|
+
: `${label(i)} lists "${encodeAddresses(to)}" twice; a pair appears in \`borders\` once`,
|
|
479
584
|
);
|
|
480
585
|
return;
|
|
481
586
|
}
|
|
@@ -488,7 +593,7 @@ function checkRelation(note, { field, index }) {
|
|
|
488
593
|
error(
|
|
489
594
|
i,
|
|
490
595
|
"to",
|
|
491
|
-
`${label(i, "to")} names "${to}", which is a ${target.type} note; a ` +
|
|
596
|
+
`${label(i, "to")} names "${encodeAddresses(to)}", which is a ${target.type} note; a ` +
|
|
492
597
|
`${field === "borders" ? "border" : "route"} is between places`,
|
|
493
598
|
);
|
|
494
599
|
return;
|
|
@@ -497,20 +602,20 @@ function checkRelation(note, { field, index }) {
|
|
|
497
602
|
// Containment is `parents`; a border to a parent or a child restates
|
|
498
603
|
// it as adjacency, and the two cannot both be true.
|
|
499
604
|
if (!isRoute) {
|
|
500
|
-
if (hasParent(data.parents,
|
|
605
|
+
if (hasParent(data.parents, toAddress, here)) {
|
|
501
606
|
error(
|
|
502
607
|
i,
|
|
503
608
|
undefined,
|
|
504
|
-
`${label(i)} borders "${to}", which is this place's parent; ` +
|
|
609
|
+
`${label(i)} borders "${encodeAddresses(to)}", which is this place's parent; ` +
|
|
505
610
|
`containment is \`parents\`, and a place never borders its parent`,
|
|
506
611
|
);
|
|
507
612
|
return;
|
|
508
613
|
}
|
|
509
|
-
if (hasParent(target.parents,
|
|
614
|
+
if (hasParent(target.parents, selfAddress, here)) {
|
|
510
615
|
error(
|
|
511
616
|
i,
|
|
512
617
|
undefined,
|
|
513
|
-
`${label(i)} borders "${to}", which is this place's child; ` +
|
|
618
|
+
`${label(i)} borders "${encodeAddresses(to)}", which is this place's child; ` +
|
|
514
619
|
`containment is \`parents\`, and a place never borders its child`,
|
|
515
620
|
);
|
|
516
621
|
return;
|
|
@@ -518,8 +623,13 @@ function checkRelation(note, { field, index }) {
|
|
|
518
623
|
}
|
|
519
624
|
|
|
520
625
|
if (!bearingIsValid) return;
|
|
521
|
-
const back = entriesTo(target[field],
|
|
626
|
+
const back = entriesTo(target[field], selfAddress, here);
|
|
522
627
|
const expected = oppositeBearing(bearing);
|
|
628
|
+
// How the far end writes this place: its bare `shortcode` within the same
|
|
629
|
+
// package, and the full Address across a package boundary, where the far
|
|
630
|
+
// note is authored in a package whose own short forms name its own
|
|
631
|
+
// places.
|
|
632
|
+
const selfThere = read.package === here.package || !selfAddress ? self : selfAddress;
|
|
523
633
|
|
|
524
634
|
if (!isRoute) {
|
|
525
635
|
if (back.length === 0) {
|
|
@@ -528,9 +638,9 @@ function checkRelation(note, { field, index }) {
|
|
|
528
638
|
i,
|
|
529
639
|
undefined,
|
|
530
640
|
"warning",
|
|
531
|
-
`"${self}" borders "${to}" at ${bearing}, but "${to}" states no ` +
|
|
641
|
+
`"${self}" borders "${encodeAddresses(to)}" at ${bearing}, but "${encodeAddresses(to)}" states no ` +
|
|
532
642
|
`border to "${self}"; it should carry ` +
|
|
533
|
-
`\`{ to: ${
|
|
643
|
+
`\`{ to: ${selfThere}, bearing: ${expected} }\``,
|
|
534
644
|
),
|
|
535
645
|
);
|
|
536
646
|
return;
|
|
@@ -540,7 +650,7 @@ function checkRelation(note, { field, index }) {
|
|
|
540
650
|
error(
|
|
541
651
|
i,
|
|
542
652
|
"bearing",
|
|
543
|
-
`"${self}" borders "${to}" at ${bearing}, so "${to}" should state the ` +
|
|
653
|
+
`"${self}" borders "${encodeAddresses(to)}" at ${bearing}, so "${encodeAddresses(to)}" should state the ` +
|
|
544
654
|
`border back at bearing ${expected}, but states ${theirs}`,
|
|
545
655
|
);
|
|
546
656
|
}
|
|
@@ -554,9 +664,9 @@ function checkRelation(note, { field, index }) {
|
|
|
554
664
|
i,
|
|
555
665
|
undefined,
|
|
556
666
|
"warning",
|
|
557
|
-
`"${self}" reaches "${to}" by ${mode} in ${days} days, but "${to}" ` +
|
|
667
|
+
`"${self}" reaches "${encodeAddresses(to)}" by ${mode} in ${days} days, but "${encodeAddresses(to)}" ` +
|
|
558
668
|
`states no route to "${self}"; it should carry ` +
|
|
559
|
-
`\`{ to: ${
|
|
669
|
+
`\`{ to: ${selfThere}, bearing: ${expected}, mode: ${mode}, days: ${days} }\``,
|
|
560
670
|
),
|
|
561
671
|
);
|
|
562
672
|
return;
|
|
@@ -573,7 +683,7 @@ function checkRelation(note, { field, index }) {
|
|
|
573
683
|
error(
|
|
574
684
|
i,
|
|
575
685
|
"mode",
|
|
576
|
-
`"${self}" reaches "${to}" by ${mode}, but "${to}" states the route back ` +
|
|
686
|
+
`"${self}" reaches "${encodeAddresses(to)}" by ${mode}, but "${encodeAddresses(to)}" states the route back ` +
|
|
577
687
|
`by ${oneOf(theirs)} only; a pair is stated by the same mode at ` +
|
|
578
688
|
`both ends`,
|
|
579
689
|
);
|
|
@@ -584,7 +694,7 @@ function checkRelation(note, { field, index }) {
|
|
|
584
694
|
error(
|
|
585
695
|
i,
|
|
586
696
|
"bearing",
|
|
587
|
-
`"${self}" reaches "${to}" at ${bearing}, so "${to}" should state the route ` +
|
|
697
|
+
`"${self}" reaches "${encodeAddresses(to)}" at ${bearing}, so "${encodeAddresses(to)}" should state the route ` +
|
|
588
698
|
`back at bearing ${expected}, but states ${theirBearing}`,
|
|
589
699
|
);
|
|
590
700
|
}
|
|
@@ -593,7 +703,7 @@ function checkRelation(note, { field, index }) {
|
|
|
593
703
|
error(
|
|
594
704
|
i,
|
|
595
705
|
"days",
|
|
596
|
-
`"${self}" reaches "${to}" by ${mode} in ${days} days, but "${to}" states ` +
|
|
706
|
+
`"${self}" reaches "${encodeAddresses(to)}" by ${mode} in ${days} days, but "${encodeAddresses(to)}" states ` +
|
|
597
707
|
`the route back as ${theirDays}; both ends state the same days`,
|
|
598
708
|
);
|
|
599
709
|
}
|
|
@@ -605,7 +715,7 @@ function checkRelation(note, { field, index }) {
|
|
|
605
715
|
/**
|
|
606
716
|
* Check a place note's `data.borders`.
|
|
607
717
|
*
|
|
608
|
-
* Every `to` is
|
|
718
|
+
* Every `to` is an Address a place declares, every `bearing` is one of the
|
|
609
719
|
* eight, no pair is listed twice, no target is the note's own parent or child,
|
|
610
720
|
* and the other end states the pair back at the opposite bearing — a missing
|
|
611
721
|
* reciprocal is a warning naming both notes, a wrong one an error.
|
package/engine/populations.mjs
CHANGED
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
*/
|
|
55
55
|
|
|
56
56
|
import { positionOfFrontmatterPath, positionOfLiteral } from "./diagnostics.mjs";
|
|
57
|
+
import { readCanonicalKey } from "./content-address.mjs";
|
|
57
58
|
import { shortcodesOf } from "./holdings.mjs";
|
|
58
59
|
|
|
59
60
|
/**
|
|
@@ -145,8 +146,12 @@ const CORPUS = new WeakMap();
|
|
|
145
146
|
* whose `parents` name it.
|
|
146
147
|
* @property {Map<string, string[]>} holdersOf Place shortcode → the polities
|
|
147
148
|
* whose `domains` name it.
|
|
148
|
-
* @property {Map<string, PopulationNode>} byAddress `type
|
|
149
|
-
* for the citation rule.
|
|
149
|
+
* @property {Map<string, PopulationNode>} byAddress `type/shortcode` → the
|
|
150
|
+
* node, for the citation rule.
|
|
151
|
+
* Package-blind by design: a
|
|
152
|
+
* local note shadows a
|
|
153
|
+
* dependency's, so this is
|
|
154
|
+
* never a canonical Address.
|
|
150
155
|
*/
|
|
151
156
|
|
|
152
157
|
/**
|
|
@@ -219,10 +224,10 @@ function localNode(note) {
|
|
|
219
224
|
* @param {object} entry - The entry.
|
|
220
225
|
* @returns {PopulationNode|null} The node.
|
|
221
226
|
*/
|
|
222
|
-
function foreignNode(canonical, entry) {
|
|
227
|
+
export function foreignNode(canonical, entry) {
|
|
223
228
|
const type = String(entry?.type ?? "");
|
|
224
229
|
if (type !== "place" && type !== "affiliation") return null;
|
|
225
|
-
const shortcode = canonical
|
|
230
|
+
const shortcode = readCanonicalKey(canonical)?.shortcode?.toLowerCase() ?? "";
|
|
226
231
|
if (!shortcode) return null;
|
|
227
232
|
return {
|
|
228
233
|
shortcode,
|
|
@@ -259,8 +264,11 @@ function corpusOf(index) {
|
|
|
259
264
|
const by = node.type === "place" ? places : affiliations;
|
|
260
265
|
if (by.has(node.shortcode)) return;
|
|
261
266
|
by.set(node.shortcode, node);
|
|
262
|
-
|
|
263
|
-
|
|
267
|
+
// Not a canonical Address: package-blind by design, so a local place
|
|
268
|
+
// shadows a foreign one of the same type and shortcode. `/` keeps that
|
|
269
|
+
// distinct from the `-`-joined form every canonical Address renders.
|
|
270
|
+
const key = `${node.type}/${node.shortcode}`;
|
|
271
|
+
if (!byAddress.has(key)) byAddress.set(key, node);
|
|
264
272
|
};
|
|
265
273
|
|
|
266
274
|
for (const note of index.notes ?? []) take(localNode(note));
|
|
@@ -499,7 +507,7 @@ export function checkPopulation(note, { index } = {}) {
|
|
|
499
507
|
* @type {RegExp}
|
|
500
508
|
*/
|
|
501
509
|
const CITED =
|
|
502
|
-
/\[\[(
|
|
510
|
+
/\[\[(place|affiliation)-([a-z0-9.]+)(?:\\?\|[^\]]*)?\]\]([^[\n]{0,80}?)~([0-9](?:[0-9,]*[0-9])?)(?![\w%]|[.,]\d|[-–—])/g;
|
|
503
511
|
|
|
504
512
|
/**
|
|
505
513
|
* Check the populations a `doc` note cites against the notes they belong to.
|
|
@@ -531,8 +539,8 @@ export function checkCitedPopulations(note, { index } = {}) {
|
|
|
531
539
|
|
|
532
540
|
CITED.lastIndex = 0;
|
|
533
541
|
for (const match of body.matchAll(CITED)) {
|
|
534
|
-
const [,
|
|
535
|
-
const target = corpus.byAddress.get(
|
|
542
|
+
const [, type, shortcode, , figure] = match;
|
|
543
|
+
const target = corpus.byAddress.get(`${type}/${shortcode}`.toLowerCase());
|
|
536
544
|
if (!target || target.population === undefined) continue;
|
|
537
545
|
const claimed = Number(figure.replace(/,/g, ""));
|
|
538
546
|
if (!Number.isFinite(claimed) || claimed === target.population) continue;
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { completeAddress, renderAddress } from "./address.mjs";
|
|
8
|
+
import { systemOf } from "./document-subtypes.mjs";
|
|
9
|
+
import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Names and published destinations for reference presentation.
|
|
13
|
+
* Stubs contribute names, never pages or documents. The published index
|
|
14
|
+
* remains separate so body-link validation retains its publication rules.
|
|
15
|
+
* @param {readonly object[]} records - Decoded local or fetched records.
|
|
16
|
+
* @param {Map<string, object>} [published] - Actual published link destinations.
|
|
17
|
+
* @returns {Map<string, object>} Canonical lookup keys and presentation values.
|
|
18
|
+
*/
|
|
19
|
+
export function buildReferenceTargets(records, published = new Map()) {
|
|
20
|
+
const targets = new Map();
|
|
21
|
+
for (const record of records) {
|
|
22
|
+
if (!record.package || !record.type || !record.shortcode) continue;
|
|
23
|
+
const value = { name: record.name?.full ?? record.name, subType: record.subType };
|
|
24
|
+
for (const system of new Set([
|
|
25
|
+
systemOf(record.type, KNOWN_DOCUMENT_SUBTYPE_MAPS),
|
|
26
|
+
"none",
|
|
27
|
+
])) {
|
|
28
|
+
const address = completeAddress({
|
|
29
|
+
package: record.package,
|
|
30
|
+
system,
|
|
31
|
+
type: record.type,
|
|
32
|
+
shortcode: record.shortcode,
|
|
33
|
+
});
|
|
34
|
+
const key = renderAddress(address);
|
|
35
|
+
targets.set(key, { ...targets.get(key), ...value });
|
|
36
|
+
}
|
|
37
|
+
for (const [system, entry] of Object.entries(record.foundry ?? {})) {
|
|
38
|
+
const address = completeAddress({
|
|
39
|
+
package: record.package,
|
|
40
|
+
system,
|
|
41
|
+
type: record.type,
|
|
42
|
+
shortcode: record.shortcode,
|
|
43
|
+
});
|
|
44
|
+
const key = renderAddress(address);
|
|
45
|
+
targets.set(key, {
|
|
46
|
+
...targets.get(key),
|
|
47
|
+
...value,
|
|
48
|
+
...(entry.uuid ? { uuid: entry.uuid } : {}),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const [key, value] of published) targets.set(key, { ...targets.get(key), ...value });
|
|
53
|
+
return targets;
|
|
54
|
+
}
|
package/engine/scenes.mjs
CHANGED
|
@@ -427,8 +427,11 @@ export class Scenes extends BasePackCompiler {
|
|
|
427
427
|
const scene = buildScene(fm, {
|
|
428
428
|
packageId: foundryPackageId(),
|
|
429
429
|
// The art resolver, so the map pass turns an address into the path
|
|
430
|
-
// each surface serves without holding an index of its own.
|
|
431
|
-
|
|
430
|
+
// each surface serves without holding an index of its own. `accepts`
|
|
431
|
+
// is threaded through so `bgImage`, one of the four declared art
|
|
432
|
+
// slots, is held to its own accepted set here exactly as it is
|
|
433
|
+
// through `artPath`.
|
|
434
|
+
art: (value, key, type, accepts) => this.artPathOf(value, key, type, accepts),
|
|
432
435
|
name,
|
|
433
436
|
folder,
|
|
434
437
|
stats: this.stats,
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { completeAddress, isAddressTuple, readCanonicalKey, renderAddress } from "./address.mjs";
|
|
8
|
+
|
|
9
|
+
const nativeIndexes = new WeakMap();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Index a completed presentation map by native namespace and Shortcode.
|
|
13
|
+
* Callers finish populating a map before resolving references through it.
|
|
14
|
+
* @param {Map<string, object>} index - Completed identity index.
|
|
15
|
+
* @returns {Map<string, object[]>} Native matches grouped by declared namespace.
|
|
16
|
+
*/
|
|
17
|
+
function nativeIndex(index) {
|
|
18
|
+
if (nativeIndexes.has(index)) return nativeIndexes.get(index);
|
|
19
|
+
const grouped = new Map();
|
|
20
|
+
for (const [key, value] of index) {
|
|
21
|
+
const address = readCanonicalKey(key);
|
|
22
|
+
if (!isAddressTuple(address)) continue;
|
|
23
|
+
const namespace = `${address.system}/${address.type}/${address.shortcode}`;
|
|
24
|
+
const matches = grouped.get(namespace) ?? [];
|
|
25
|
+
matches.push({ key, address, value });
|
|
26
|
+
grouped.set(namespace, matches);
|
|
27
|
+
}
|
|
28
|
+
nativeIndexes.set(index, grouped);
|
|
29
|
+
return grouped;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Present a native Shortcode using its unique system/type target.
|
|
34
|
+
* Address properties never use this lookup. Multiple packages claiming the
|
|
35
|
+
* same native identity remain ambiguous. Documentation supplies links; the
|
|
36
|
+
* native target supplies a name even when its documentation is unpublished.
|
|
37
|
+
* @param {readonly Map<string, object>[]} indexes - Published identity indexes.
|
|
38
|
+
* @param {unknown} code - Native Shortcode.
|
|
39
|
+
* @param {{type: string, system: string}} hint - Its declared native namespace.
|
|
40
|
+
* @returns {object|undefined} Documentation presentation, or no unique target.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveShortcodeReference(indexes, code, hint) {
|
|
43
|
+
if (typeof code !== "string" || !code || !hint.type || !hint.system) return undefined;
|
|
44
|
+
const matches = new Map();
|
|
45
|
+
const namespace = `${hint.system}/${hint.type}/${code.toLowerCase()}`;
|
|
46
|
+
for (const index of indexes) {
|
|
47
|
+
if (!index) continue;
|
|
48
|
+
for (const match of nativeIndex(index).get(namespace) ?? [])
|
|
49
|
+
if (!matches.has(match.key)) matches.set(match.key, match);
|
|
50
|
+
}
|
|
51
|
+
if (matches.size !== 1) return undefined;
|
|
52
|
+
const { address: native, value } = matches.values().next().value;
|
|
53
|
+
const address = completeAddress({
|
|
54
|
+
package: native.package,
|
|
55
|
+
system: "none",
|
|
56
|
+
type: native.type,
|
|
57
|
+
shortcode: native.shortcode,
|
|
58
|
+
});
|
|
59
|
+
const key = renderAddress(address);
|
|
60
|
+
const documentation = indexes.map((index) => index?.get(key)).find(Boolean);
|
|
61
|
+
return {
|
|
62
|
+
name: documentation?.name ?? value.name,
|
|
63
|
+
subType: documentation?.subType ?? value.subType,
|
|
64
|
+
address,
|
|
65
|
+
...(documentation?.uuid ? { uuid: documentation.uuid } : {}),
|
|
66
|
+
...(documentation?.url ? { url: documentation.url } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|