@heroiclands/package-build 23.0.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 +58 -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 +124 -141
- package/docs/authoring/assets.md +1 -1
- package/docs/authoring/dates-and-calendars.md +12 -6
- 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 +2 -2
- 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 +877 -276
- package/docs/reference/note-types.md +278 -91
- package/docs/reference/theme-pages.md +58 -97
- 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 +79 -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 +4 -1
- package/engine/literature-works.mjs +21 -13
- package/engine/map-graphviz.mjs +4 -18
- package/engine/markdown-expressions.mjs +116 -11
- package/engine/metadata-index.mjs +26 -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 +184 -88
- 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 +75 -101
- 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/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 -3
- package/hugo-theme/static/css/style.css +25 -55
- package/package.json +1 -2
- package/packagebuild-metadata.jsonl +13 -13
- 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-works.d.mts +1 -2
- 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/literature-works.html +0 -29
- 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
package/engine/diagnostics.mjs
CHANGED
|
@@ -377,6 +377,34 @@ export function yamlKeyPath(field) {
|
|
|
377
377
|
return segments;
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
+
/**
|
|
381
|
+
* The scalar a note's frontmatter states at a path, **as the author wrote it**.
|
|
382
|
+
*
|
|
383
|
+
* A check reads a note whose Addresses are already resolved to their canonical
|
|
384
|
+
* form, so a finding quoting the value it holds would quote text the file does
|
|
385
|
+
* not contain. This reads the file instead, through the same parse
|
|
386
|
+
* {@link positionOfYamlPath} uses.
|
|
387
|
+
*
|
|
388
|
+
* @param {string} raw - The note's full contents, frontmatter included.
|
|
389
|
+
* @param {ReadonlyArray<string|number>} keyPath - Path to the node, from the
|
|
390
|
+
* top of the frontmatter.
|
|
391
|
+
* @returns {string|number|boolean|undefined} The scalar, or `undefined` where
|
|
392
|
+
* the path names no scalar.
|
|
393
|
+
*/
|
|
394
|
+
export function frontmatterValueAt(raw, keyPath) {
|
|
395
|
+
if (typeof raw !== "string" || !Array.isArray(keyPath) || keyPath.length === 0)
|
|
396
|
+
return undefined;
|
|
397
|
+
const fence = raw.match(/^---\n([\s\S]*?)\n---/);
|
|
398
|
+
if (!fence) return undefined;
|
|
399
|
+
try {
|
|
400
|
+
const node = YAML.parseDocument(fence[1]).getIn(keyPath, true);
|
|
401
|
+
const value = node?.value;
|
|
402
|
+
return ["string", "number", "boolean"].includes(typeof value) ? value : undefined;
|
|
403
|
+
} catch {
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
380
408
|
/**
|
|
381
409
|
* Where a key sits **inside a note's frontmatter fence**, addressed by path.
|
|
382
410
|
*
|
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
* What an inline `{{ref "<event address>" field="<key>"}}` reads: every note's
|
|
16
|
+
* events, by the note's Address, and how one field of one event prints.
|
|
17
|
+
*
|
|
18
|
+
* **One index for every surface.** The website, the Foundry compile and the
|
|
19
|
+
* book each build it from the same content-index records and the same fetched
|
|
20
|
+
* dependency indexes, so a reference prints the same text on all three.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { ownDocumentSystem, parseAddress, readCanonicalKey, renderAddress } from "./address.mjs";
|
|
26
|
+
import { formatNoteDate, parseNoteDate } from "./note-dates.mjs";
|
|
27
|
+
import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
|
|
28
|
+
|
|
29
|
+
/** The fields of an event an inline reference may print, in the order a message names them. */
|
|
30
|
+
export const EVENT_REFERENCE_FIELDS = Object.freeze(["when", "until", "kind", "summary", "name"]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {object} EventView
|
|
34
|
+
* @property {string} [id] - The event's `id`.
|
|
35
|
+
* @property {object} entry - The event entry as the index holds it.
|
|
36
|
+
* @property {object|null} when - Its resolved `when`, or `null`.
|
|
37
|
+
* @property {object|null} until - Its resolved `until`, or `null`.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {object} EventNote
|
|
42
|
+
* @property {string} target - The note's canonical Address.
|
|
43
|
+
* @property {EventView[]} events - Its events, in entry order.
|
|
44
|
+
* @property {Array<{slug: string, kind?: string}>} anchors - Every anchor it declares.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every note's events, by Address, from this package's index records and the
|
|
49
|
+
* fetched indexes of its dependencies.
|
|
50
|
+
*
|
|
51
|
+
* @param {Iterable<Record<string, any>>} records - This package's content-index records.
|
|
52
|
+
* @param {object} [options]
|
|
53
|
+
* @param {string} [options.contentPackage] - The package a written Address
|
|
54
|
+
* without one defaults to.
|
|
55
|
+
* @param {Map<string, object>} [options.foreignIndex] - The merged fetched
|
|
56
|
+
* indexes, as `loadForeignIndexes` returns them.
|
|
57
|
+
* @param {object} [options.dates] - The reckoning context, for a record that
|
|
58
|
+
* carries no resolved dates.
|
|
59
|
+
* @returns {{note: (written: string) => EventNote|undefined,
|
|
60
|
+
* own: (fm: object) => EventNote|undefined}} The lookup: `note` from a
|
|
61
|
+
* written Address, anchor already removed, to the note it names, and `own`
|
|
62
|
+
* from a note of this package's frontmatter to that note.
|
|
63
|
+
*/
|
|
64
|
+
export function eventNoteIndex(records, { contentPackage, foreignIndex, dates } = {}) {
|
|
65
|
+
/** @type {Map<string, EventNote>} */
|
|
66
|
+
const byKey = new Map();
|
|
67
|
+
const types = new Set(Object.keys(NOTE_VOCABULARY));
|
|
68
|
+
const packages = new Set(contentPackage ? [contentPackage] : []);
|
|
69
|
+
const keysOf = (tuple) => [
|
|
70
|
+
renderAddress({ ...tuple, system: "note" }),
|
|
71
|
+
renderAddress({ ...tuple, system: ownDocumentSystem(tuple.type) }),
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
for (const record of records ?? []) {
|
|
75
|
+
if (!record?.package || !record.type || !record.shortcode || record.documents) continue;
|
|
76
|
+
packages.add(String(record.package));
|
|
77
|
+
const tuple = {
|
|
78
|
+
package: String(record.package),
|
|
79
|
+
type: String(record.type),
|
|
80
|
+
shortcode: String(record.shortcode),
|
|
81
|
+
};
|
|
82
|
+
const entries = Array.isArray(record.data?.events) ? record.data.events : [];
|
|
83
|
+
const resolved = record.resolvedDates?.events ?? [];
|
|
84
|
+
const parse = (value) =>
|
|
85
|
+
value == null ? null : (
|
|
86
|
+
(parseNoteDate(value, { ...dates, allowUnknown: true, allowZeroYear: true }).date ??
|
|
87
|
+
null)
|
|
88
|
+
);
|
|
89
|
+
const events = entries.map((entry, position) => ({
|
|
90
|
+
...(typeof entry?.id === "string" ? { id: entry.id } : {}),
|
|
91
|
+
entry: entry ?? {},
|
|
92
|
+
when: resolved[position]?.when ?? parse(entry?.when),
|
|
93
|
+
until: resolved[position]?.until ?? parse(entry?.until),
|
|
94
|
+
}));
|
|
95
|
+
const anchors =
|
|
96
|
+
Array.isArray(record.anchors) ?
|
|
97
|
+
record.anchors
|
|
98
|
+
: events.filter((one) => one.id).map((one) => ({ slug: one.id, kind: "event" }));
|
|
99
|
+
const note = { target: renderAddress({ ...tuple, system: "note" }), events, anchors };
|
|
100
|
+
for (const key of keysOf(tuple)) byKey.set(key, note);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const [key, value] of foreignIndex ?? []) {
|
|
104
|
+
const tuple = readCanonicalKey(key);
|
|
105
|
+
if (!tuple) continue;
|
|
106
|
+
packages.add(tuple.package);
|
|
107
|
+
if (value?.type) types.add(String(value.type));
|
|
108
|
+
const note = {
|
|
109
|
+
target: renderAddress({ ...tuple, system: "note" }),
|
|
110
|
+
events: (Array.isArray(value?.events) ? value.events : []).map((event) => ({
|
|
111
|
+
...(typeof event?.id === "string" ? { id: event.id } : {}),
|
|
112
|
+
entry: event?.entry ?? {},
|
|
113
|
+
when: event?.when ?? null,
|
|
114
|
+
until: event?.until ?? null,
|
|
115
|
+
})),
|
|
116
|
+
anchors: Array.isArray(value?.noteAnchors) ? value.noteAnchors : [],
|
|
117
|
+
};
|
|
118
|
+
for (const one of keysOf(tuple)) if (!byKey.has(one)) byKey.set(one, note);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
note(written) {
|
|
123
|
+
const tuple = parseAddress(
|
|
124
|
+
written,
|
|
125
|
+
{ package: contentPackage ?? "local", system: "note", types, packages },
|
|
126
|
+
{ declared: true },
|
|
127
|
+
);
|
|
128
|
+
if (tuple.reason) return undefined;
|
|
129
|
+
return byKey.get(renderAddress({ ...tuple, system: "note" }));
|
|
130
|
+
},
|
|
131
|
+
own(fm) {
|
|
132
|
+
if (!fm?.type || !fm?.shortcode) return undefined;
|
|
133
|
+
return byKey.get(
|
|
134
|
+
renderAddress({
|
|
135
|
+
package: contentPackage ?? "local",
|
|
136
|
+
system: "note",
|
|
137
|
+
type: String(fm.type),
|
|
138
|
+
shortcode: String(fm.shortcode),
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A date as a note prints one: in the era its own marker or qualifier names,
|
|
147
|
+
* where the reckoning knows that era, else as written — `~` kept either way.
|
|
148
|
+
*
|
|
149
|
+
* @param {unknown} written - The date as the event writes it.
|
|
150
|
+
* @param {object|null} date - The resolved date.
|
|
151
|
+
* @param {object} [dates] - The reckoning context.
|
|
152
|
+
* @returns {string} The printed date.
|
|
153
|
+
*/
|
|
154
|
+
export function printEventDate(written, date, dates) {
|
|
155
|
+
if (!date?.known) return String(written);
|
|
156
|
+
const era =
|
|
157
|
+
date.marker ? dates?.markers?.get(date.marker)
|
|
158
|
+
: date.qualifier ? dates?.eras?.get(date.qualifier)
|
|
159
|
+
: null;
|
|
160
|
+
const printable = era ? formatNoteDate(date, era, dates?.daysPerYear) : null;
|
|
161
|
+
return printable?.prose ?? printable?.text ?? date.prose ?? date.text ?? String(written);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* One field of one event, as the text a reference prints, or why it cannot.
|
|
166
|
+
*
|
|
167
|
+
* @param {EventView} event - The event.
|
|
168
|
+
* @param {string} field - One of {@link EVENT_REFERENCE_FIELDS}.
|
|
169
|
+
* @param {object} [dates] - The reckoning context.
|
|
170
|
+
* @returns {{text: string}|{missing: true}} The text, or that the event states
|
|
171
|
+
* no such field.
|
|
172
|
+
*/
|
|
173
|
+
export function eventFieldText(event, field, dates) {
|
|
174
|
+
const entry = event.entry ?? {};
|
|
175
|
+
if (field === "when" || field === "until") {
|
|
176
|
+
const written = entry[field];
|
|
177
|
+
if (written === undefined || written === null || written === "") return { missing: true };
|
|
178
|
+
return { text: printEventDate(written, event[field], dates) };
|
|
179
|
+
}
|
|
180
|
+
if (field === "name") {
|
|
181
|
+
const name =
|
|
182
|
+
Array.isArray(entry.names) ? entry.names.find((one) => one?.name)?.name : undefined;
|
|
183
|
+
return typeof name === "string" && name ? { text: name } : { missing: true };
|
|
184
|
+
}
|
|
185
|
+
const value = entry[field];
|
|
186
|
+
return typeof value === "string" && value ? { text: value } : { missing: true };
|
|
187
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
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 rows of the SQL `events` relation: one per `data.events` entry of every
|
|
16
|
+
* note that carries events, read from index records whose Addresses are
|
|
17
|
+
* already strings.
|
|
18
|
+
*
|
|
19
|
+
* **Every row has every column.** The event keys come from
|
|
20
|
+
* {@link module:engine/note-event-terms.EVENT_ENTRY_KEYS}, so a key no event in a
|
|
21
|
+
* corpus writes is still a column, `NULL` in every row, and a fence naming it
|
|
22
|
+
* binds rather than failing on a corpus that happens not to use it.
|
|
23
|
+
*
|
|
24
|
+
* **The dates are the ones the index resolved.** Each record carries
|
|
25
|
+
* `resolvedDates.events`, position for position with `data.events`, and the
|
|
26
|
+
* sort keys are read from it rather than parsed again here.
|
|
27
|
+
*
|
|
28
|
+
* @module
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { ownDocumentSystem, renderAddress } from "./address.mjs";
|
|
32
|
+
import { EVENT_ENTRY_KEYS } from "./note-event-terms.mjs";
|
|
33
|
+
|
|
34
|
+
/** The columns a row carries besides the event's own keys, before them. */
|
|
35
|
+
const IDENTITY_COLUMNS = Object.freeze(["note", "address"]);
|
|
36
|
+
|
|
37
|
+
/** The sort keys a row carries, after the event's own keys. */
|
|
38
|
+
export const EVENT_SORT_COLUMNS = Object.freeze(["whenSort", "whenYear", "untilSort", "untilYear"]);
|
|
39
|
+
|
|
40
|
+
/** Every column of the `events` relation, in order. */
|
|
41
|
+
export const EVENT_COLUMNS = Object.freeze([
|
|
42
|
+
...IDENTITY_COLUMNS,
|
|
43
|
+
...EVENT_ENTRY_KEYS,
|
|
44
|
+
...EVENT_SORT_COLUMNS,
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** The keys whose authored value is a date, printed as the text it was written as. */
|
|
48
|
+
const DATE_KEYS = new Set(["when", "until"]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The owning note's canonical Address, as a string: the one the record
|
|
52
|
+
* publishes, or — for a stub, which publishes none — the one its package,
|
|
53
|
+
* type and shortcode spell.
|
|
54
|
+
*
|
|
55
|
+
* @param {Record<string, any>} record - An index record, Addresses as strings.
|
|
56
|
+
* @returns {string|null} The Address, or `null` for a record naming no note.
|
|
57
|
+
*/
|
|
58
|
+
function noteAddressOf(record) {
|
|
59
|
+
const canonical = record.address?.canonical;
|
|
60
|
+
if (typeof canonical === "string" && canonical) return canonical;
|
|
61
|
+
if (!record.package || !record.type || !record.shortcode) return null;
|
|
62
|
+
return renderAddress({
|
|
63
|
+
package: String(record.package),
|
|
64
|
+
system: ownDocumentSystem(record.type),
|
|
65
|
+
type: String(record.type),
|
|
66
|
+
shortcode: String(record.shortcode),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A resolved date's position on the canonical axis: its `sort` where the
|
|
72
|
+
* calendar fixes a day's fraction of the year, else its year. A year-0 date
|
|
73
|
+
* recurs in every year and has no position.
|
|
74
|
+
*
|
|
75
|
+
* @param {object|undefined} date - A resolved date.
|
|
76
|
+
* @returns {{sort: number|null, year: number|null}} The keys.
|
|
77
|
+
*/
|
|
78
|
+
function axisKeys(date) {
|
|
79
|
+
if (!date?.known || !Number.isFinite(date.canonicalYear) || date.year === 0)
|
|
80
|
+
return { sort: null, year: null };
|
|
81
|
+
return {
|
|
82
|
+
sort: Number.isFinite(date.sort) ? date.sort : date.canonicalYear,
|
|
83
|
+
year: date.canonicalYear,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The `events` rows of a set of index records.
|
|
89
|
+
*
|
|
90
|
+
* @param {Iterable<Record<string, any>>} records - Index records, every
|
|
91
|
+
* Address a string, already narrowed to the audience being published to.
|
|
92
|
+
* @returns {object[]} One row per event entry, in record then entry order.
|
|
93
|
+
*/
|
|
94
|
+
export function eventRows(records) {
|
|
95
|
+
const rows = [];
|
|
96
|
+
for (const record of records) {
|
|
97
|
+
const events = record?.data?.events;
|
|
98
|
+
if (!Array.isArray(events) || !events.length) continue;
|
|
99
|
+
const note = noteAddressOf(record);
|
|
100
|
+
if (!note) continue;
|
|
101
|
+
const resolved =
|
|
102
|
+
Array.isArray(record.resolvedDates?.events) ? record.resolvedDates.events : [];
|
|
103
|
+
events.forEach((entry, position) => {
|
|
104
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return;
|
|
105
|
+
const row = {
|
|
106
|
+
note,
|
|
107
|
+
address: typeof entry.id === "string" && entry.id ? `${note}#${entry.id}` : note,
|
|
108
|
+
};
|
|
109
|
+
for (const key of EVENT_ENTRY_KEYS) {
|
|
110
|
+
const value = entry[key];
|
|
111
|
+
row[key] =
|
|
112
|
+
value === undefined || value === null ? null
|
|
113
|
+
: DATE_KEYS.has(key) ? String(value)
|
|
114
|
+
: value;
|
|
115
|
+
}
|
|
116
|
+
const when = axisKeys(resolved[position]?.when);
|
|
117
|
+
const until = axisKeys(resolved[position]?.until);
|
|
118
|
+
row.whenSort = when.sort;
|
|
119
|
+
row.whenYear = when.year;
|
|
120
|
+
row.untilSort = until.sort;
|
|
121
|
+
row.untilYear = until.year;
|
|
122
|
+
rows.push(row);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return rows;
|
|
126
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
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
|
+
* A note's **event views**: generated sections — see
|
|
16
|
+
* {@link module:engine/generated-sections} — each an H1 with a fixed anchor
|
|
17
|
+
* holding a `sql` fence over the `events` relation.
|
|
18
|
+
*
|
|
19
|
+
* **Nothing here renders.** A view is Markdown an author could have written —
|
|
20
|
+
* a heading and a fence — and it reaches every surface through the same table
|
|
21
|
+
* expansion an authored fence does, so the website, the Foundry journal and the
|
|
22
|
+
* book receive one text: Foundry starts a page at the heading, the website
|
|
23
|
+
* sets a section, the book a chapter section.
|
|
24
|
+
*
|
|
25
|
+
* Three rules decide whether a view is generated:
|
|
26
|
+
*
|
|
27
|
+
* - **The view applies to the note** — see {@link EVENT_VIEWS}.
|
|
28
|
+
* - **The note's own body declares no prose anchor with the view's slug.** An
|
|
29
|
+
* author who writes `# Chronology {#chronology}` keeps that section, and it
|
|
30
|
+
* is the one way to replace a view.
|
|
31
|
+
* - **The view has rows.** The query is run before the view is written, and a
|
|
32
|
+
* view selecting nothing is left out rather than printed as an empty table.
|
|
33
|
+
* A region's chronology counts only its own rows: the world events set
|
|
34
|
+
* beside them as context make no chronology by themselves.
|
|
35
|
+
*
|
|
36
|
+
* A stub publishes no page and is given no view, and neither is a note's
|
|
37
|
+
* documentation journal.
|
|
38
|
+
*
|
|
39
|
+
* @module
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { ownDocumentSystem, renderAddress } from "./address.mjs";
|
|
43
|
+
import { authoredSection, sectionHeading } from "./generated-sections.mjs";
|
|
44
|
+
|
|
45
|
+
/** A literal SQL string. */
|
|
46
|
+
const literal = (text) => `'${String(text).replace(/'/g, "''")}'`;
|
|
47
|
+
|
|
48
|
+
/** A list of SQL string literals, for an `IN (…)`. */
|
|
49
|
+
const literals = (texts) => texts.map(literal).join(", ");
|
|
50
|
+
|
|
51
|
+
/** The order every view sorts its rows in: by date, undated last. */
|
|
52
|
+
const BY_DATE = "ORDER BY e.whenSort NULLS LAST, e.address";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The views, in the order
|
|
56
|
+
* {@link module:engine/generated-sections.GENERATED_SECTIONS} sets them in.
|
|
57
|
+
*
|
|
58
|
+
* Each names its anchor `slug`, its `heading`, whether it `applies` to a note's
|
|
59
|
+
* index record, and the `query` it writes for a note — given the note's
|
|
60
|
+
* Address in each form an event may name it by, and the shape of the corpus —
|
|
61
|
+
* or `null` where the corpus states nothing the view could select. A view's
|
|
62
|
+
* `own` query, where it has one, is what decides whether the view has rows.
|
|
63
|
+
*
|
|
64
|
+
* @type {ReadonlyArray<Readonly<{slug: string, heading: string,
|
|
65
|
+
* applies: (record: object) => boolean,
|
|
66
|
+
* query: (self: string[], shape: CorpusShape) => {fence: string, own?: string}|null}>>}
|
|
67
|
+
*/
|
|
68
|
+
export const EVENT_VIEWS = Object.freeze([
|
|
69
|
+
Object.freeze({
|
|
70
|
+
slug: "chronology",
|
|
71
|
+
heading: "Chronology",
|
|
72
|
+
applies: (record) => record.type === "place",
|
|
73
|
+
query: chronologyQuery,
|
|
74
|
+
}),
|
|
75
|
+
Object.freeze({
|
|
76
|
+
slug: "events",
|
|
77
|
+
heading: "Events",
|
|
78
|
+
applies: (record) =>
|
|
79
|
+
record.type === "being" ||
|
|
80
|
+
record.type === "affiliation" ||
|
|
81
|
+
(record.type === "lore" && record.subType === "culture"),
|
|
82
|
+
query: (self, shape) => {
|
|
83
|
+
if (!shape.who) return null;
|
|
84
|
+
const named = `list_filter(e.who, w -> w.ref IN (${literals(self)}))`;
|
|
85
|
+
return {
|
|
86
|
+
fence: [
|
|
87
|
+
"SELECT e.note AS _ref,",
|
|
88
|
+
' e.summary AS "What happened",',
|
|
89
|
+
' e."when" AS "When",',
|
|
90
|
+
` array_to_string(list_transform(${named}, w -> w.role), ', ') AS "Role"`,
|
|
91
|
+
"FROM events e",
|
|
92
|
+
`WHERE len(${named}) > 0`,
|
|
93
|
+
BY_DATE,
|
|
94
|
+
].join("\n"),
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
Object.freeze({
|
|
99
|
+
slug: "accounts",
|
|
100
|
+
heading: "Accounts",
|
|
101
|
+
applies: (record) =>
|
|
102
|
+
record.type === "affiliation" ||
|
|
103
|
+
record.type === "place" ||
|
|
104
|
+
(record.type === "lore" && record.subType === "culture"),
|
|
105
|
+
query: (self, shape) => {
|
|
106
|
+
if (!shape.accounts) return null;
|
|
107
|
+
return {
|
|
108
|
+
fence: [
|
|
109
|
+
"SELECT e.note AS _ref,",
|
|
110
|
+
' e.summary AS "Event",',
|
|
111
|
+
' e."when" AS "When",',
|
|
112
|
+
' e.account.says AS "What they say",',
|
|
113
|
+
' e.account.agrees AS "Agrees"',
|
|
114
|
+
"FROM (SELECT *, unnest(accounts) AS account FROM events) e",
|
|
115
|
+
`WHERE e.account."by" IN (${literals(self)})`,
|
|
116
|
+
BY_DATE,
|
|
117
|
+
].join("\n"),
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
Object.freeze({
|
|
122
|
+
slug: "followed",
|
|
123
|
+
heading: "What followed",
|
|
124
|
+
applies: (record) => Array.isArray(record.data?.events) && record.data.events.length > 0,
|
|
125
|
+
query: (self, shape) => {
|
|
126
|
+
if (!shape.follows) return null;
|
|
127
|
+
const edges =
|
|
128
|
+
`list_filter(e.follows, f -> split_part(f.event, '#', 1) ` +
|
|
129
|
+
`IN (${literals(self)}))`;
|
|
130
|
+
return {
|
|
131
|
+
fence: [
|
|
132
|
+
"SELECT e.note AS _ref,",
|
|
133
|
+
' e.summary AS "What followed",',
|
|
134
|
+
' e."when" AS "When",',
|
|
135
|
+
` array_to_string(list_transform(${edges}, f -> f.how), ', ') AS "How"`,
|
|
136
|
+
"FROM events e",
|
|
137
|
+
`WHERE len(${edges}) > 0`,
|
|
138
|
+
BY_DATE,
|
|
139
|
+
].join("\n"),
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @typedef {object} CorpusShape
|
|
147
|
+
* @property {boolean} parents - Some place states `data.parents`.
|
|
148
|
+
* @property {boolean} locus - Some event states `where.locus`.
|
|
149
|
+
* @property {boolean} reach - Some event states `where.reach`.
|
|
150
|
+
* @property {boolean} who - Some event states `who`.
|
|
151
|
+
* @property {boolean} accounts - Some event states `accounts`.
|
|
152
|
+
* @property {boolean} follows - Some event states `follows`.
|
|
153
|
+
* @property {boolean} depth - Some event states `depth`.
|
|
154
|
+
*/
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A place's chronology: every event that happened at the place or anywhere
|
|
158
|
+
* below it in `parents`, every event felt there — with the clause saying how —
|
|
159
|
+
* and every `depth: world` event as context.
|
|
160
|
+
*
|
|
161
|
+
* The places below are found by walking `parents` downward from the place, so
|
|
162
|
+
* an event felt in towns under two continents is in each continent's
|
|
163
|
+
* chronology, and each region's, through the towns it names.
|
|
164
|
+
*
|
|
165
|
+
* @param {string[]} self - The place's Address, in each form.
|
|
166
|
+
* @param {CorpusShape} shape - What the corpus states.
|
|
167
|
+
* @returns {{fence: string, own: string}|null} The query, and the one that
|
|
168
|
+
* counts the place's own rows.
|
|
169
|
+
*/
|
|
170
|
+
function chronologyQuery(self, shape) {
|
|
171
|
+
if (!shape.locus && !shape.reach) return null;
|
|
172
|
+
const below =
|
|
173
|
+
shape.parents ?
|
|
174
|
+
[
|
|
175
|
+
"WITH RECURSIVE below(place) AS (",
|
|
176
|
+
` SELECT ${literal(self[0])}`,
|
|
177
|
+
" UNION",
|
|
178
|
+
" SELECT lower(p.package || '-note-place-' || p.shortcode)",
|
|
179
|
+
" FROM entries p JOIN below b ON list_contains(p.data.parents, b.place)",
|
|
180
|
+
" WHERE p.type = 'place'",
|
|
181
|
+
"),",
|
|
182
|
+
]
|
|
183
|
+
: [`WITH RECURSIVE below(place) AS (SELECT ${literal(self[0])}),`];
|
|
184
|
+
const felt = 'list_filter(e."where".reach, r -> list_contains(here.places, r.place))';
|
|
185
|
+
const ownRows = [
|
|
186
|
+
...(shape.locus ?
|
|
187
|
+
['len(list_filter(e."where".locus, l -> list_contains(here.places, l))) > 0']
|
|
188
|
+
: []),
|
|
189
|
+
...(shape.reach ? [`len(${felt}) > 0`] : []),
|
|
190
|
+
];
|
|
191
|
+
const head = [
|
|
192
|
+
...below,
|
|
193
|
+
"here AS (SELECT list(place) AS places FROM below)",
|
|
194
|
+
"SELECT e.note AS _ref,",
|
|
195
|
+
' e.summary AS "What happened",',
|
|
196
|
+
' e."when" AS "When",',
|
|
197
|
+
shape.reach ?
|
|
198
|
+
` array_to_string(list_transform(${felt}, r -> r.how), '; ') AS "Felt here"`
|
|
199
|
+
: ' NULL AS "Felt here"',
|
|
200
|
+
"FROM events e, here",
|
|
201
|
+
];
|
|
202
|
+
const context = shape.depth ? ["e.depth = 'world'"] : [];
|
|
203
|
+
return {
|
|
204
|
+
fence: [...head, `WHERE ${[...context, ...ownRows].join("\n OR ")}`, BY_DATE].join("\n"),
|
|
205
|
+
own: [...head, `WHERE ${ownRows.join("\n OR ")}`].join("\n"),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* What the corpus states, read from the relations' own types, so a view never
|
|
211
|
+
* names a field no note writes — which would fail to bind rather than select
|
|
212
|
+
* nothing.
|
|
213
|
+
*
|
|
214
|
+
* @param {object} db - From {@link module:engine/sql-tables.openNotesDatabase}.
|
|
215
|
+
* @returns {Promise<CorpusShape>} The shape.
|
|
216
|
+
*/
|
|
217
|
+
async function corpusShape(db) {
|
|
218
|
+
const typeOf = async (relation) => {
|
|
219
|
+
const { rows } = await db.query(`DESCRIBE ${relation}`);
|
|
220
|
+
return new Map(rows.map((row) => [String(row.column_name), String(row.column_type)]));
|
|
221
|
+
};
|
|
222
|
+
const events = await typeOf("events");
|
|
223
|
+
const entries = await typeOf("entries");
|
|
224
|
+
const has = (type, field) => new RegExp(`\\b"?${field}"? `).test(type ?? "");
|
|
225
|
+
const where = events.get("where");
|
|
226
|
+
return {
|
|
227
|
+
parents: has(entries.get("data"), "parents"),
|
|
228
|
+
locus: has(where, "locus"),
|
|
229
|
+
reach: has(where, "reach"),
|
|
230
|
+
who: /STRUCT/.test(events.get("who") ?? ""),
|
|
231
|
+
accounts: /STRUCT/.test(events.get("accounts") ?? ""),
|
|
232
|
+
follows: /STRUCT/.test(events.get("follows") ?? ""),
|
|
233
|
+
depth: /VARCHAR/.test(events.get("depth") ?? ""),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Every form an event may name a note by: its Address in the `note` system,
|
|
239
|
+
* and in its own document's system where that differs.
|
|
240
|
+
*
|
|
241
|
+
* @param {object} record - The note's index record.
|
|
242
|
+
* @returns {string[]} The forms, the `note` one first.
|
|
243
|
+
*/
|
|
244
|
+
function selfForms(record) {
|
|
245
|
+
const tuple = {
|
|
246
|
+
package: String(record.package),
|
|
247
|
+
type: String(record.type),
|
|
248
|
+
shortcode: String(record.shortcode),
|
|
249
|
+
};
|
|
250
|
+
return [
|
|
251
|
+
...new Set([
|
|
252
|
+
renderAddress({ ...tuple, system: "note" }),
|
|
253
|
+
renderAddress({ ...tuple, system: ownDocumentSystem(tuple.type) }),
|
|
254
|
+
]),
|
|
255
|
+
];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The Markdown of one view: its heading, with its anchor, and its fence.
|
|
260
|
+
*
|
|
261
|
+
* @param {object} view - One of {@link EVENT_VIEWS}.
|
|
262
|
+
* @param {string} query - The fence's query.
|
|
263
|
+
* @returns {string} The section.
|
|
264
|
+
*/
|
|
265
|
+
function section(view, query) {
|
|
266
|
+
return `${sectionHeading(view.slug)}\n\n\`\`\`sql\n${query}\n\`\`\`\n`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The views each note is given, by section.
|
|
271
|
+
*
|
|
272
|
+
* @param {object} db - From {@link module:engine/sql-tables.openNotesDatabase},
|
|
273
|
+
* over the same records.
|
|
274
|
+
* @param {Iterable<object>} records - The content-index records.
|
|
275
|
+
* @param {(record: object) => string} fileOf - A record's note file, as the
|
|
276
|
+
* prepared results are keyed.
|
|
277
|
+
* @param {string} [only] - One note file to give views to, every note's being
|
|
278
|
+
* read for them; absent, every note is given its views.
|
|
279
|
+
* @returns {Promise<Map<string, Map<string, string>>>} Note file to its views,
|
|
280
|
+
* each keyed by its slug, for every note given at least one.
|
|
281
|
+
*/
|
|
282
|
+
export async function eventViewSections(db, records, fileOf, only) {
|
|
283
|
+
const out = new Map();
|
|
284
|
+
const all = [...records];
|
|
285
|
+
if (!all.some((record) => Array.isArray(record?.data?.events) && record.data.events.length))
|
|
286
|
+
return out;
|
|
287
|
+
const shape = await corpusShape(db);
|
|
288
|
+
for (const record of all) {
|
|
289
|
+
if (!record?.address || record.documents || !record.shortcode) continue;
|
|
290
|
+
if (only !== undefined && fileOf(record) !== only) continue;
|
|
291
|
+
const self = selfForms(record);
|
|
292
|
+
const sections = new Map();
|
|
293
|
+
for (const view of EVENT_VIEWS) {
|
|
294
|
+
if (!view.applies(record) || authoredSection(record, view.slug)) continue;
|
|
295
|
+
const query = view.query(self, shape);
|
|
296
|
+
if (!query) continue;
|
|
297
|
+
const { rows } = await db.query(
|
|
298
|
+
`SELECT count(*) AS n FROM (${query.own ?? query.fence})`,
|
|
299
|
+
);
|
|
300
|
+
if (Number(rows[0]?.n ?? 0) > 0) sections.set(view.slug, section(view, query.fence));
|
|
301
|
+
}
|
|
302
|
+
if (sections.size) out.set(fileOf(record), sections);
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|