@heroiclands/package-build 23.12.4 → 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 +28 -0
- package/content-config.mjs +26 -0
- package/docs/api.md +9 -5
- package/docs/authoring/dates-and-calendars.md +29 -1
- package/docs/authoring/frontmatter.md +1 -1
- package/docs/authoring/links-and-markup.md +7 -7
- package/docs/configuration.md +30 -4
- package/docs/guides/book.md +12 -12
- package/docs/guides/module-project.md +1 -1
- package/docs/reference/format-details.md +81 -24
- package/docs/reference/note-types.md +10 -11
- package/engine/content-format.yaml +0 -2
- package/engine/content-index.mjs +23 -41
- package/engine/content-links.mjs +3 -0
- package/engine/event-fields.mjs +24 -1
- package/engine/event-rows.mjs +82 -2
- package/engine/event-views.mjs +74 -17
- package/engine/frontmatter-lint.mjs +11 -0
- package/engine/generated-sections.mjs +8 -1
- package/engine/holdings.mjs +8 -2
- package/engine/index-records.mjs +1 -0
- package/engine/infobox-render.mjs +16 -5
- package/engine/infobox.mjs +27 -10
- package/engine/literature-works.mjs +8 -2
- package/engine/markdown-expressions.mjs +72 -17
- package/engine/metadata-index.mjs +3 -0
- package/engine/name-sort.mjs +91 -0
- package/engine/note-calendars.mjs +314 -0
- package/engine/note-frontmatter.mjs +1 -1
- package/engine/note-vocabulary.mjs +12 -17
- package/engine/page-lists.mjs +3 -2
- package/engine/pdf-index.mjs +11 -4
- package/engine/pdf-pages.mjs +2 -1
- package/engine/pdf-render.mjs +43 -13
- package/engine/pdf-toc.mjs +4 -3
- package/engine/reckoning-markers.mjs +63 -3
- package/engine/related-pages.mjs +12 -2
- package/engine/site-build.mjs +3 -0
- package/engine/sql-tables.mjs +28 -7
- package/package.json +1 -1
- package/types/content-config.d.mts +12 -0
- package/types/engine/content-index.d.mts +9 -32
- package/types/engine/event-fields.d.mts +11 -0
- package/types/engine/event-rows.d.mts +19 -1
- package/types/engine/event-views.d.mts +8 -3
- package/types/engine/generated-sections.d.mts +6 -0
- package/types/engine/holdings.d.mts +7 -1
- package/types/engine/infobox.d.mts +11 -0
- package/types/engine/literature-works.d.mts +5 -0
- package/types/engine/name-sort.d.mts +49 -0
- package/types/engine/note-calendars.d.mts +60 -0
- package/types/engine/pdf-render.d.mts +8 -0
- package/types/engine/pdf-toc.d.mts +1 -1
- package/types/engine/reckoning-markers.d.mts +32 -1
- package/types/engine/related-pages.d.mts +5 -0
|
@@ -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
|
+
}
|
|
@@ -22,7 +22,7 @@ export {
|
|
|
22
22
|
} from "./note-vocabulary.mjs";
|
|
23
23
|
|
|
24
24
|
/** The name properties every note accepts, in display order. */
|
|
25
|
-
export const NOTE_NAME_KEYS = Object.freeze(["full", "aliases"]);
|
|
25
|
+
export const NOTE_NAME_KEYS = Object.freeze(["full", "aliases", "sort"]);
|
|
26
26
|
|
|
27
27
|
/** Additional name components accepted by an individual character. */
|
|
28
28
|
export const CHARACTER_NAME_KEYS = Object.freeze(["given", "clan"]);
|
|
@@ -939,7 +939,7 @@ export const NOTE_TOP_LEVEL_FIELDS = Object.freeze([
|
|
|
939
939
|
}),
|
|
940
940
|
Object.freeze({
|
|
941
941
|
name: "name",
|
|
942
|
-
describe: "The display names — a required `full`, and any `aliases`.",
|
|
942
|
+
describe: "The display names — a required `full`, and any `aliases` and `sort`.",
|
|
943
943
|
}),
|
|
944
944
|
Object.freeze({
|
|
945
945
|
name: "type",
|
|
@@ -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,
|
package/engine/page-lists.mjs
CHANGED
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
* @module
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
|
+
import { nameSortKey } from "./name-sort.mjs";
|
|
44
45
|
import { FENCE_LINE, parseHeaderArgs } from "./code-fences.mjs";
|
|
45
46
|
import { booleanAttribute } from "./extension-attributes.mjs";
|
|
46
47
|
import { isNoteRecord } from "./index-records.mjs";
|
|
@@ -195,8 +196,8 @@ export function findPageListBlocks(markdown) {
|
|
|
195
196
|
return blocks;
|
|
196
197
|
}
|
|
197
198
|
|
|
198
|
-
/** A page's sort key
|
|
199
|
-
const nameKey = (record) =>
|
|
199
|
+
/** A page's sort key: its `name.sort` or `name.full`, folded so a circumflex sorts with its letter. */
|
|
200
|
+
const nameKey = (record) => nameSortKey(record).toLowerCase();
|
|
200
201
|
|
|
201
202
|
/** The address a page is addressed by, as a stable tiebreak. */
|
|
202
203
|
const slugKey = (record) => String(record?.address?.slug ?? "");
|
package/engine/pdf-index.mjs
CHANGED
|
@@ -33,12 +33,15 @@
|
|
|
33
33
|
*
|
|
34
34
|
* Terms sort on the ASCII fold the content index derives for names
|
|
35
35
|
* ({@link module:engine/content-index.asciiName}), ignoring case, so an
|
|
36
|
-
* accented name files under its base letter.
|
|
36
|
+
* accented name files under its base letter. A note's own entry is its
|
|
37
|
+
* `name.sort` when it declares one (`Black Flame, The`, `Hilvan, Doran`) and
|
|
38
|
+
* its `name.full` otherwise; it prints, files and takes its letter heading
|
|
39
|
+
* from that one form.
|
|
37
40
|
*
|
|
38
41
|
* @module
|
|
39
42
|
*/
|
|
40
43
|
|
|
41
|
-
import { asciiName } from "./
|
|
44
|
+
import { asciiName, nameSortOf } from "./name-sort.mjs";
|
|
42
45
|
|
|
43
46
|
/**
|
|
44
47
|
* The marker an entry heading or an authored mention carries.
|
|
@@ -125,8 +128,9 @@ export function indexTerms(entries, { owns = () => true, foreign = [], heads = [
|
|
|
125
128
|
for (const entry of entries ?? []) {
|
|
126
129
|
if (entry?.kind !== "note" || !owns(entry)) continue;
|
|
127
130
|
const slug = slugOf(entry.record);
|
|
128
|
-
const name = String(entry.record
|
|
131
|
+
const name = String(nameSortOf(entry.record) ?? "").trim();
|
|
129
132
|
if (!slug || !name || info.has(slug)) continue;
|
|
133
|
+
const declared = Boolean(String(entry.record?.name?.sort ?? "").trim());
|
|
130
134
|
const parents = Array.isArray(entry.record?.data?.parents) ? entry.record.data.parents : [];
|
|
131
135
|
const parent = parents
|
|
132
136
|
.map((one) =>
|
|
@@ -139,7 +143,10 @@ export function indexTerms(entries, { owns = () => true, foreign = [], heads = [
|
|
|
139
143
|
});
|
|
140
144
|
terms.push({ name, slug, main: true });
|
|
141
145
|
const taken = new Set([sortKey(name)]);
|
|
142
|
-
const head =
|
|
146
|
+
const head =
|
|
147
|
+
declared ? undefined : (
|
|
148
|
+
byLength.find((one) => name.startsWith(one) && name.slice(one.length).trim())
|
|
149
|
+
);
|
|
143
150
|
if (head) {
|
|
144
151
|
const inverted = `${name.slice(head.length).trim()}, ${head.replace(/[\s:]+$/, "")}`;
|
|
145
152
|
taken.add(sortKey(inverted));
|
package/engine/pdf-pages.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import { spawnSync } from "node:child_process";
|
|
|
39
39
|
import { encodeAddresses } from "./address-values.mjs";
|
|
40
40
|
import { recordKey } from "./pdf-coverage.mjs";
|
|
41
41
|
import { labelFor } from "./pdf-render.mjs";
|
|
42
|
+
import { nameSortOf } from "./name-sort.mjs";
|
|
42
43
|
|
|
43
44
|
/** What a reference prints for a page that has not been measured. */
|
|
44
45
|
export const UNMEASURED_PAGE = "?";
|
|
@@ -259,7 +260,7 @@ export function elsewhereFor({ entries, book, printed, table, seeAlso = false })
|
|
|
259
260
|
if (seeAlso && entry.slug && !printed.has(entry.slug)) {
|
|
260
261
|
listed.push({
|
|
261
262
|
slug: entry.slug,
|
|
262
|
-
name: String(entry.record
|
|
263
|
+
name: String(nameSortOf(entry.record) ?? "").trim(),
|
|
263
264
|
title: entry.title,
|
|
264
265
|
abbreviation: entry.abbreviation,
|
|
265
266
|
page,
|
package/engine/pdf-render.mjs
CHANGED
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
imagePlugin,
|
|
76
76
|
} from "./content-images.mjs";
|
|
77
77
|
import { bookImageWidthIn } from "./pdf-images.mjs";
|
|
78
|
+
import { CHRONOLOGY_SLUG } from "./generated-sections.mjs";
|
|
78
79
|
|
|
79
80
|
/** Requested print width for every named image size. */
|
|
80
81
|
export const BOOK_IMAGE_WIDTHS = Object.freeze({
|
|
@@ -738,6 +739,8 @@ function renderBlock(tokens, i, out, ctx) {
|
|
|
738
739
|
// the same words later in the same entry.
|
|
739
740
|
const base = anchor || slugify(plainHeadingText(inline)) || "heading";
|
|
740
741
|
const unique = anchorFor(base, ctx.seen);
|
|
742
|
+
// The section a table falls in decides whether it spans the page.
|
|
743
|
+
ctx.section = base;
|
|
741
744
|
const label = ` <${sectionLabel(ctx.anchorPrefix, unique)}>`;
|
|
742
745
|
// A body heading is never printed and never bookmarked — it is
|
|
743
746
|
// structure a reader reaches only by following a link, not a
|
|
@@ -1166,8 +1169,14 @@ function renderTable(tokens, ctx) {
|
|
|
1166
1169
|
// Wide content is given an explicit span rather than left to overflow the
|
|
1167
1170
|
// measure: past three columns a table is set across the page, and
|
|
1168
1171
|
// `book-wide` decides between a float and pages of its own by measuring it.
|
|
1169
|
-
|
|
1170
|
-
|
|
1172
|
+
// A table can ask for the span, whatever its columns: its caption takes
|
|
1173
|
+
// `.full-width`, and a Chronology section is always set that way. A wide
|
|
1174
|
+
// table floats to where it fits and the text runs on past it, so a heading
|
|
1175
|
+
// is never left alone above an empty page; its heading, caption and any
|
|
1176
|
+
// reference to it are what lead a reader there.
|
|
1177
|
+
const asked =
|
|
1178
|
+
Boolean(caption?.classes?.includes("full-width")) || ctx.section === CHRONOLOGY_SLUG;
|
|
1179
|
+
if (asked || columns > WIDE_TABLE_COLUMNS) return `\n#book-wide[\n${labelled}\n]\n\n`;
|
|
1171
1180
|
return `\n${labelled}\n\n`;
|
|
1172
1181
|
}
|
|
1173
1182
|
|
|
@@ -1295,7 +1304,11 @@ function renderInline(token, ctx) {
|
|
|
1295
1304
|
case "link_open": {
|
|
1296
1305
|
const close = childMatching(children, i, "link_open", "link_close");
|
|
1297
1306
|
const inner = renderInline({ children: children.slice(i + 1, close) }, ctx);
|
|
1298
|
-
|
|
1307
|
+
// A link the text closes a bracket on takes its reference
|
|
1308
|
+
// inside that bracket.
|
|
1309
|
+
const after = children[close + 1];
|
|
1310
|
+
const bracketed = after?.type === "text" && after.content.startsWith(")");
|
|
1311
|
+
out.push(renderLink(child.attrGet?.("href") ?? "", inner, ctx, { bracketed }));
|
|
1299
1312
|
i = close;
|
|
1300
1313
|
break;
|
|
1301
1314
|
}
|
|
@@ -1411,9 +1424,11 @@ function pageOfLabel(label) {
|
|
|
1411
1424
|
* @param {string} href - The resolved URL.
|
|
1412
1425
|
* @param {string} inner - The already-rendered link text.
|
|
1413
1426
|
* @param {object} ctx - Render context.
|
|
1427
|
+
* @param {{bracketed?: boolean}} [opts] - `bracketed` when the text closes a
|
|
1428
|
+
* bracket right after the link; see {@link crossBookReference}.
|
|
1414
1429
|
* @returns {string} Typst markup.
|
|
1415
1430
|
*/
|
|
1416
|
-
function renderLink(href, inner, ctx) {
|
|
1431
|
+
function renderLink(href, inner, ctx, { bracketed = false } = {}) {
|
|
1417
1432
|
const url = String(href ?? "");
|
|
1418
1433
|
const fragment = /#([^?]*)/.exec(url)?.[1] ?? "";
|
|
1419
1434
|
if (url.startsWith("#") && fragment)
|
|
@@ -1441,7 +1456,7 @@ function renderLink(href, inner, ctx) {
|
|
|
1441
1456
|
// A mention in authored prose is what lists the note, under
|
|
1442
1457
|
// `pdf.indexSeeAlso`.
|
|
1443
1458
|
const marker = ctx.indexMentions && ctx.elsewhere.seeAlso ? indexMarker(away.slug) : "";
|
|
1444
|
-
return `${crossBookReference(inner, away)}${marker}`;
|
|
1459
|
+
return `${crossBookReference(inner, away, { bracketed })}${marker}`;
|
|
1445
1460
|
}
|
|
1446
1461
|
if (!url || ctx.generated) return inner;
|
|
1447
1462
|
return `#link("${escapeTypstString(absolutePageUrl(url, ctx.url))}")[${inner}]`;
|
|
@@ -1452,6 +1467,10 @@ function renderLink(href, inner, ctx) {
|
|
|
1452
1467
|
* carried, then the book and the page. A book with an abbreviation reads
|
|
1453
1468
|
* `Name (Aelw. p12)`; one without reads `Name (_Book title_, p. 12)`.
|
|
1454
1469
|
*
|
|
1470
|
+
* A link the text already closes a bracket on, as in `(see Name)`, takes the
|
|
1471
|
+
* reference inside that bracket after a comma, `(see Name, Aelw. p12)`, so the
|
|
1472
|
+
* brackets never nest.
|
|
1473
|
+
*
|
|
1455
1474
|
* The space before the page number, or between the abbreviation and the page,
|
|
1456
1475
|
* does not break, so a number never starts a line of its own.
|
|
1457
1476
|
*
|
|
@@ -1459,13 +1478,16 @@ function renderLink(href, inner, ctx) {
|
|
|
1459
1478
|
* @param {{title: string, abbreviation?: string, page: string|number}} away -
|
|
1460
1479
|
* The home book's title, its abbreviation when it declares one, and the page
|
|
1461
1480
|
* the entry starts on.
|
|
1481
|
+
* @param {{bracketed?: boolean}} [opts] - `bracketed` when the text closes a
|
|
1482
|
+
* bracket right after the link.
|
|
1462
1483
|
* @returns {string} Typst markup.
|
|
1463
1484
|
*/
|
|
1464
|
-
export function crossBookReference(inner, away) {
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1485
|
+
export function crossBookReference(inner, away, { bracketed = false } = {}) {
|
|
1486
|
+
const where =
|
|
1487
|
+
away.abbreviation ?
|
|
1488
|
+
`${escapeTypst(away.abbreviation)}~p${away.page}`
|
|
1489
|
+
: `#emph[${escapeTypst(away.title)}], p.~${away.page}`;
|
|
1490
|
+
return bracketed ? `${inner}, ${where}` : `${inner} (${where})`;
|
|
1469
1491
|
}
|
|
1470
1492
|
|
|
1471
1493
|
/**
|
|
@@ -1702,10 +1724,12 @@ export function bookTypstPreamble() {
|
|
|
1702
1724
|
" ]\n" +
|
|
1703
1725
|
" ])\n" +
|
|
1704
1726
|
"]",
|
|
1705
|
-
"#let book-ornament = box(baseline:
|
|
1706
|
-
"rotate(45deg, rect(width:
|
|
1727
|
+
"#let book-ornament = box(baseline: 1.2pt, " +
|
|
1728
|
+
"rotate(45deg, rect(width: 3.6pt, height: 3.6pt, fill: book-accent)))",
|
|
1729
|
+
// The foot is set at the body's size, in the faint ink, so the page
|
|
1730
|
+
// number and the section name read without being taken for text.
|
|
1707
1731
|
"#let book-footer(name) = context {\n" +
|
|
1708
|
-
" set text(
|
|
1732
|
+
" set text(fill: book-faint)\n" +
|
|
1709
1733
|
" grid(columns: (1fr, auto, 1fr), align(left)[#name], align(center)[#book-ornament],\n" +
|
|
1710
1734
|
" align(right)[#counter(page).display()])\n" +
|
|
1711
1735
|
"}",
|
|
@@ -2030,6 +2054,12 @@ export function renderBook({
|
|
|
2030
2054
|
// becomes wider than `1.`.
|
|
2031
2055
|
out.push("#show list: set par(first-line-indent: 0em)");
|
|
2032
2056
|
out.push("#show enum: set par(first-line-indent: 0em)");
|
|
2057
|
+
// Items sit three-quarters of a line apart, so a list of several-line
|
|
2058
|
+
// items reads as separate entries. The face's own bullet is small at book
|
|
2059
|
+
// size, so it is enlarged and lifted to the middle of the first line; a
|
|
2060
|
+
// nested level takes a dash.
|
|
2061
|
+
out.push("#set list(spacing: 0.75em, marker: (text(size: 1.3em, baseline: -0.08em)[•], [–]))");
|
|
2062
|
+
out.push("#set enum(spacing: 0.75em)");
|
|
2033
2063
|
// The mono face is a separate claim from the book face: a fenced block is
|
|
2034
2064
|
// the one place the corpus is allowed box-drawing characters, and the
|
|
2035
2065
|
// serif that sets the prose is not the font that carries them.
|
package/engine/pdf-toc.mjs
CHANGED
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
* @module
|
|
50
50
|
*/
|
|
51
51
|
|
|
52
|
+
import { nameSortKey } from "./name-sort.mjs";
|
|
52
53
|
import { encodeAddresses } from "./address-values.mjs";
|
|
53
54
|
import { slugify } from "./content-slug.mjs";
|
|
54
55
|
import { positionOfYamlPath } from "./diagnostics.mjs";
|
|
@@ -416,7 +417,7 @@ export async function runTreeFilters(nodes, db, { keep = () => true, text } = {}
|
|
|
416
417
|
* without a renderer or a PDF. Only how the result *looks* needs eyes.
|
|
417
418
|
*
|
|
418
419
|
* **Notes are sorted, prose is not.** A section's entries come out in
|
|
419
|
-
*
|
|
420
|
+
* name-sort order (`name.sort`, else `name.full`, folded to ASCII), so a
|
|
420
421
|
* circumflex sorts with its letter instead of after `Z` as a raw codepoint
|
|
421
422
|
* comparison would put it. Prose keeps the position it was written in, because
|
|
422
423
|
* its place in the sequence is the author's statement.
|
|
@@ -566,8 +567,8 @@ function emitSection(node, nodes, ctx) {
|
|
|
566
567
|
*/
|
|
567
568
|
function sortRecords(records) {
|
|
568
569
|
return [...records].sort((a, b) => {
|
|
569
|
-
const an = a
|
|
570
|
-
const bn = b
|
|
570
|
+
const an = nameSortKey(a);
|
|
571
|
+
const bn = nameSortKey(b);
|
|
571
572
|
if (an !== bn) return an < bn ? -1 : 1;
|
|
572
573
|
const as = a?.address?.slug ?? "";
|
|
573
574
|
const bs = b?.address?.slug ?? "";
|