@heroiclands/package-build 22.28.0 → 23.1.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 +72 -0
- package/README.md +1 -1
- package/bin/content-commands.mjs +1 -1
- package/ci/content-format-reference.mjs +46 -0
- package/docs/api.md +125 -142
- package/docs/authoring/assets.md +1 -1
- package/docs/authoring/dates-and-calendars.md +12 -6
- package/docs/authoring/first-note.md +1 -1
- package/docs/authoring/frontmatter.md +2 -2
- package/docs/authoring/links-and-markup.md +145 -29
- package/docs/commands.md +8 -10
- package/docs/configuration.md +13 -11
- package/docs/content-format.md +3 -3
- package/docs/guides/book.md +59 -4
- package/docs/guides/icons.md +4 -5
- package/docs/guides/packs.md +6 -0
- package/docs/guides/reference-pages.md +21 -1
- package/docs/guides/release-and-deploy.md +17 -1
- package/docs/guides/site.md +17 -18
- package/docs/reference/format-details.md +904 -275
- package/docs/reference/note-types.md +282 -91
- package/docs/reference/theme-pages.md +58 -66
- package/engine/address-values.mjs +124 -4
- package/engine/address.mjs +44 -3
- package/engine/anchors.mjs +103 -3
- package/engine/art-slots.mjs +2 -9
- package/engine/base-compiler.mjs +25 -6
- package/engine/calendar-artifacts.mjs +4 -1
- package/engine/calendar-notes.mjs +166 -102
- package/engine/content-format-check.mjs +2 -3
- package/engine/content-format.mjs +44 -1
- package/engine/content-format.yaml +84 -16
- package/engine/content-images.mjs +10 -1
- package/engine/content-index.mjs +37 -9
- package/engine/content-links.mjs +24 -5
- package/engine/content-tables.mjs +24 -3
- package/engine/data-keys.mjs +347 -0
- package/engine/derived-sections.mjs +322 -0
- package/engine/diagnostics.mjs +28 -0
- package/engine/event-fields.mjs +187 -0
- package/engine/event-rows.mjs +126 -0
- package/engine/event-views.mjs +305 -0
- package/engine/exported-scene.mjs +29 -53
- package/engine/frontmatter-lint.mjs +103 -77
- package/engine/frontmatter.mjs +7 -2
- package/engine/generate.mjs +53 -0
- package/engine/generated-sections.mjs +223 -0
- package/engine/heading-attributes.mjs +4 -2
- package/engine/helpers.mjs +12 -1
- package/engine/holdings.mjs +12 -33
- package/engine/ids.mjs +17 -11
- package/engine/index.mjs +4 -4
- package/engine/infobox-render.mjs +21 -6
- package/engine/infobox.mjs +217 -99
- package/engine/journals.mjs +104 -47
- package/engine/literature-notes.mjs +136 -0
- package/engine/literature-works.mjs +161 -0
- package/engine/map-graphviz.mjs +4 -18
- package/engine/markdown-expressions.mjs +116 -11
- package/engine/metadata-index.mjs +34 -0
- package/engine/near-miss.mjs +90 -0
- package/engine/note-addresses.mjs +75 -19
- package/engine/note-dates.mjs +5 -3
- package/engine/note-event-terms.mjs +364 -0
- package/engine/note-events.mjs +823 -0
- package/engine/note-vocabulary.mjs +195 -92
- package/engine/office-holders.mjs +20 -54
- package/engine/packagebuild-index.mjs +16 -1
- package/engine/pdf-build.mjs +97 -60
- package/engine/pdf-index.mjs +263 -0
- package/engine/pdf-render.mjs +47 -5
- package/engine/place-relation-terms.mjs +174 -0
- package/engine/place-relations.mjs +72 -160
- package/engine/rank-ladder.mjs +14 -41
- package/engine/reckoning-markers.mjs +2 -0
- package/engine/region-events.mjs +6 -12
- package/engine/scenes.mjs +73 -468
- package/engine/site-build.mjs +74 -85
- package/engine/site-index.mjs +3 -1
- package/engine/site-maps.mjs +43 -29
- package/engine/site-preview.mjs +40 -31
- package/engine/social-ties.mjs +2 -1
- package/engine/sql-tables.mjs +212 -9
- package/engine/standing-terms.mjs +145 -6
- package/engine/standings.mjs +37 -49
- package/engine/stub-lint.mjs +6 -0
- package/engine/web-wikilinks.mjs +21 -1
- package/engine/wikilink-syntax.mjs +34 -4
- package/engine/wikilinks.mjs +27 -2
- package/hugo-theme/layouts/partials/infobox/entry.html +4 -1
- package/hugo-theme/layouts/partials/page-body.html +0 -2
- package/hugo-theme/static/css/style.css +25 -55
- package/package.json +1 -2
- package/packagebuild-metadata.jsonl +13 -13
- package/sohl/default-item-art.mjs +6 -6
- package/sohl/note-schemas.mjs +8 -73
- package/types/engine/address-values.d.mts +50 -3
- package/types/engine/address.d.mts +21 -0
- package/types/engine/anchors.d.mts +49 -2
- package/types/engine/art-slots.d.mts +1 -1
- package/types/engine/base-compiler.d.mts +13 -4
- package/types/engine/calendar-notes.d.mts +7 -0
- package/types/engine/content-format.d.mts +52 -0
- package/types/engine/content-images.d.mts +5 -1
- package/types/engine/content-tables.d.mts +9 -1
- package/types/engine/data-keys.d.mts +145 -0
- package/types/engine/derived-sections.d.mts +73 -0
- package/types/engine/diagnostics.d.mts +15 -0
- package/types/engine/event-fields.d.mts +99 -0
- package/types/engine/event-rows.d.mts +12 -0
- package/types/engine/event-views.d.mts +67 -0
- package/types/engine/exported-scene.d.mts +5 -2
- package/types/engine/frontmatter-lint.d.mts +3 -3
- package/types/engine/generated-sections.d.mts +143 -0
- package/types/engine/holdings.d.mts +6 -20
- package/types/engine/ids.d.mts +15 -12
- package/types/engine/index.d.mts +1 -1
- package/types/engine/infobox.d.mts +13 -3
- package/types/engine/journals.d.mts +24 -11
- package/types/engine/literature-notes.d.mts +29 -0
- package/types/engine/literature-works.d.mts +67 -0
- package/types/engine/map-graphviz.d.mts +2 -10
- package/types/engine/markdown-expressions.d.mts +12 -3
- package/types/engine/near-miss.d.mts +42 -0
- package/types/engine/note-addresses.d.mts +7 -1
- package/types/engine/note-event-terms.d.mts +68 -0
- package/types/engine/note-events.d.mts +71 -0
- package/types/engine/note-vocabulary.d.mts +37 -8
- package/types/engine/office-holders.d.mts +3 -0
- package/types/engine/pdf-index.d.mts +35 -0
- package/types/engine/pdf-render.d.mts +8 -2
- package/types/engine/place-relation-terms.d.mts +83 -0
- package/types/engine/place-relations.d.mts +11 -66
- package/types/engine/rank-ladder.d.mts +2 -0
- package/types/engine/region-events.d.mts +6 -12
- package/types/engine/scenes.d.mts +6 -32
- package/types/engine/site-build.d.mts +8 -22
- package/types/engine/site-maps.d.mts +15 -7
- package/types/engine/sql-tables.d.mts +77 -2
- package/types/engine/standing-terms.d.mts +37 -4
- package/types/engine/standings.d.mts +14 -9
- package/types/engine/wikilink-syntax.d.mts +20 -0
- package/types/engine/wikilinks.d.mts +3 -2
- package/docs/guides/government-migration.md +0 -52
- package/engine/itinerary-scenes.mjs +0 -166
- package/engine/lore-events.mjs +0 -197
- package/engine/map-notes.mjs +0 -1309
- package/engine/map-raster.mjs +0 -51
- package/hugo-theme/layouts/partials/holdings/entry.html +0 -21
- package/hugo-theme/layouts/partials/holdings/kind-groups.html +0 -71
- package/hugo-theme/layouts/partials/holdings.html +0 -61
- package/hugo-theme/layouts/partials/place-map.html +0 -9
- package/types/engine/itinerary-scenes.d.mts +0 -29
- package/types/engine/lore-events.d.mts +0 -14
- package/types/engine/map-notes.d.mts +0 -297
- package/types/engine/map-raster.d.mts +0 -8
|
@@ -8,6 +8,7 @@ import { formatDateInCalendar, parseNoteDate } from "./note-dates.mjs";
|
|
|
8
8
|
import { numberWords, numberDigits } from "./number-words.mjs";
|
|
9
9
|
import { slugify } from "./content-slug.mjs";
|
|
10
10
|
import { WIKILINK, authoredLabel, isSamePage, parseWikilink } from "./wikilink-syntax.mjs";
|
|
11
|
+
import { EVENT_REFERENCE_FIELDS, eventFieldText } from "./event-fields.mjs";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* An inline expression, as an author writes one.
|
|
@@ -152,7 +153,9 @@ const HELPERS = Object.freeze([
|
|
|
152
153
|
}),
|
|
153
154
|
Object.freeze({
|
|
154
155
|
name: "ref",
|
|
155
|
-
params:
|
|
156
|
+
params:
|
|
157
|
+
'address [form="number"|"full"|"title"] ' +
|
|
158
|
+
'| address field="when"|"until"|"kind"|"summary"|"name"',
|
|
156
159
|
summary:
|
|
157
160
|
'A link to a captioned item, by its anchor — `"#thorn"` on this note, ' +
|
|
158
161
|
'`"note-address#thorn"` on another. `form` is `number` (the default, and ' +
|
|
@@ -160,11 +163,21 @@ const HELPERS = Object.freeze([
|
|
|
160
163
|
"caption; or `title`, the caption alone. Always renders as a link to the " +
|
|
161
164
|
"figure, and a link inside the caption contributes only its label text. " +
|
|
162
165
|
"The number rendered is the one the figure carries on the surface doing " +
|
|
163
|
-
"the rendering, whichever note names it."
|
|
166
|
+
"the rendering, whichever note names it. With `field` in place of `form`, " +
|
|
167
|
+
'the address names an event — `"place-ironfells#sack"`, `"#sack"` on this ' +
|
|
168
|
+
'note, or `"lore-founding"` for a note holding one event — and the reference ' +
|
|
169
|
+
"prints that event's `when`, `until`, `kind`, `summary` or `name` as plain " +
|
|
170
|
+
"text, never a link; a date prints as the note format prints any date.",
|
|
164
171
|
make:
|
|
165
|
-
({ figures }) =>
|
|
172
|
+
({ figures, events, fm, dates }) =>
|
|
166
173
|
(address, options) => {
|
|
167
174
|
const hash = options?.hash ?? {};
|
|
175
|
+
if (Object.hasOwn(hash, "field")) {
|
|
176
|
+
if (Object.hasOwn(hash, "form")) throw new RangeError(REF_BOTH_FAULT);
|
|
177
|
+
const result = eventReference(address, hash.field, { events, fm, dates });
|
|
178
|
+
if (result.problem) throw new RangeError(result.problem);
|
|
179
|
+
return result.text;
|
|
180
|
+
}
|
|
168
181
|
const form = Object.hasOwn(hash, "form") ? hash.form : "number";
|
|
169
182
|
const formFault = refFormFault(form);
|
|
170
183
|
if (formFault) throw new RangeError(formFault);
|
|
@@ -184,6 +197,74 @@ const HELPERS = Object.freeze([
|
|
|
184
197
|
}),
|
|
185
198
|
]);
|
|
186
199
|
|
|
200
|
+
/** What a reference writing both `form` and `field` is told. */
|
|
201
|
+
const REF_BOTH_FAULT = "ref takes form= for a figure or field= for an event, not both";
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The text one event reference prints, or why it prints nothing.
|
|
205
|
+
*
|
|
206
|
+
* The address is read through the wikilink grammar, as a figure reference's
|
|
207
|
+
* is: `"#sack"` names an event of this note, `"place-ironfells#sack"` one of
|
|
208
|
+
* another, and `"lore-founding"` a note holding exactly one event. The anchor
|
|
209
|
+
* must be one the note declares, and an `event` one.
|
|
210
|
+
*
|
|
211
|
+
* @param {unknown} address - The reference's address.
|
|
212
|
+
* @param {unknown} field - The field to print, one of {@link EVENT_REFERENCE_FIELDS}.
|
|
213
|
+
* @param {{events?: {note: (written: string) => object|undefined,
|
|
214
|
+
* own?: (fm: object) => object|undefined}, fm?: object,
|
|
215
|
+
* dates?: object}} context - Every note's events, this note's frontmatter,
|
|
216
|
+
* and the reckoning context.
|
|
217
|
+
* @returns {{text: string}|{problem: string}} The text, with any link reduced to
|
|
218
|
+
* its label, or the finding's message.
|
|
219
|
+
*/
|
|
220
|
+
function eventReference(address, field, { events, fm, dates } = {}) {
|
|
221
|
+
if (!EVENT_REFERENCE_FIELDS.includes(field))
|
|
222
|
+
return { problem: `ref's field must be one of ${EVENT_REFERENCE_FIELDS.join(", ")}` };
|
|
223
|
+
if (typeof address !== "string" || !address.trim())
|
|
224
|
+
return { problem: "ref needs an address naming an event" };
|
|
225
|
+
if (!events?.note)
|
|
226
|
+
return { problem: `ref "${address}" names an event, and this build resolves none` };
|
|
227
|
+
const parsed = parseWikilink(address);
|
|
228
|
+
const same = isSamePage(parsed);
|
|
229
|
+
const note = same ? events.own?.(fm) : events.note(parsed.target);
|
|
230
|
+
if (!note)
|
|
231
|
+
return {
|
|
232
|
+
problem:
|
|
233
|
+
same ?
|
|
234
|
+
`ref "${address}" names an event of this note, which this build does not resolve`
|
|
235
|
+
: `ref "${address}" addresses "${parsed.target}", which names no note this build resolves`,
|
|
236
|
+
};
|
|
237
|
+
let event;
|
|
238
|
+
if (parsed.anchor) {
|
|
239
|
+
const anchor = note.anchors.find((one) => one?.slug === parsed.anchor);
|
|
240
|
+
event = note.events.find((one) => one.id === parsed.anchor);
|
|
241
|
+
if (anchor && anchor.kind !== "event")
|
|
242
|
+
return {
|
|
243
|
+
problem:
|
|
244
|
+
`ref "${address}" names "#${parsed.anchor}", a ${anchor.kind ?? "prose"} ` +
|
|
245
|
+
"anchor — field= reads an event",
|
|
246
|
+
};
|
|
247
|
+
if (!event)
|
|
248
|
+
return {
|
|
249
|
+
problem: `ref "${address}" names no anchor "#${parsed.anchor}" in "${note.target}"`,
|
|
250
|
+
};
|
|
251
|
+
} else {
|
|
252
|
+
if (note.events.length !== 1)
|
|
253
|
+
return {
|
|
254
|
+
problem:
|
|
255
|
+
note.events.length === 0 ?
|
|
256
|
+
`ref "${address}" names "${note.target}", which holds no events`
|
|
257
|
+
: `ref "${address}" names "${note.target}", which holds ` +
|
|
258
|
+
`${note.events.length} events — name one as "${parsed.target}#<id>"`,
|
|
259
|
+
};
|
|
260
|
+
event = note.events[0];
|
|
261
|
+
}
|
|
262
|
+
const value = eventFieldText(event, field, dates);
|
|
263
|
+
if (value.missing)
|
|
264
|
+
return { problem: `ref "${address}" field="${field}": that event states no ${field}` };
|
|
265
|
+
return { text: flattenCaptionLinks(value.text) };
|
|
266
|
+
}
|
|
267
|
+
|
|
187
268
|
/** The closed set of forms a figure reference may render as; `number` is the default. */
|
|
188
269
|
const REF_FORMS = Object.freeze(["number", "full", "title"]);
|
|
189
270
|
|
|
@@ -382,10 +463,25 @@ export function sqlQueriesInMarkdown(body, fm = {}) {
|
|
|
382
463
|
* @param {object} node - A `MustacheStatement`/`SubExpression` AST node.
|
|
383
464
|
* @param {object} [figures] - This note's figures, and how to reach another
|
|
384
465
|
* note's — see {@link refFigure}.
|
|
466
|
+
* @param {object} [eventContext] - What an event reference reads — see
|
|
467
|
+
* {@link eventReference}.
|
|
385
468
|
* @returns {string|null} A finding message, or `null` when the call is sound.
|
|
386
469
|
*/
|
|
387
|
-
function refCallFault(node, figures) {
|
|
470
|
+
function refCallFault(node, figures, eventContext) {
|
|
388
471
|
const pair = node.hash?.pairs?.find((entry) => entry.key === "form");
|
|
472
|
+
const fieldPair = node.hash?.pairs?.find((entry) => entry.key === "field");
|
|
473
|
+
if (fieldPair) {
|
|
474
|
+
if (fieldPair.value.type !== "StringLiteral")
|
|
475
|
+
return (
|
|
476
|
+
"ref's field must be a quoted string — one of " + EVENT_REFERENCE_FIELDS.join(", ")
|
|
477
|
+
);
|
|
478
|
+
if (pair) return REF_BOTH_FAULT;
|
|
479
|
+
const addressParam = node.params?.[0];
|
|
480
|
+
if (addressParam?.type !== "StringLiteral") return null;
|
|
481
|
+
return (
|
|
482
|
+
eventReference(addressParam.value, fieldPair.value.value, eventContext).problem ?? null
|
|
483
|
+
);
|
|
484
|
+
}
|
|
389
485
|
if (pair && pair.value.type !== "StringLiteral")
|
|
390
486
|
return `ref's form must be a quoted string — one of ${REF_FORMS.join(", ")}`;
|
|
391
487
|
const form = pair ? pair.value.value : "number";
|
|
@@ -413,10 +509,12 @@ function refCallFault(node, figures) {
|
|
|
413
509
|
* @param {string} body
|
|
414
510
|
* @param {object} [figures] - This note's figures, and how to reach another
|
|
415
511
|
* note's — see {@link refFigure}.
|
|
512
|
+
* @param {object} [eventContext] - What an event reference reads — see
|
|
513
|
+
* {@link eventReference}.
|
|
416
514
|
* @returns {Array<{offset: number, message: string}>} One entry per faulty
|
|
417
515
|
* `{{...}}` expression, `offset` into `body`.
|
|
418
516
|
*/
|
|
419
|
-
function refFaults(body, figures) {
|
|
517
|
+
function refFaults(body, figures, eventContext) {
|
|
420
518
|
const faults = [];
|
|
421
519
|
for (const match of matchAllOutsideCode(body, EXPRESSION)) {
|
|
422
520
|
let program;
|
|
@@ -428,7 +526,7 @@ function refFaults(body, figures) {
|
|
|
428
526
|
const visit = (node) => {
|
|
429
527
|
if (!node || typeof node !== "object") return;
|
|
430
528
|
if (node.path?.original === "ref") {
|
|
431
|
-
const message = refCallFault(node, figures);
|
|
529
|
+
const message = refCallFault(node, figures, eventContext);
|
|
432
530
|
if (message) faults.push({ offset: match.index, message });
|
|
433
531
|
}
|
|
434
532
|
for (const parameter of node.params ?? []) visit(parameter);
|
|
@@ -452,22 +550,29 @@ function positionAt(body, offset, bodyLine) {
|
|
|
452
550
|
* figures?: {get: (id: string) => {label: string, caption: string,
|
|
453
551
|
* hasCaption: boolean}|undefined, note?: (address: string) =>
|
|
454
552
|
* {url: string|null, figures: Map<string, object>}|undefined,
|
|
455
|
-
* link?: Function},
|
|
553
|
+
* link?: Function}, events?: {note: (written: string) => object|undefined,
|
|
554
|
+
* own?: (fm: object) => object|undefined},
|
|
555
|
+
* file?: string, bodyLine?: number}} [options] -
|
|
456
556
|
* `figures` is the `ref` helper's own view of the corpus: `get` reads this
|
|
457
557
|
* note's captioned items by id, and `note` resolves another note's the
|
|
458
558
|
* way a wikilink would — omitted, a cross-note address is refused rather
|
|
459
559
|
* than looked up. `link` overrides how a resolved reference renders; the
|
|
460
|
-
* default is a Markdown link to the target's own anchor.
|
|
560
|
+
* default is a Markdown link to the target's own anchor. `events` is what an
|
|
561
|
+
* event reference reads: every note's events by Address, from
|
|
562
|
+
* {@link module:engine/event-fields.eventNoteIndex}; omitted, an event
|
|
563
|
+
* reference is refused.
|
|
461
564
|
*/
|
|
462
565
|
export function renderMarkdownExpressions(
|
|
463
566
|
body,
|
|
464
|
-
{ fm, dates, sqlResults, figures, file, bodyLine = 1 } = {},
|
|
567
|
+
{ fm, dates, sqlResults, figures, events, file, bodyLine = 1 } = {},
|
|
465
568
|
) {
|
|
466
569
|
const findings = [];
|
|
467
570
|
const engine = Handlebars.create();
|
|
468
571
|
for (const helper of HELPERS)
|
|
469
|
-
engine.registerHelper(helper.name, helper.make({ sqlResults, dates, figures }));
|
|
470
|
-
const staticFaults = new Map(
|
|
572
|
+
engine.registerHelper(helper.name, helper.make({ sqlResults, dates, figures, events, fm }));
|
|
573
|
+
const staticFaults = new Map(
|
|
574
|
+
refFaults(body, figures, { events, fm, dates }).map((f) => [f.offset, f.message]),
|
|
575
|
+
);
|
|
471
576
|
const markdown = replaceOutsideCode(body, EXPRESSION, (source, _inside, offset) => {
|
|
472
577
|
const fault = staticFaults.get(offset);
|
|
473
578
|
if (fault) {
|
|
@@ -307,6 +307,27 @@ function newestIndex(dirs) {
|
|
|
307
307
|
return path.join(dir, entries[0]);
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
+
/**
|
|
311
|
+
* A published record's events, each with its `id`, the dates the publishing
|
|
312
|
+
* package resolved for it, and the entry as published, which an inline
|
|
313
|
+
* reference to one of its fields reads.
|
|
314
|
+
*
|
|
315
|
+
* @param {Record<string, any>} record - An index record.
|
|
316
|
+
* @returns {Array<{id?: string, when?: object, until?: object, entry: object}>|undefined}
|
|
317
|
+
* In entry order, or `undefined` for a note with none.
|
|
318
|
+
*/
|
|
319
|
+
function publishedEvents(record) {
|
|
320
|
+
const events = record.data?.events;
|
|
321
|
+
if (!Array.isArray(events) || events.length === 0) return undefined;
|
|
322
|
+
const resolved = record.resolvedDates?.events ?? [];
|
|
323
|
+
return events.map((entry, position) => ({
|
|
324
|
+
...(typeof entry?.id === "string" ? { id: entry.id } : {}),
|
|
325
|
+
...(resolved[position]?.when ? { when: resolved[position].when } : {}),
|
|
326
|
+
...(resolved[position]?.until ? { until: resolved[position].until } : {}),
|
|
327
|
+
entry,
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
|
|
310
331
|
/**
|
|
311
332
|
* Resolve every foreign address this build can cite, from the fetched indexes.
|
|
312
333
|
*
|
|
@@ -437,6 +458,19 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
|
|
|
437
458
|
...(Object.hasOwn(record.data ?? {}, "government") ?
|
|
438
459
|
{ government: record.data.government }
|
|
439
460
|
: {}),
|
|
461
|
+
// Every anchor the note declares, with its kind, and each of its
|
|
462
|
+
// events with its id and resolved date, so a consumer's
|
|
463
|
+
// `follows` edge into this package is resolved and ordered here.
|
|
464
|
+
noteAnchors: record.anchors ?? undefined,
|
|
465
|
+
events: publishedEvents(record),
|
|
466
|
+
// What a work of literature concerns and what kind of work it
|
|
467
|
+
// is, so a consumer's subject pages list a dependency's works.
|
|
468
|
+
...(parts.type === "lore" && record.subType === "literature" ?
|
|
469
|
+
{
|
|
470
|
+
subjects: record.data?.subjects ?? undefined,
|
|
471
|
+
form: record.data?.form ?? undefined,
|
|
472
|
+
}
|
|
473
|
+
: {}),
|
|
440
474
|
package: pkg,
|
|
441
475
|
});
|
|
442
476
|
const entry = index.get(key);
|
|
@@ -0,0 +1,90 @@
|
|
|
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 near miss a closed vocabulary names when it refuses a key: the declared
|
|
16
|
+
* key an unknown one was most likely meant to be, and the declared list as an
|
|
17
|
+
* English sentence.
|
|
18
|
+
*
|
|
19
|
+
* Shared by every closed region — the top level, `data:`, a system block, and
|
|
20
|
+
* the inner keys of a `data:` field — so a misspelling is answered the same
|
|
21
|
+
* way at every depth.
|
|
22
|
+
*
|
|
23
|
+
* @module
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Edit distance, capped — enough to answer "did you mean".
|
|
28
|
+
*
|
|
29
|
+
* A misspelled property is the failure class this check exists for, and a
|
|
30
|
+
* finding that names the key the author *meant* turns a hunt through the
|
|
31
|
+
* reference into a one-character fix.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} a - One string.
|
|
34
|
+
* @param {string} b - The other.
|
|
35
|
+
* @returns {number} The Levenshtein distance.
|
|
36
|
+
*/
|
|
37
|
+
export function distance(a, b) {
|
|
38
|
+
const rows = a.length + 1;
|
|
39
|
+
const cols = b.length + 1;
|
|
40
|
+
let prev = Array.from({ length: cols }, (_, j) => j);
|
|
41
|
+
for (let i = 1; i < rows; i += 1) {
|
|
42
|
+
const row = [i];
|
|
43
|
+
for (let j = 1; j < cols; j += 1) {
|
|
44
|
+
row[j] = Math.min(
|
|
45
|
+
prev[j] + 1,
|
|
46
|
+
row[j - 1] + 1,
|
|
47
|
+
prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
prev = row;
|
|
51
|
+
}
|
|
52
|
+
return prev[cols - 1];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A closed vocabulary as an English list, for the message that names it.
|
|
57
|
+
*
|
|
58
|
+
* Written from the declaration rather than spelled into the message, so a key
|
|
59
|
+
* the vocabulary gains cannot go unmentioned by the finding that refuses its
|
|
60
|
+
* neighbours.
|
|
61
|
+
*
|
|
62
|
+
* @param {readonly string[]} keys - The declared keys, in declared order.
|
|
63
|
+
* @returns {string} `"a, b, or c"`.
|
|
64
|
+
*/
|
|
65
|
+
export function listed(keys) {
|
|
66
|
+
if (keys.length < 2) return keys.join("");
|
|
67
|
+
return `${keys.slice(0, -1).join(", ")}, or ${keys[keys.length - 1]}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The declared key an unknown one was most likely meant to be.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} key - The unknown key.
|
|
74
|
+
* @param {Iterable<string>} candidates - The declared keys.
|
|
75
|
+
* @returns {string|undefined} The nearest, when it is near enough to suggest.
|
|
76
|
+
*/
|
|
77
|
+
export function nearest(key, candidates) {
|
|
78
|
+
let best;
|
|
79
|
+
let bestAt = Infinity;
|
|
80
|
+
for (const candidate of candidates) {
|
|
81
|
+
const d = distance(key.toLowerCase(), candidate.toLowerCase());
|
|
82
|
+
if (d < bestAt) {
|
|
83
|
+
bestAt = d;
|
|
84
|
+
best = candidate;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// A third of the key's length, so a suggestion is a plausible typo rather
|
|
88
|
+
// than the least-bad of a list of unrelated words.
|
|
89
|
+
return bestAt <= Math.max(1, Math.floor(key.length / 3)) ? best : undefined;
|
|
90
|
+
}
|
|
@@ -12,15 +12,25 @@ import {
|
|
|
12
12
|
parseAddress,
|
|
13
13
|
renderAddress,
|
|
14
14
|
acceptsType,
|
|
15
|
+
hasAnchor,
|
|
15
16
|
readCanonicalKey,
|
|
17
|
+
splitAnchor,
|
|
16
18
|
} from "./address.mjs";
|
|
17
19
|
import { NOTE_VOCABULARY, dataFields } from "./note-vocabulary.mjs";
|
|
18
20
|
import { ASSET_TYPE_NAMES } from "./asset-types.mjs";
|
|
19
21
|
import { ART_SLOTS } from "./art-slots.mjs";
|
|
20
22
|
import { SHIPPED_SYSTEMS } from "./subtype-registry.mjs";
|
|
23
|
+
import { EVENT_ADDRESS_POSITIONS } from "./note-events.mjs";
|
|
21
24
|
|
|
22
|
-
import { AddressEntries, AddressLink } from "./address-values.mjs";
|
|
23
|
-
export {
|
|
25
|
+
import { AddressEntries, AddressLink, readPublishedAddresses } from "./address-values.mjs";
|
|
26
|
+
export {
|
|
27
|
+
AddressEntries,
|
|
28
|
+
encodeAddresses,
|
|
29
|
+
cloneAddressState,
|
|
30
|
+
publishAddresses,
|
|
31
|
+
readPublishedAddresses,
|
|
32
|
+
flattenPublishedAddresses,
|
|
33
|
+
} from "./address-values.mjs";
|
|
24
34
|
|
|
25
35
|
/** Structured positions whose Address is nested inside a declared container. */
|
|
26
36
|
export const STRUCTURED_ADDRESSES = Object.freeze([
|
|
@@ -77,12 +87,24 @@ export function addressPositions(fm, context = {}) {
|
|
|
77
87
|
shape,
|
|
78
88
|
type: field.ref,
|
|
79
89
|
accepts: field.accepts,
|
|
90
|
+
...(field.anchors ? { anchors: field.anchors } : {}),
|
|
80
91
|
...(ASSET_TYPE_NAMES.has(field.ref) || field.ref === "folder" ?
|
|
81
92
|
{ system: "none" }
|
|
82
93
|
: {}),
|
|
83
94
|
});
|
|
84
95
|
}
|
|
85
96
|
out.push(...STRUCTURED_ADDRESSES.filter((p) => !p.noteType || p.noteType === fm.type));
|
|
97
|
+
// An event's Addresses, on every type that carries events. Read leniently:
|
|
98
|
+
// a value that is no accepted Address stays as written, for the event
|
|
99
|
+
// check to refuse at its own key with the message its key earns, rather
|
|
100
|
+
// than taking the whole note out of the index.
|
|
101
|
+
if ((dataFields(fm.type) ?? []).some((field) => field.name === "events"))
|
|
102
|
+
for (const position of EVENT_ADDRESS_POSITIONS)
|
|
103
|
+
out.push({
|
|
104
|
+
...position,
|
|
105
|
+
path: ["data", "events", "*", ...position.path],
|
|
106
|
+
lenient: true,
|
|
107
|
+
});
|
|
86
108
|
for (const system of new Set([
|
|
87
109
|
...SHIPPED_SYSTEMS,
|
|
88
110
|
...Object.keys(context.systemBlocks ?? {}),
|
|
@@ -206,7 +228,27 @@ export function decodeNoteAddresses(fm, context) {
|
|
|
206
228
|
};
|
|
207
229
|
const read = (value, path) => {
|
|
208
230
|
if (value == null || value === "") return value;
|
|
209
|
-
|
|
231
|
+
if (value instanceof AddressLink) {
|
|
232
|
+
if (!position.anchors)
|
|
233
|
+
fail(
|
|
234
|
+
value,
|
|
235
|
+
path,
|
|
236
|
+
"takes no anchor — it names a whole note, so write the Address without `#…`",
|
|
237
|
+
);
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
240
|
+
// An anchor is admitted only where the position declares the kinds
|
|
241
|
+
// it takes; the anchor's existence and kind are a resolution
|
|
242
|
+
// question, asked where the index is.
|
|
243
|
+
const anchored = hasAnchor(value);
|
|
244
|
+
if (anchored && !position.anchors)
|
|
245
|
+
fail(
|
|
246
|
+
value,
|
|
247
|
+
path,
|
|
248
|
+
"takes no anchor — it names a whole note, so write the Address without `#…`",
|
|
249
|
+
);
|
|
250
|
+
const { address: written, anchor } = anchored ? splitAnchor(value) : { address: value };
|
|
251
|
+
const tuple = parseAddress(written, defaults, {
|
|
210
252
|
declared: true,
|
|
211
253
|
legacyShortcodeCase: position.legacyShortcodeCase,
|
|
212
254
|
});
|
|
@@ -217,7 +259,13 @@ export function decodeNoteAddresses(fm, context) {
|
|
|
217
259
|
path,
|
|
218
260
|
`must name a ${position.accepts.join(" or ")} Address, but names ${tuple.type}`,
|
|
219
261
|
);
|
|
220
|
-
return
|
|
262
|
+
return anchored ?
|
|
263
|
+
new AddressLink(
|
|
264
|
+
tuple,
|
|
265
|
+
anchor,
|
|
266
|
+
position.anchors.length === 1 ? position.anchors[0] : null,
|
|
267
|
+
)
|
|
268
|
+
: tuple;
|
|
221
269
|
};
|
|
222
270
|
const convert = (value, path) => {
|
|
223
271
|
if (value == null || value === "") return value;
|
|
@@ -258,12 +306,24 @@ export function decodeNoteAddresses(fm, context) {
|
|
|
258
306
|
}
|
|
259
307
|
return read(value, path);
|
|
260
308
|
};
|
|
309
|
+
// A lenient position keeps a value it cannot read, as written.
|
|
310
|
+
const settle = (value, path) => {
|
|
311
|
+
if (!position.lenient) return convert(value, path);
|
|
312
|
+
try {
|
|
313
|
+
return convert(value, path);
|
|
314
|
+
} catch {
|
|
315
|
+
return value;
|
|
316
|
+
}
|
|
317
|
+
};
|
|
261
318
|
// Copy each traversed owner so YAML aliases retain their own read context.
|
|
262
319
|
const visit = (owner, path, offset = 0, actual = []) => {
|
|
263
320
|
if (owner == null || owner === "") return owner;
|
|
264
321
|
const key = path[offset];
|
|
265
322
|
if (key === "*") {
|
|
266
|
-
if (!Array.isArray(owner))
|
|
323
|
+
if (!Array.isArray(owner)) {
|
|
324
|
+
if (position.lenient) return owner;
|
|
325
|
+
fail(owner, actual, "must be a list of entries");
|
|
326
|
+
}
|
|
267
327
|
return owner.map((value, i) => visit(value, path, offset + 1, [...actual, i]));
|
|
268
328
|
}
|
|
269
329
|
if (typeof owner !== "object") return owner;
|
|
@@ -271,7 +331,7 @@ export function decodeNoteAddresses(fm, context) {
|
|
|
271
331
|
const copy = Array.isArray(owner) ? [...owner] : { ...owner };
|
|
272
332
|
copy[key] =
|
|
273
333
|
offset === path.length - 1 ?
|
|
274
|
-
|
|
334
|
+
settle(owner[key], [...actual, key])
|
|
275
335
|
: visit(owner[key], path, offset + 1, [...actual, key]);
|
|
276
336
|
return copy;
|
|
277
337
|
};
|
|
@@ -312,11 +372,18 @@ export function noteAddressContext(config) {
|
|
|
312
372
|
|
|
313
373
|
/**
|
|
314
374
|
* Decode Address properties contributed by an index record.
|
|
375
|
+
*
|
|
376
|
+
* A record read from a published index writes every Address as
|
|
377
|
+
* `{ address, anchor, anchorKind }`; each becomes a tuple, or an
|
|
378
|
+
* {@link AddressLink} where it carries an anchor, before the declared
|
|
379
|
+
* positions are read.
|
|
380
|
+
*
|
|
315
381
|
* @param {object} record - The record.
|
|
316
382
|
* @param {object} context - Builder defaults.
|
|
317
383
|
* @returns {object} The typed record.
|
|
318
384
|
*/
|
|
319
385
|
export function decodeIndexAddresses(record, context) {
|
|
386
|
+
Object.assign(record, readPublishedAddresses(record));
|
|
320
387
|
decodeNoteAddresses(record, context);
|
|
321
388
|
const canonical = (value, keyPath) => {
|
|
322
389
|
const target = readCanonicalKey(value);
|
|
@@ -335,19 +402,8 @@ export function decodeIndexAddresses(record, context) {
|
|
|
335
402
|
if (record.address?.canonical != null)
|
|
336
403
|
record.address.canonical = canonical(record.address.canonical, ["address", "canonical"]);
|
|
337
404
|
for (const anchor of record.anchors ?? []) {
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
const target = parseAddress(
|
|
341
|
-
written,
|
|
342
|
-
{
|
|
343
|
-
...context,
|
|
344
|
-
system: "note",
|
|
345
|
-
types: new Set([...Object.keys(NOTE_VOCABULARY), ...ASSET_TYPE_NAMES]),
|
|
346
|
-
},
|
|
347
|
-
{ declared: true },
|
|
348
|
-
);
|
|
349
|
-
if (target.reason) throw new Error(`Index anchor link is not an Address: ${anchor.link}`);
|
|
350
|
-
anchor.link = new AddressLink(target, section);
|
|
405
|
+
if (anchor.link != null && !(anchor.link instanceof AddressLink))
|
|
406
|
+
throw new Error(`Index anchor link is not an Address: ${JSON.stringify(anchor.link)}`);
|
|
351
407
|
}
|
|
352
408
|
return record;
|
|
353
409
|
}
|
package/engine/note-dates.mjs
CHANGED
|
@@ -955,11 +955,13 @@ function resolveEventEntry(entry, context) {
|
|
|
955
955
|
|
|
956
956
|
/** Normalize a note's declared dates for indexes and generated pages. */
|
|
957
957
|
export function resolvedDateFields(fm, context) {
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
958
|
+
// A note carrying `data.events` — a lore, place or affiliation note — is
|
|
959
|
+
// dated by them.
|
|
960
|
+
if (Array.isArray(fm?.data?.events)) {
|
|
961
|
+
const resolved = fm.data.events.map((entry) => resolveEventEntry(entry, context));
|
|
961
962
|
return resolved.length ? { events: resolved } : {};
|
|
962
963
|
}
|
|
964
|
+
if (fm?.type === "lore") return {};
|
|
963
965
|
const fields =
|
|
964
966
|
fm?.type === "being" ?
|
|
965
967
|
[
|