@heroiclands/package-build 23.12.5 → 23.12.6
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 +10 -0
- package/content-config.mjs +26 -0
- package/docs/api.md +8 -5
- package/docs/authoring/dates-and-calendars.md +29 -1
- package/docs/authoring/links-and-markup.md +3 -3
- package/docs/configuration.md +27 -3
- package/docs/guides/module-project.md +1 -1
- package/docs/reference/format-details.md +62 -17
- package/docs/reference/note-types.md +9 -10
- package/engine/content-format.yaml +0 -2
- package/engine/content-index.mjs +21 -2
- package/engine/content-links.mjs +3 -0
- package/engine/event-rows.mjs +63 -18
- package/engine/event-views.mjs +28 -15
- package/engine/index-records.mjs +1 -0
- package/engine/infobox.mjs +4 -8
- package/engine/markdown-expressions.mjs +72 -17
- package/engine/note-calendars.mjs +314 -0
- package/engine/note-vocabulary.mjs +11 -16
- package/engine/reckoning-markers.mjs +63 -3
- package/engine/sql-tables.mjs +9 -3
- package/package.json +1 -1
- package/types/content-config.d.mts +12 -0
- package/types/engine/content-index.d.mts +7 -1
- package/types/engine/event-rows.d.mts +16 -1
- package/types/engine/event-views.d.mts +2 -1
- package/types/engine/note-calendars.d.mts +60 -0
- package/types/engine/reckoning-markers.d.mts +32 -1
|
@@ -0,0 +1,314 @@
|
|
|
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 calendar a note's dates print in.** A note's calendar is the first of:
|
|
16
|
+
*
|
|
17
|
+
* 1. its own `data.calendar`, on any type;
|
|
18
|
+
* 2. its culture's calendar: the culture note its `data.culture` names,
|
|
19
|
+
* resolved by these same rules;
|
|
20
|
+
* 3. for a `place`, its `data.parents`, each resolved by these same rules. Two
|
|
21
|
+
* or more distinct calendars among them are an error at the place's
|
|
22
|
+
* `data.parents`, fixed by stating `data.calendar` on the place;
|
|
23
|
+
* 4. the package's configured `defaultCalendar`;
|
|
24
|
+
* 5. otherwise none, and a date prints as it was written.
|
|
25
|
+
*
|
|
26
|
+
* Steps 2 and 3 take only a calendar some note states: a culture or parent
|
|
27
|
+
* that itself falls through to the default contributes nothing, so parents
|
|
28
|
+
* that all fall through give the default and raise no error. A note met again
|
|
29
|
+
* while it is being resolved — a cycle through `data.parents` or
|
|
30
|
+
* `data.culture` — contributes nothing either, and the chain goes on to the
|
|
31
|
+
* default.
|
|
32
|
+
*
|
|
33
|
+
* The content index writes the result onto each note record as
|
|
34
|
+
* `resolvedCalendar`, the calendar note's Address, and
|
|
35
|
+
* every reader of a note's calendar reads it from there: the event views, the
|
|
36
|
+
* `dateformat` helper and the infobox's dates.
|
|
37
|
+
*
|
|
38
|
+
* @module
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import fs from "node:fs";
|
|
42
|
+
import path from "node:path";
|
|
43
|
+
|
|
44
|
+
import { canonicalKey, isAddressTuple, readCanonicalKey, renderAddress } from "./address.mjs";
|
|
45
|
+
import { isAddressSegment } from "./address-charset.mjs";
|
|
46
|
+
import { AddressLink } from "./address-values.mjs";
|
|
47
|
+
import { formatDiagnostic, positionOfFrontmatterPath, positionOfYamlPath } from "./diagnostics.mjs";
|
|
48
|
+
import { isNoteRecord, noteFile } from "./index-records.mjs";
|
|
49
|
+
import { noteCalendarKey } from "./reckoning-markers.mjs";
|
|
50
|
+
import { NOTE_SYSTEM } from "./systems.mjs";
|
|
51
|
+
|
|
52
|
+
/** The configuration key naming the calendar of last resort. */
|
|
53
|
+
export const DEFAULT_CALENDAR_KEY = "defaultCalendar";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The canonical key an Address names, without any anchor.
|
|
57
|
+
*
|
|
58
|
+
* @param {unknown} value - A tuple, an {@link AddressLink}, or anything else.
|
|
59
|
+
* @returns {string|undefined} The key, or `undefined` for anything else.
|
|
60
|
+
*/
|
|
61
|
+
function addressKey(value) {
|
|
62
|
+
const tuple = value instanceof AddressLink ? value.target : value;
|
|
63
|
+
return isAddressTuple(tuple) ? renderAddress(tuple) : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether a record is a calendar note.
|
|
68
|
+
*
|
|
69
|
+
* @param {Record<string, any>|undefined} record - An index record.
|
|
70
|
+
* @returns {boolean} Whether it is a `lore` note with `subType: calendar`.
|
|
71
|
+
*/
|
|
72
|
+
function isCalendarNote(record) {
|
|
73
|
+
return record?.type === "lore" && String(record.subType ?? "").toLowerCase() === "calendar";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The canonical key of the calendar `defaultCalendar` names, or why it names none.
|
|
78
|
+
*
|
|
79
|
+
* @param {unknown} shortcode - The configured value.
|
|
80
|
+
* @param {string} contentPackage - The package the shortcode is read in.
|
|
81
|
+
* @param {(key: string) => Record<string, any>|undefined} recordOf - A local
|
|
82
|
+
* note by canonical key.
|
|
83
|
+
* @returns {{key?: string, problem?: string}} The calendar, or the problem.
|
|
84
|
+
*/
|
|
85
|
+
function defaultCalendarKey(shortcode, contentPackage, recordOf) {
|
|
86
|
+
if (shortcode === undefined || shortcode === null) return {};
|
|
87
|
+
const named = String(shortcode);
|
|
88
|
+
const key =
|
|
89
|
+
isAddressSegment(named) ?
|
|
90
|
+
canonicalKey(contentPackage, NOTE_SYSTEM, "lore", named)
|
|
91
|
+
: undefined;
|
|
92
|
+
if (key && isCalendarNote(recordOf(key))) return { key };
|
|
93
|
+
return {
|
|
94
|
+
problem:
|
|
95
|
+
`${DEFAULT_CALENDAR_KEY} ${named} names no calendar note in ${contentPackage}; ` +
|
|
96
|
+
`name the shortcode of a lore note with subType: calendar`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Write each note's resolved calendar into its record, as `resolvedCalendar`.
|
|
102
|
+
*
|
|
103
|
+
* Reads each note's authored `data.calendar`, `data.culture` and
|
|
104
|
+
* `data.parents`, so it runs before the index writes a resolved culture over
|
|
105
|
+
* `data.culture`. A culture or parent in another package contributes the
|
|
106
|
+
* calendar its published entry states in `data.calendar`, and nothing else.
|
|
107
|
+
*
|
|
108
|
+
* @param {Array<Record<string, any>>} records - Index records, every Address a
|
|
109
|
+
* tuple. Records that are not notes are passed over.
|
|
110
|
+
* @param {object} options - Options.
|
|
111
|
+
* @param {string} options.contentPackage - The package the records belong to.
|
|
112
|
+
* @param {string} [options.defaultCalendar] - The configured calendar of last
|
|
113
|
+
* resort, a calendar note's shortcode.
|
|
114
|
+
* @param {string} [options.configFile] - The configuration file, where an
|
|
115
|
+
* unknown `defaultCalendar` is reported.
|
|
116
|
+
* @param {string} [options.contentBase] - The content tree, for opening a note
|
|
117
|
+
* to locate a finding.
|
|
118
|
+
* @param {(key: string) => Record<string, any>|undefined} [options.foreignRecord] -
|
|
119
|
+
* Another package's entry for a canonical address.
|
|
120
|
+
* @param {object[]} [options.problems] - Receives each finding. Omitted, the
|
|
121
|
+
* findings are collected and one Error lists them all.
|
|
122
|
+
* @param {Map<string, {raw: string}>} [options.parsed] - Notes already read, keyed by file.
|
|
123
|
+
* @returns {Array<Record<string, any>>} The same records.
|
|
124
|
+
* @throws {Error} When `problems` is omitted and anything is reported.
|
|
125
|
+
*/
|
|
126
|
+
export function resolveNoteCalendars(
|
|
127
|
+
records,
|
|
128
|
+
{ contentPackage, defaultCalendar, configFile, contentBase, foreignRecord, problems, parsed },
|
|
129
|
+
) {
|
|
130
|
+
const notes = new Map();
|
|
131
|
+
for (const record of records) {
|
|
132
|
+
if (!isNoteRecord(record) || !isAddressSegment(String(record.shortcode ?? ""))) continue;
|
|
133
|
+
const key = canonicalKey(contentPackage, NOTE_SYSTEM, record.type, record.shortcode);
|
|
134
|
+
if (!notes.has(key)) notes.set(key, record);
|
|
135
|
+
}
|
|
136
|
+
const findings = [];
|
|
137
|
+
|
|
138
|
+
const fallback = defaultCalendarKey(defaultCalendar, contentPackage, (key) => notes.get(key));
|
|
139
|
+
if (fallback.problem) {
|
|
140
|
+
const file = configFile ?? "package-build.config.yaml";
|
|
141
|
+
const source =
|
|
142
|
+
configFile && /\.ya?ml$/.test(configFile) && fs.existsSync(configFile) ?
|
|
143
|
+
fs.readFileSync(configFile, "utf8")
|
|
144
|
+
: undefined;
|
|
145
|
+
findings.push({
|
|
146
|
+
file,
|
|
147
|
+
...(source ? positionOfYamlPath(source, [DEFAULT_CALENDAR_KEY], { key: true }) : {}),
|
|
148
|
+
severity: "error",
|
|
149
|
+
message: fallback.problem,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const memo = new Map();
|
|
154
|
+
const inProgress = new Set();
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The calendar some note states for a source Address, local or foreign.
|
|
158
|
+
*
|
|
159
|
+
* @param {unknown} value - A `data.culture` or `data.parents` Address.
|
|
160
|
+
* @returns {string|undefined} The calendar's canonical key.
|
|
161
|
+
*/
|
|
162
|
+
const statedThrough = (value) => {
|
|
163
|
+
const key = addressKey(value);
|
|
164
|
+
if (!key) return undefined;
|
|
165
|
+
const tuple = value instanceof AddressLink ? value.target : value;
|
|
166
|
+
if (tuple.package === contentPackage) {
|
|
167
|
+
const record = notes.get(
|
|
168
|
+
canonicalKey(contentPackage, NOTE_SYSTEM, tuple.type, tuple.shortcode),
|
|
169
|
+
);
|
|
170
|
+
return record ? resolve(record) : undefined;
|
|
171
|
+
}
|
|
172
|
+
return addressKey(foreignRecord?.(key)?.data?.calendar);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Report parents that disagree.
|
|
177
|
+
*
|
|
178
|
+
* @param {Record<string, any>} record - The place.
|
|
179
|
+
* @param {Array<{key: string, via: unknown}>} distinct - The calendars and
|
|
180
|
+
* the parent each came through.
|
|
181
|
+
*/
|
|
182
|
+
const conflict = (record, distinct) => {
|
|
183
|
+
const file = contentBase ? noteFile(contentBase, record) : record.file?.path;
|
|
184
|
+
const raw =
|
|
185
|
+
parsed?.get(file)?.raw ??
|
|
186
|
+
(contentBase && fs.existsSync(file) ? fs.readFileSync(file, "utf8") : undefined);
|
|
187
|
+
let position = positionOfFrontmatterPath(raw, ["data", "parents"], { key: true });
|
|
188
|
+
if (position.line === undefined)
|
|
189
|
+
position = positionOfFrontmatterPath(raw, ["data"], { key: true });
|
|
190
|
+
const via = (value) => {
|
|
191
|
+
const tuple = value instanceof AddressLink ? value.target : value;
|
|
192
|
+
return tuple.package === contentPackage ? tuple.shortcode : addressKey(tuple);
|
|
193
|
+
};
|
|
194
|
+
const named = distinct.map(({ key, via: source }) => `${key} via ${via(source)}`);
|
|
195
|
+
findings.push({
|
|
196
|
+
file,
|
|
197
|
+
...position,
|
|
198
|
+
severity: "error",
|
|
199
|
+
message:
|
|
200
|
+
`place ${record.shortcode}: data.parents resolve to different calendars ` +
|
|
201
|
+
`(${named.join(", ")}); state data.calendar`,
|
|
202
|
+
});
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The calendar a note states through its own field, its culture or — for a
|
|
207
|
+
* place — its parents, ignoring the default.
|
|
208
|
+
*
|
|
209
|
+
* @param {Record<string, any>} record - The note.
|
|
210
|
+
* @returns {string|undefined} The calendar's canonical key.
|
|
211
|
+
*/
|
|
212
|
+
const resolveRecord = (record) => {
|
|
213
|
+
const own = addressKey(record.data?.calendar);
|
|
214
|
+
if (own) return own;
|
|
215
|
+
const culture = statedThrough(record.data?.culture);
|
|
216
|
+
if (culture) return culture;
|
|
217
|
+
if (record.type !== "place") return undefined;
|
|
218
|
+
const parents = Array.isArray(record.data?.parents) ? record.data.parents : [];
|
|
219
|
+
const distinct = new Map();
|
|
220
|
+
for (const parent of parents) {
|
|
221
|
+
const calendar = statedThrough(parent);
|
|
222
|
+
if (calendar && !distinct.has(calendar)) distinct.set(calendar, parent);
|
|
223
|
+
}
|
|
224
|
+
if (distinct.size > 1) {
|
|
225
|
+
conflict(
|
|
226
|
+
record,
|
|
227
|
+
[...distinct].map(([key, via]) => ({ key, via })),
|
|
228
|
+
);
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
return distinct.keys().next().value;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A local note's stated calendar, memoised; a note already being resolved
|
|
236
|
+
* is a cycle and states nothing.
|
|
237
|
+
*
|
|
238
|
+
* @param {Record<string, any>} record - The note.
|
|
239
|
+
* @returns {string|undefined} The calendar's canonical key.
|
|
240
|
+
*/
|
|
241
|
+
const resolve = (record) => {
|
|
242
|
+
if (memo.has(record)) return memo.get(record);
|
|
243
|
+
if (inProgress.has(record)) return undefined;
|
|
244
|
+
inProgress.add(record);
|
|
245
|
+
const result = resolveRecord(record);
|
|
246
|
+
inProgress.delete(record);
|
|
247
|
+
memo.set(record, result);
|
|
248
|
+
return result;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Walked in address order, so which note of a cycle is met first is a fact
|
|
252
|
+
// about the content rather than about directory-read order.
|
|
253
|
+
const calendars = new Map();
|
|
254
|
+
for (const key of [...notes.keys()].sort())
|
|
255
|
+
calendars.set(key, resolve(notes.get(key)) ?? fallback.key);
|
|
256
|
+
// A note's second record — an item's documentation journal — is the same
|
|
257
|
+
// note, and reads in the same calendar.
|
|
258
|
+
for (const record of records) {
|
|
259
|
+
if (!isNoteRecord(record) || !isAddressSegment(String(record.shortcode ?? ""))) continue;
|
|
260
|
+
const calendar = calendars.get(
|
|
261
|
+
canonicalKey(contentPackage, NOTE_SYSTEM, record.type, record.shortcode),
|
|
262
|
+
);
|
|
263
|
+
if (calendar) record.resolvedCalendar = readCanonicalKey(calendar);
|
|
264
|
+
else delete record.resolvedCalendar;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
findings.sort(
|
|
268
|
+
(a, b) =>
|
|
269
|
+
String(a.file).localeCompare(String(b.file), "en") ||
|
|
270
|
+
(a.line ?? 0) - (b.line ?? 0) ||
|
|
271
|
+
(a.column ?? 0) - (b.column ?? 0),
|
|
272
|
+
);
|
|
273
|
+
if (problems) problems.push(...findings);
|
|
274
|
+
else if (findings.length)
|
|
275
|
+
throw Object.assign(new Error(findings.map(formatDiagnostic).join("\n")), {
|
|
276
|
+
located: true,
|
|
277
|
+
});
|
|
278
|
+
return records;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The configuration file in a package root, where a configured value is
|
|
283
|
+
* reported.
|
|
284
|
+
*
|
|
285
|
+
* @param {string|undefined} rootDir - The package root.
|
|
286
|
+
* @param {readonly string[]} names - The file names a configuration may take.
|
|
287
|
+
* @returns {string|undefined} The first that exists.
|
|
288
|
+
*/
|
|
289
|
+
export function configFileIn(rootDir, names) {
|
|
290
|
+
if (!rootDir) return undefined;
|
|
291
|
+
for (const name of names) {
|
|
292
|
+
const file = path.join(rootDir, name);
|
|
293
|
+
if (fs.existsSync(file)) return file;
|
|
294
|
+
}
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The calendar a note's dates print in: the one the content index resolved for
|
|
300
|
+
* it, where the reckoning context carries it, else the note's own
|
|
301
|
+
* `data.calendar`.
|
|
302
|
+
*
|
|
303
|
+
* @param {object|undefined} fm - The note's frontmatter.
|
|
304
|
+
* @param {{noteCalendars?: Map<string, string>}} [dates] - The reckoning context.
|
|
305
|
+
* @returns {string|undefined} The calendar's Address or shortcode.
|
|
306
|
+
*/
|
|
307
|
+
export function noteCalendar(fm, dates) {
|
|
308
|
+
const key = noteCalendarKey(fm);
|
|
309
|
+
const resolved = key ? dates?.noteCalendars?.get(key) : undefined;
|
|
310
|
+
if (resolved) return resolved;
|
|
311
|
+
const own = fm?.data?.calendar;
|
|
312
|
+
if (own === undefined || own === null || own === "") return undefined;
|
|
313
|
+
return addressKey(own) ?? String(own);
|
|
314
|
+
}
|
|
@@ -1030,6 +1030,17 @@ export const SHARED_DATA_FIELDS = Object.freeze([
|
|
|
1030
1030
|
describe:
|
|
1031
1031
|
"Deliberate cross-references in reading order, with optional glosses; shown on the site, in journals and in books.",
|
|
1032
1032
|
}),
|
|
1033
|
+
Object.freeze({
|
|
1034
|
+
name: "calendar",
|
|
1035
|
+
...LINK,
|
|
1036
|
+
ref: "lore",
|
|
1037
|
+
accepts: Object.freeze(["lore"]),
|
|
1038
|
+
check: checkCalendarChoice,
|
|
1039
|
+
describe:
|
|
1040
|
+
"The calendar this note's dates print in, as a calendar lore note. Absent, the " +
|
|
1041
|
+
"note takes its culture's calendar, then — for a place — its parents', then the " +
|
|
1042
|
+
"configured `defaultCalendar`.",
|
|
1043
|
+
}),
|
|
1033
1044
|
Object.freeze({
|
|
1034
1045
|
name: "id",
|
|
1035
1046
|
...TEXT,
|
|
@@ -1596,14 +1607,6 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
1596
1607
|
"When the being was born — a date, or `unknown` where the birth is " +
|
|
1597
1608
|
"unrecorded. Absent, the being was never born.",
|
|
1598
1609
|
},
|
|
1599
|
-
{
|
|
1600
|
-
name: "calendar",
|
|
1601
|
-
...LINK,
|
|
1602
|
-
ref: "lore",
|
|
1603
|
-
accepts: ["lore"],
|
|
1604
|
-
check: checkCalendarChoice,
|
|
1605
|
-
describe: "Calendar that displays this being's dates.",
|
|
1606
|
-
},
|
|
1607
1610
|
{
|
|
1608
1611
|
name: "died",
|
|
1609
1612
|
...TEXT,
|
|
@@ -2281,14 +2284,6 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
2281
2284
|
check: checkPlace,
|
|
2282
2285
|
data: Object.freeze([
|
|
2283
2286
|
EVENTS_FIELD,
|
|
2284
|
-
{
|
|
2285
|
-
name: "calendar",
|
|
2286
|
-
...LINK,
|
|
2287
|
-
ref: "lore",
|
|
2288
|
-
accepts: ["lore"],
|
|
2289
|
-
check: checkCalendarChoice,
|
|
2290
|
-
describe: "Calendar that displays this place's dates.",
|
|
2291
|
-
},
|
|
2292
2287
|
{
|
|
2293
2288
|
name: "demonym",
|
|
2294
2289
|
...TEXT,
|
|
@@ -209,13 +209,69 @@ export function resolveReckoningMarkers(index, daysPerYear) {
|
|
|
209
209
|
return { eras, markers, findings };
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* The key a note's resolved calendar is filed under: its type and shortcode.
|
|
214
|
+
*
|
|
215
|
+
* @param {object|undefined} fm - A note's frontmatter or record.
|
|
216
|
+
* @returns {string|undefined} The key.
|
|
217
|
+
*/
|
|
218
|
+
export function noteCalendarKey(fm) {
|
|
219
|
+
return fm?.type && fm?.shortcode ? `${fm.type}\u0000${fm.shortcode}` : undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* A record's `resolvedCalendar` as its canonical Address string: an Address
|
|
224
|
+
* tuple, as a record in memory carries it, or the string an encoded record
|
|
225
|
+
* carries.
|
|
226
|
+
*
|
|
227
|
+
* @param {unknown} value - The value.
|
|
228
|
+
* @returns {string|undefined} The Address, or `undefined` for none.
|
|
229
|
+
*/
|
|
230
|
+
export function calendarAddress(value) {
|
|
231
|
+
if (typeof value === "string") return value || undefined;
|
|
232
|
+
return value && typeof value === "object" && "shortcode" in value ?
|
|
233
|
+
renderAddress(/** @type {any} */ (value))
|
|
234
|
+
: undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Each note's resolved calendar, by {@link noteCalendarKey}, from the
|
|
239
|
+
* `resolvedCalendar` the content index writes on a record.
|
|
240
|
+
*
|
|
241
|
+
* @param {Iterable<object>|undefined} notes - Index records, or
|
|
242
|
+
* `{ fm, resolvedCalendar }` entries.
|
|
243
|
+
* @returns {Map<string, string>} Note key to the calendar's canonical Address.
|
|
244
|
+
*/
|
|
245
|
+
export function noteCalendarMap(notes) {
|
|
246
|
+
const out = new Map();
|
|
247
|
+
for (const note of notes ?? []) {
|
|
248
|
+
const calendar = calendarAddress(note?.resolvedCalendar);
|
|
249
|
+
if (!calendar) continue;
|
|
250
|
+
const key = noteCalendarKey(note?.fm ?? note?.frontmatter ?? note);
|
|
251
|
+
if (key && !out.has(key)) out.set(key, calendar);
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
212
256
|
/** One resolved date context per corpus index. */
|
|
213
257
|
const contexts = new WeakMap();
|
|
214
258
|
|
|
215
|
-
/**
|
|
259
|
+
/**
|
|
260
|
+
* Read the world's year length and resolve every calendar era in one corpus.
|
|
261
|
+
*
|
|
262
|
+
* The context also carries `noteCalendars`, each note's resolved calendar by
|
|
263
|
+
* note, read from the `resolvedCalendar` the content index writes on a record;
|
|
264
|
+
* {@link module:engine/note-calendars.noteCalendar} reads it.
|
|
265
|
+
*/
|
|
216
266
|
export function reckoningContext(index) {
|
|
217
267
|
if (!index || typeof index !== "object")
|
|
218
|
-
return {
|
|
268
|
+
return {
|
|
269
|
+
eras: new Map(),
|
|
270
|
+
markers: new Map(),
|
|
271
|
+
daysPerYear: undefined,
|
|
272
|
+
findings: [],
|
|
273
|
+
noteCalendars: new Map(),
|
|
274
|
+
};
|
|
219
275
|
const cached = contexts.get(index);
|
|
220
276
|
if (cached) return cached;
|
|
221
277
|
const world = (index.notes ?? []).find((note) => {
|
|
@@ -224,7 +280,11 @@ export function reckoningContext(index) {
|
|
|
224
280
|
});
|
|
225
281
|
const fm = world?.fm ?? world?.frontmatter ?? world;
|
|
226
282
|
const daysPerYear = fm?.data?.year?.days;
|
|
227
|
-
const context = {
|
|
283
|
+
const context = {
|
|
284
|
+
...resolveReckoningMarkers(index, daysPerYear),
|
|
285
|
+
daysPerYear,
|
|
286
|
+
noteCalendars: noteCalendarMap(index.notes),
|
|
287
|
+
};
|
|
228
288
|
contexts.set(index, context);
|
|
229
289
|
return context;
|
|
230
290
|
}
|
package/engine/sql-tables.mjs
CHANGED
|
@@ -31,7 +31,7 @@ import { collectAnchors } from "./anchors.mjs";
|
|
|
31
31
|
import { expandContentTables } from "./content-tables.mjs";
|
|
32
32
|
import { NOTE_VOCABULARY, isGmNote } from "./note-vocabulary.mjs";
|
|
33
33
|
import { encodeAddresses, flattenPublishedAddresses } from "./address-values.mjs";
|
|
34
|
-
import { EVENT_COLUMNS, EVENT_SORT_COLUMNS, eventRows } from "./event-rows.mjs";
|
|
34
|
+
import { EVENT_COLUMNS, EVENT_SORT_COLUMNS, eventCalendars, eventRows } from "./event-rows.mjs";
|
|
35
35
|
import { reckoningContext } from "./reckoning-markers.mjs";
|
|
36
36
|
import { NAME_COLUMNS, namesRows } from "./name-rows.mjs";
|
|
37
37
|
import {
|
|
@@ -552,7 +552,7 @@ async function createRelations(connection, file, records, prefix = "", audience
|
|
|
552
552
|
*/
|
|
553
553
|
async function createEventRelation(connection, file, records, prefix, audience) {
|
|
554
554
|
const visible = audience === "public" ? records.filter((record) => !isGmNote(record)) : records;
|
|
555
|
-
const rows = eventRows(visible, reckoningContext({ notes: records }));
|
|
555
|
+
const rows = eventRows(visible, reckoningContext({ notes: records }), eventCalendars(records));
|
|
556
556
|
if (!rows.length) {
|
|
557
557
|
const columns = EVENT_COLUMNS.map((column) => {
|
|
558
558
|
const type = EVENT_SORT_COLUMNS.includes(column) ? "DOUBLE" : "VARCHAR";
|
|
@@ -564,7 +564,13 @@ async function createEventRelation(connection, file, records, prefix, audience)
|
|
|
564
564
|
return;
|
|
565
565
|
}
|
|
566
566
|
fs.writeFileSync(file, rows.map((row) => JSON.stringify(row)).join("\n"));
|
|
567
|
-
|
|
567
|
+
// `whenIn` and `untilIn` are keyed by calendar Address, and read as structs
|
|
568
|
+
// whatever their number of keys, so `whenIn['<calendar>']` always binds.
|
|
569
|
+
await createTableFromFile(
|
|
570
|
+
connection,
|
|
571
|
+
`${prefix}events`,
|
|
572
|
+
readJsonAuto(file).replace(/\)$/, ", map_inference_threshold=-1)"),
|
|
573
|
+
);
|
|
568
574
|
}
|
|
569
575
|
|
|
570
576
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "23.12.
|
|
3
|
+
"version": "23.12.6",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -637,6 +637,13 @@ export type ContentBuildConfigInput = {
|
|
|
637
637
|
* Setting-wide neutral current date; defaults to "720.1".
|
|
638
638
|
*/
|
|
639
639
|
currentDate?: string | undefined;
|
|
640
|
+
/**
|
|
641
|
+
* Shortcode of the `lore` note with
|
|
642
|
+
* `subType: calendar` that prints the
|
|
643
|
+
* dates of a note no other rule gives a
|
|
644
|
+
* calendar.
|
|
645
|
+
*/
|
|
646
|
+
defaultCalendar?: string | undefined;
|
|
640
647
|
/**
|
|
641
648
|
* Content package name — the address
|
|
642
649
|
* namespace every note in this
|
|
@@ -761,6 +768,11 @@ export type ContentBuildConfig = {
|
|
|
761
768
|
* Setting-wide neutral current date.
|
|
762
769
|
*/
|
|
763
770
|
currentDate: string;
|
|
771
|
+
/**
|
|
772
|
+
* The calendar of last resort's
|
|
773
|
+
* shortcode, `null` when none is named.
|
|
774
|
+
*/
|
|
775
|
+
defaultCalendar: string | null;
|
|
764
776
|
rootDir: string;
|
|
765
777
|
contentPackage: string;
|
|
766
778
|
/**
|
|
@@ -205,6 +205,10 @@ export function resolveCultures(records: Array<Record<string, any>>, { contentPa
|
|
|
205
205
|
* reader receives each as a finding, and the emitter throws once, listing
|
|
206
206
|
* every one.
|
|
207
207
|
* @param {string} [options.currentDate="720.1"] - Setting-wide neutral current date.
|
|
208
|
+
* @param {string} [options.defaultCalendar] - The calendar of last resort, a
|
|
209
|
+
* calendar note's shortcode; see {@link module:engine/note-calendars}.
|
|
210
|
+
* @param {string} [options.configFile] - The configuration file, where an
|
|
211
|
+
* unknown `defaultCalendar` is reported.
|
|
208
212
|
* @param {(key: string) => ({culture?: unknown}|undefined)} [options.foreignRecord] -
|
|
209
213
|
* Another package's entry for a canonical address, read for a parent that
|
|
210
214
|
* package publishes; see {@link resolveCultures}.
|
|
@@ -217,7 +221,7 @@ export function resolveCultures(records: Array<Record<string, any>>, { contentPa
|
|
|
217
221
|
* @returns {Array<Record<string, any>>} The records, in a total order that does
|
|
218
222
|
* not depend on directory-read order.
|
|
219
223
|
*/
|
|
220
|
-
export function collectContentIndex(contentBase: string, { contentPackage, currentDate, skipDirectories, assetsBase, manifest, problems, addressContext, foreignRecord, parsed, sources, syntax, nameTypes, }: {
|
|
224
|
+
export function collectContentIndex(contentBase: string, { contentPackage, currentDate, defaultCalendar, configFile, skipDirectories, assetsBase, manifest, problems, addressContext, foreignRecord, parsed, sources, syntax, nameTypes, }: {
|
|
221
225
|
contentPackage: string;
|
|
222
226
|
skipDirectories: readonly string[];
|
|
223
227
|
assetsBase?: string | undefined;
|
|
@@ -225,6 +229,8 @@ export function collectContentIndex(contentBase: string, { contentPackage, curre
|
|
|
225
229
|
addressContext?: object | undefined;
|
|
226
230
|
problems?: object[] | undefined;
|
|
227
231
|
currentDate?: string | undefined;
|
|
232
|
+
defaultCalendar?: string | undefined;
|
|
233
|
+
configFile?: string | undefined;
|
|
228
234
|
foreignRecord?: ((key: string) => ({
|
|
229
235
|
culture?: unknown;
|
|
230
236
|
} | undefined)) | undefined;
|
|
@@ -1,15 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every calendar a set of records can print an event's date in: each calendar
|
|
3
|
+
* note's canonical Address, and every calendar a note resolves to.
|
|
4
|
+
*
|
|
5
|
+
* @param {Iterable<Record<string, any>>} records - Index records, every
|
|
6
|
+
* Address a string.
|
|
7
|
+
* @returns {string[]} The calendars, sorted.
|
|
8
|
+
*/
|
|
9
|
+
export function eventCalendars(records: Iterable<Record<string, any>>): string[];
|
|
1
10
|
/**
|
|
2
11
|
* The `events` rows of a set of index records.
|
|
3
12
|
*
|
|
4
13
|
* @param {Iterable<Record<string, any>>} records - Index records, every
|
|
5
14
|
* Address a string, already narrowed to the audience being published to.
|
|
6
15
|
* @param {object} [dates] - The reckoning context the dates are printed in.
|
|
16
|
+
* @param {readonly string[]} [calendars] - The calendars `whenIn` and
|
|
17
|
+
* `untilIn` are keyed by: {@link eventCalendars} of every record of the
|
|
18
|
+
* corpus, whatever the audience, so every surface's relation has the same
|
|
19
|
+
* keys. Absent, those of `records`.
|
|
7
20
|
* @returns {object[]} One row per event entry, in record then entry order.
|
|
8
21
|
*/
|
|
9
|
-
export function eventRows(records: Iterable<Record<string, any>>, dates?: object): object[];
|
|
22
|
+
export function eventRows(records: Iterable<Record<string, any>>, dates?: object, calendars?: readonly string[]): object[];
|
|
10
23
|
/** The sort keys a row carries, after the event's own keys. */
|
|
11
24
|
export const EVENT_SORT_COLUMNS: readonly string[];
|
|
12
25
|
/** The printed dates a row carries, after the sort keys. */
|
|
13
26
|
export const EVENT_TEXT_COLUMNS: readonly string[];
|
|
27
|
+
/** The dates a row carries in every calendar, after the printed ones. */
|
|
28
|
+
export const EVENT_CALENDAR_COLUMNS: readonly string[];
|
|
14
29
|
/** Every column of the `events` relation, in order. */
|
|
15
30
|
export const EVENT_COLUMNS: any;
|
|
@@ -34,7 +34,7 @@ export function eventViewSections(db: object, records: Iterable<object>, fileOf:
|
|
|
34
34
|
*
|
|
35
35
|
* @type {ReadonlyArray<Readonly<{slug: string, heading: string,
|
|
36
36
|
* applies: (record: object) => boolean,
|
|
37
|
-
* query: (self: string[], shape: CorpusShape, columns?: {felt?: boolean}) => {fence: string, own?: string}|null,
|
|
37
|
+
* query: (self: string[], shape: CorpusShape, columns?: {felt?: boolean, calendar?: string}) => {fence: string, own?: string}|null,
|
|
38
38
|
* owners?: (db: object, selves: string[][], shape: CorpusShape) => Promise<Set<string>>,
|
|
39
39
|
* felt?: (db: object, selves: string[][], shape: CorpusShape) => Promise<Set<string>>}>>}
|
|
40
40
|
*/
|
|
@@ -44,6 +44,7 @@ export const EVENT_VIEWS: ReadonlyArray<Readonly<{
|
|
|
44
44
|
applies: (record: object) => boolean;
|
|
45
45
|
query: (self: string[], shape: CorpusShape, columns?: {
|
|
46
46
|
felt?: boolean;
|
|
47
|
+
calendar?: string;
|
|
47
48
|
}) => {
|
|
48
49
|
fence: string;
|
|
49
50
|
own?: string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write each note's resolved calendar into its record, as `resolvedCalendar`.
|
|
3
|
+
*
|
|
4
|
+
* Reads each note's authored `data.calendar`, `data.culture` and
|
|
5
|
+
* `data.parents`, so it runs before the index writes a resolved culture over
|
|
6
|
+
* `data.culture`. A culture or parent in another package contributes the
|
|
7
|
+
* calendar its published entry states in `data.calendar`, and nothing else.
|
|
8
|
+
*
|
|
9
|
+
* @param {Array<Record<string, any>>} records - Index records, every Address a
|
|
10
|
+
* tuple. Records that are not notes are passed over.
|
|
11
|
+
* @param {object} options - Options.
|
|
12
|
+
* @param {string} options.contentPackage - The package the records belong to.
|
|
13
|
+
* @param {string} [options.defaultCalendar] - The configured calendar of last
|
|
14
|
+
* resort, a calendar note's shortcode.
|
|
15
|
+
* @param {string} [options.configFile] - The configuration file, where an
|
|
16
|
+
* unknown `defaultCalendar` is reported.
|
|
17
|
+
* @param {string} [options.contentBase] - The content tree, for opening a note
|
|
18
|
+
* to locate a finding.
|
|
19
|
+
* @param {(key: string) => Record<string, any>|undefined} [options.foreignRecord] -
|
|
20
|
+
* Another package's entry for a canonical address.
|
|
21
|
+
* @param {object[]} [options.problems] - Receives each finding. Omitted, the
|
|
22
|
+
* findings are collected and one Error lists them all.
|
|
23
|
+
* @param {Map<string, {raw: string}>} [options.parsed] - Notes already read, keyed by file.
|
|
24
|
+
* @returns {Array<Record<string, any>>} The same records.
|
|
25
|
+
* @throws {Error} When `problems` is omitted and anything is reported.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveNoteCalendars(records: Array<Record<string, any>>, { contentPackage, defaultCalendar, configFile, contentBase, foreignRecord, problems, parsed }: {
|
|
28
|
+
contentPackage: string;
|
|
29
|
+
defaultCalendar?: string | undefined;
|
|
30
|
+
configFile?: string | undefined;
|
|
31
|
+
contentBase?: string | undefined;
|
|
32
|
+
foreignRecord?: ((key: string) => Record<string, any> | undefined) | undefined;
|
|
33
|
+
problems?: object[] | undefined;
|
|
34
|
+
parsed?: Map<string, {
|
|
35
|
+
raw: string;
|
|
36
|
+
}> | undefined;
|
|
37
|
+
}): Array<Record<string, any>>;
|
|
38
|
+
/**
|
|
39
|
+
* The configuration file in a package root, where a configured value is
|
|
40
|
+
* reported.
|
|
41
|
+
*
|
|
42
|
+
* @param {string|undefined} rootDir - The package root.
|
|
43
|
+
* @param {readonly string[]} names - The file names a configuration may take.
|
|
44
|
+
* @returns {string|undefined} The first that exists.
|
|
45
|
+
*/
|
|
46
|
+
export function configFileIn(rootDir: string | undefined, names: readonly string[]): string | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* The calendar a note's dates print in: the one the content index resolved for
|
|
49
|
+
* it, where the reckoning context carries it, else the note's own
|
|
50
|
+
* `data.calendar`.
|
|
51
|
+
*
|
|
52
|
+
* @param {object|undefined} fm - The note's frontmatter.
|
|
53
|
+
* @param {{noteCalendars?: Map<string, string>}} [dates] - The reckoning context.
|
|
54
|
+
* @returns {string|undefined} The calendar's Address or shortcode.
|
|
55
|
+
*/
|
|
56
|
+
export function noteCalendar(fm: object | undefined, dates?: {
|
|
57
|
+
noteCalendars?: Map<string, string>;
|
|
58
|
+
}): string | undefined;
|
|
59
|
+
/** The configuration key naming the calendar of last resort. */
|
|
60
|
+
export const DEFAULT_CALENDAR_KEY: "defaultCalendar";
|