@heroiclands/package-build 23.12.3 → 23.12.5
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/ci/ci-steps.mjs +5 -5
- package/content-config.mjs +19 -0
- package/docs/api.md +1 -0
- package/docs/authoring/frontmatter.md +1 -1
- package/docs/authoring/links-and-markup.md +4 -4
- package/docs/commands.md +5 -4
- package/docs/configuration.md +29 -12
- package/docs/guides/book.md +15 -13
- package/docs/guides/release-and-deploy.md +6 -9
- package/docs/project-setup.md +11 -11
- package/docs/reference/format-details.md +28 -16
- package/docs/reference/note-types.md +1 -1
- package/engine/content-index.mjs +2 -39
- package/engine/event-fields.mjs +24 -1
- package/engine/event-rows.mjs +37 -2
- package/engine/event-views.mjs +58 -14
- package/engine/frontmatter-lint.mjs +11 -0
- package/engine/generated-sections.mjs +8 -1
- package/engine/holdings.mjs +8 -2
- package/engine/infobox-render.mjs +16 -5
- package/engine/infobox.mjs +23 -2
- package/engine/literature-works.mjs +8 -2
- package/engine/metadata-index.mjs +3 -0
- package/engine/name-sort.mjs +91 -0
- package/engine/note-frontmatter.mjs +1 -1
- package/engine/note-vocabulary.mjs +1 -1
- package/engine/page-lists.mjs +3 -2
- package/engine/pdf-build.mjs +1 -0
- package/engine/pdf-index.mjs +33 -12
- package/engine/pdf-pages.mjs +13 -3
- package/engine/pdf-render.mjs +60 -33
- package/engine/pdf-toc.mjs +4 -3
- package/engine/project-init.mjs +2 -2
- package/engine/related-pages.mjs +12 -2
- package/engine/site-build.mjs +3 -0
- package/engine/sql-tables.mjs +20 -5
- package/githooks/pre-push +5 -5
- package/package.json +1 -1
- package/types/content-config.d.mts +5 -0
- package/types/engine/content-index.d.mts +2 -31
- package/types/engine/event-fields.d.mts +11 -0
- package/types/engine/event-rows.d.mts +4 -1
- package/types/engine/event-views.d.mts +7 -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/pdf-index.d.mts +7 -3
- package/types/engine/pdf-pages.d.mts +2 -1
- package/types/engine/pdf-render.d.mts +16 -5
- package/types/engine/pdf-toc.d.mts +1 -1
- package/types/engine/related-pages.d.mts +5 -0
- package/ci/confirm-npm-version.mjs +0 -135
package/engine/event-fields.mjs
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { ownDocumentSystem, parseAddress, readCanonicalKey, renderAddress } from "./address.mjs";
|
|
26
|
-
import { formatNoteDate, parseNoteDate } from "./note-dates.mjs";
|
|
26
|
+
import { formatDateInCalendar, formatNoteDate, parseNoteDate } from "./note-dates.mjs";
|
|
27
27
|
import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
|
|
28
28
|
|
|
29
29
|
/** The fields of an event an inline reference may print, in the order a message names them. */
|
|
@@ -161,6 +161,29 @@ export function printEventDate(written, date, dates) {
|
|
|
161
161
|
return printable?.prose ?? printable?.text ?? date.prose ?? date.text ?? String(written);
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* A date as `dateformat` prints it in a calendar, where one is named and
|
|
166
|
+
* prints it; otherwise as {@link printEventDate} prints it.
|
|
167
|
+
*
|
|
168
|
+
* @param {unknown} written - The date as the event writes it.
|
|
169
|
+
* @param {object|null} date - The resolved date.
|
|
170
|
+
* @param {object} [dates] - The reckoning context.
|
|
171
|
+
* @param {string} [calendar] - The calendar's Address or shortcode.
|
|
172
|
+
* @returns {string} The printed date; the authored text when nothing resolves.
|
|
173
|
+
*/
|
|
174
|
+
export function printEventDateIn(written, date, dates, calendar) {
|
|
175
|
+
if (date?.known && calendar) {
|
|
176
|
+
try {
|
|
177
|
+
const printable = formatDateInCalendar(date, calendar, dates);
|
|
178
|
+
const text = printable?.prose ?? printable?.text;
|
|
179
|
+
if (text) return text;
|
|
180
|
+
} catch {
|
|
181
|
+
// A calendar that does not resolve leaves the date in its own era.
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return printEventDate(written, date, dates);
|
|
185
|
+
}
|
|
186
|
+
|
|
164
187
|
/**
|
|
165
188
|
* One field of one event, as the text a reference prints, or why it cannot.
|
|
166
189
|
*
|
package/engine/event-rows.mjs
CHANGED
|
@@ -25,10 +25,16 @@
|
|
|
25
25
|
* `resolvedDates.events`, position for position with `data.events`, and the
|
|
26
26
|
* sort keys are read from it rather than parsed again here.
|
|
27
27
|
*
|
|
28
|
+
* **`whenText` and `untilText` are the dates as a reader sees them**: printed
|
|
29
|
+
* as the `dateformat` helper prints a date, in the calendar the owning note
|
|
30
|
+
* names in `data.calendar`, else the one the world note names, else in the era
|
|
31
|
+
* the date itself names. A date that does not resolve keeps its authored text.
|
|
32
|
+
*
|
|
28
33
|
* @module
|
|
29
34
|
*/
|
|
30
35
|
|
|
31
36
|
import { ownDocumentSystem, renderAddress } from "./address.mjs";
|
|
37
|
+
import { printEventDateIn } from "./event-fields.mjs";
|
|
32
38
|
import { EVENT_ENTRY_KEYS } from "./note-event-terms.mjs";
|
|
33
39
|
|
|
34
40
|
/** The columns a row carries besides the event's own keys, before them. */
|
|
@@ -37,11 +43,15 @@ const IDENTITY_COLUMNS = Object.freeze(["note", "address"]);
|
|
|
37
43
|
/** The sort keys a row carries, after the event's own keys. */
|
|
38
44
|
export const EVENT_SORT_COLUMNS = Object.freeze(["whenSort", "whenYear", "untilSort", "untilYear"]);
|
|
39
45
|
|
|
46
|
+
/** The printed dates a row carries, after the sort keys. */
|
|
47
|
+
export const EVENT_TEXT_COLUMNS = Object.freeze(["whenText", "untilText"]);
|
|
48
|
+
|
|
40
49
|
/** Every column of the `events` relation, in order. */
|
|
41
50
|
export const EVENT_COLUMNS = Object.freeze([
|
|
42
51
|
...IDENTITY_COLUMNS,
|
|
43
52
|
...EVENT_ENTRY_KEYS,
|
|
44
53
|
...EVENT_SORT_COLUMNS,
|
|
54
|
+
...EVENT_TEXT_COLUMNS,
|
|
45
55
|
]);
|
|
46
56
|
|
|
47
57
|
/** The keys whose authored value is a date, printed as the text it was written as. */
|
|
@@ -84,22 +94,41 @@ function axisKeys(date) {
|
|
|
84
94
|
};
|
|
85
95
|
}
|
|
86
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The calendar the world note names, which displays every date whose own note
|
|
99
|
+
* names none.
|
|
100
|
+
*
|
|
101
|
+
* @param {Record<string, any>[]} records - Index records.
|
|
102
|
+
* @returns {string|undefined} The calendar's Address or shortcode.
|
|
103
|
+
*/
|
|
104
|
+
function worldCalendar(records) {
|
|
105
|
+
for (const record of records) {
|
|
106
|
+
if (record?.type === "place" && record.data?.year?.days && record.data.calendar)
|
|
107
|
+
return String(record.data.calendar);
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
87
112
|
/**
|
|
88
113
|
* The `events` rows of a set of index records.
|
|
89
114
|
*
|
|
90
115
|
* @param {Iterable<Record<string, any>>} records - Index records, every
|
|
91
116
|
* Address a string, already narrowed to the audience being published to.
|
|
117
|
+
* @param {object} [dates] - The reckoning context the dates are printed in.
|
|
92
118
|
* @returns {object[]} One row per event entry, in record then entry order.
|
|
93
119
|
*/
|
|
94
|
-
export function eventRows(records) {
|
|
120
|
+
export function eventRows(records, dates) {
|
|
95
121
|
const rows = [];
|
|
96
|
-
|
|
122
|
+
const all = [...records];
|
|
123
|
+
const world = worldCalendar(all);
|
|
124
|
+
for (const record of all) {
|
|
97
125
|
const events = record?.data?.events;
|
|
98
126
|
if (!Array.isArray(events) || !events.length) continue;
|
|
99
127
|
const note = noteAddressOf(record);
|
|
100
128
|
if (!note) continue;
|
|
101
129
|
const resolved =
|
|
102
130
|
Array.isArray(record.resolvedDates?.events) ? record.resolvedDates.events : [];
|
|
131
|
+
const calendar = record.data?.calendar ? String(record.data.calendar) : world;
|
|
103
132
|
events.forEach((entry, position) => {
|
|
104
133
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return;
|
|
105
134
|
const row = {
|
|
@@ -119,6 +148,12 @@ export function eventRows(records) {
|
|
|
119
148
|
row.whenYear = when.year;
|
|
120
149
|
row.untilSort = until.sort;
|
|
121
150
|
row.untilYear = until.year;
|
|
151
|
+
for (const key of DATE_KEYS) {
|
|
152
|
+
row[`${key}Text`] =
|
|
153
|
+
row[key] === null ?
|
|
154
|
+
null
|
|
155
|
+
: printEventDateIn(row[key], resolved[position]?.[key], dates, calendar);
|
|
156
|
+
}
|
|
122
157
|
rows.push(row);
|
|
123
158
|
});
|
|
124
159
|
}
|
package/engine/event-views.mjs
CHANGED
|
@@ -65,8 +65,9 @@ const BY_DATE = "ORDER BY e.whenSort NULLS LAST, e.address";
|
|
|
65
65
|
*
|
|
66
66
|
* @type {ReadonlyArray<Readonly<{slug: string, heading: string,
|
|
67
67
|
* applies: (record: object) => boolean,
|
|
68
|
-
* query: (self: string[], shape: CorpusShape) => {fence: string, own?: string}|null,
|
|
69
|
-
* owners?: (db: object, selves: string[][], shape: CorpusShape) => Promise<Set<string
|
|
68
|
+
* query: (self: string[], shape: CorpusShape, columns?: {felt?: boolean}) => {fence: string, own?: string}|null,
|
|
69
|
+
* owners?: (db: object, selves: string[][], shape: CorpusShape) => Promise<Set<string>>,
|
|
70
|
+
* felt?: (db: object, selves: string[][], shape: CorpusShape) => Promise<Set<string>>}>>}
|
|
70
71
|
*/
|
|
71
72
|
export const EVENT_VIEWS = Object.freeze([
|
|
72
73
|
Object.freeze({
|
|
@@ -75,6 +76,7 @@ export const EVENT_VIEWS = Object.freeze([
|
|
|
75
76
|
applies: (record) => record.type === "place",
|
|
76
77
|
query: chronologyQuery,
|
|
77
78
|
owners: chronologyOwners,
|
|
79
|
+
felt: chronologyFeltOwners,
|
|
78
80
|
}),
|
|
79
81
|
Object.freeze({
|
|
80
82
|
slug: "events",
|
|
@@ -91,8 +93,8 @@ export const EVENT_VIEWS = Object.freeze([
|
|
|
91
93
|
return {
|
|
92
94
|
fence: [
|
|
93
95
|
"SELECT e.note AS _ref,",
|
|
96
|
+
' e.whenText AS "When",',
|
|
94
97
|
' e.summary AS "What happened",',
|
|
95
|
-
' e."when" AS "When",',
|
|
96
98
|
` array_to_string(list_transform(${named}, w -> w.role), ', ') AS "Role"`,
|
|
97
99
|
"FROM events e",
|
|
98
100
|
`WHERE len(${named}) > 0`,
|
|
@@ -120,8 +122,8 @@ export const EVENT_VIEWS = Object.freeze([
|
|
|
120
122
|
return {
|
|
121
123
|
fence: [
|
|
122
124
|
"SELECT e.note AS _ref,",
|
|
125
|
+
' e.whenText AS "When",',
|
|
123
126
|
' e.summary AS "Event",',
|
|
124
|
-
' e."when" AS "When",',
|
|
125
127
|
' e.account.says AS "What they say",',
|
|
126
128
|
' e.account.agrees AS "Agrees"',
|
|
127
129
|
"FROM (SELECT *, unnest(accounts) AS account FROM events) e",
|
|
@@ -151,8 +153,8 @@ export const EVENT_VIEWS = Object.freeze([
|
|
|
151
153
|
return {
|
|
152
154
|
fence: [
|
|
153
155
|
"SELECT e.note AS _ref,",
|
|
156
|
+
' e.whenText AS "When",',
|
|
154
157
|
' e.summary AS "What followed",',
|
|
155
|
-
' e."when" AS "When",',
|
|
156
158
|
` array_to_string(list_transform(${edges}, f -> f.how), ', ') AS "How"`,
|
|
157
159
|
"FROM events e",
|
|
158
160
|
`WHERE len(${edges}) > 0`,
|
|
@@ -237,6 +239,36 @@ function chronologyOwnRows(shape) {
|
|
|
237
239
|
* @returns {Promise<Set<string>>} The roots with at least one own row.
|
|
238
240
|
*/
|
|
239
241
|
async function chronologyOwners(db, selves, shape) {
|
|
242
|
+
return chronologyRoots(db, selves, shape, chronologyOwnRows(shape));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Which places have a row felt there, asked of every place in one query. A
|
|
247
|
+
* chronology prints its `Felt here` column only for these.
|
|
248
|
+
*
|
|
249
|
+
* @param {object} db - From {@link module:engine/sql-tables.openNotesDatabase}.
|
|
250
|
+
* @param {string[][]} selves - Each place's Address, in each form.
|
|
251
|
+
* @param {CorpusShape} shape - What the corpus states.
|
|
252
|
+
* @returns {Promise<Set<string>>} The roots with at least one felt row.
|
|
253
|
+
*/
|
|
254
|
+
async function chronologyFeltOwners(db, selves, shape) {
|
|
255
|
+
if (!shape.reach) return new Set();
|
|
256
|
+
return chronologyRoots(db, selves, shape, [
|
|
257
|
+
`len(list_filter(${chronologyFelt()}, r -> coalesce(r.how, '') <> '')) > 0`,
|
|
258
|
+
]);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The roots among `selves` for which an event satisfies one of `conditions`,
|
|
263
|
+
* over the places at or below each root.
|
|
264
|
+
*
|
|
265
|
+
* @param {object} db - From {@link module:engine/sql-tables.openNotesDatabase}.
|
|
266
|
+
* @param {string[][]} selves - Each place's Address, in each form.
|
|
267
|
+
* @param {CorpusShape} shape - What the corpus states.
|
|
268
|
+
* @param {string[]} conditions - Alternatives over `e` and `here.places`.
|
|
269
|
+
* @returns {Promise<Set<string>>} The roots with at least one such event.
|
|
270
|
+
*/
|
|
271
|
+
async function chronologyRoots(db, selves, shape, conditions) {
|
|
240
272
|
const values = [...new Set(selves.map((self) => self[0]))]
|
|
241
273
|
.map((root) => `(${literal(root)})`)
|
|
242
274
|
.join(", ");
|
|
@@ -258,7 +290,7 @@ async function chronologyOwners(db, selves, shape) {
|
|
|
258
290
|
"here AS (SELECT root, list(place) AS places FROM below GROUP BY root)",
|
|
259
291
|
"SELECT DISTINCT here.root AS root",
|
|
260
292
|
"FROM events e, here",
|
|
261
|
-
`WHERE ${
|
|
293
|
+
`WHERE ${conditions.join(" OR ")}`,
|
|
262
294
|
].join("\n"),
|
|
263
295
|
);
|
|
264
296
|
return new Set(rows.map((row) => String(row.root)));
|
|
@@ -275,10 +307,12 @@ async function chronologyOwners(db, selves, shape) {
|
|
|
275
307
|
*
|
|
276
308
|
* @param {string[]} self - The place's Address, in each form.
|
|
277
309
|
* @param {CorpusShape} shape - What the corpus states.
|
|
310
|
+
* @param {{felt?: boolean}} [columns] - `felt: false` when no row of this
|
|
311
|
+
* place is felt there, which leaves the `Felt here` column out.
|
|
278
312
|
* @returns {{fence: string, own: string}|null} The query, and the one that
|
|
279
313
|
* counts the place's own rows.
|
|
280
314
|
*/
|
|
281
|
-
function chronologyQuery(self, shape) {
|
|
315
|
+
function chronologyQuery(self, shape, columns = {}) {
|
|
282
316
|
if (!shape.locus && !shape.reach) return null;
|
|
283
317
|
const below =
|
|
284
318
|
shape.parents ?
|
|
@@ -298,11 +332,13 @@ function chronologyQuery(self, shape) {
|
|
|
298
332
|
...below,
|
|
299
333
|
"here AS (SELECT list(place) AS places FROM below)",
|
|
300
334
|
"SELECT e.note AS _ref,",
|
|
301
|
-
' e.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
335
|
+
' e.whenText AS "When",',
|
|
336
|
+
shape.reach && columns.felt !== false ?
|
|
337
|
+
' e.summary AS "What happened",'
|
|
338
|
+
: ' e.summary AS "What happened"',
|
|
339
|
+
...(shape.reach && columns.felt !== false ?
|
|
340
|
+
[` array_to_string(list_transform(${felt}, r -> r.how), '; ') AS "Felt here"`]
|
|
341
|
+
: []),
|
|
306
342
|
"FROM events e, here",
|
|
307
343
|
];
|
|
308
344
|
const context = shape.depth ? ["e.depth = 'world'"] : [];
|
|
@@ -394,6 +430,7 @@ export async function eventViewSections(db, records, fileOf, only) {
|
|
|
394
430
|
// A view that can say which notes have rows in one query is asked once, over
|
|
395
431
|
// every note it will be given, and the per-note count is skipped.
|
|
396
432
|
const owned = new Map();
|
|
433
|
+
const felt = new Map();
|
|
397
434
|
for (const view of EVENT_VIEWS) {
|
|
398
435
|
if (!view.owners) continue;
|
|
399
436
|
const selves = [];
|
|
@@ -404,7 +441,10 @@ export async function eventViewSections(db, records, fileOf, only) {
|
|
|
404
441
|
const self = selfForms(record);
|
|
405
442
|
if (view.query(self, shape)) selves.push(self);
|
|
406
443
|
}
|
|
407
|
-
if (selves.length)
|
|
444
|
+
if (selves.length) {
|
|
445
|
+
owned.set(view.slug, await view.owners(db, selves, shape));
|
|
446
|
+
if (view.felt) felt.set(view.slug, await view.felt(db, selves, shape));
|
|
447
|
+
}
|
|
408
448
|
}
|
|
409
449
|
for (const record of all) {
|
|
410
450
|
if (!record?.address || record.documents || !record.shortcode) continue;
|
|
@@ -413,7 +453,11 @@ export async function eventViewSections(db, records, fileOf, only) {
|
|
|
413
453
|
const sections = new Map();
|
|
414
454
|
for (const view of EVENT_VIEWS) {
|
|
415
455
|
if (!view.applies(record) || authoredSection(record, view.slug)) continue;
|
|
416
|
-
const query = view.query(
|
|
456
|
+
const query = view.query(
|
|
457
|
+
self,
|
|
458
|
+
shape,
|
|
459
|
+
felt.has(view.slug) ? { felt: felt.get(view.slug).has(self[0]) } : undefined,
|
|
460
|
+
);
|
|
417
461
|
if (!query) continue;
|
|
418
462
|
const has =
|
|
419
463
|
owned.has(view.slug) ?
|
|
@@ -1285,6 +1285,17 @@ export function lintNote(
|
|
|
1285
1285
|
});
|
|
1286
1286
|
}
|
|
1287
1287
|
}
|
|
1288
|
+
if (
|
|
1289
|
+
Object.hasOwn(name, "sort") &&
|
|
1290
|
+
(typeof name.sort !== "string" || !name.sort.trim())
|
|
1291
|
+
) {
|
|
1292
|
+
findings.push({
|
|
1293
|
+
file: note.file,
|
|
1294
|
+
...nameAt(["sort"]),
|
|
1295
|
+
severity: "error",
|
|
1296
|
+
message: "`name.sort` must be a nonempty string",
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1288
1299
|
for (const key of CHARACTER_NAME_KEYS) {
|
|
1289
1300
|
if (!Object.hasOwn(name, key) || !allowed.has(key)) continue;
|
|
1290
1301
|
if (typeof name[key] === "string" && name[key].trim()) continue;
|
|
@@ -41,6 +41,13 @@
|
|
|
41
41
|
* @module
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The anchor of the Chronology section, which the book sets across the page.
|
|
46
|
+
*
|
|
47
|
+
* @type {string}
|
|
48
|
+
*/
|
|
49
|
+
export const CHRONOLOGY_SLUG = "chronology";
|
|
50
|
+
|
|
44
51
|
/**
|
|
45
52
|
* Every generated section, in the order it follows the author's text: where a
|
|
46
53
|
* place sits and who governs it, then what happened, then what was written
|
|
@@ -53,7 +60,7 @@ export const GENERATED_SECTIONS = Object.freeze(
|
|
|
53
60
|
{ slug: "within", heading: "Within" },
|
|
54
61
|
{ slug: "governedby", heading: "Governed by" },
|
|
55
62
|
{ slug: "governedplaces", heading: "Governed places" },
|
|
56
|
-
{ slug:
|
|
63
|
+
{ slug: CHRONOLOGY_SLUG, heading: "Chronology" },
|
|
57
64
|
{ slug: "events", heading: "Events" },
|
|
58
65
|
{ slug: "accounts", heading: "Accounts" },
|
|
59
66
|
{ slug: "followed", heading: "What followed" },
|
package/engine/holdings.mjs
CHANGED
|
@@ -24,13 +24,14 @@ import { isAddressTuple, ownDocumentSystem, parseAddress, renderAddress } from "
|
|
|
24
24
|
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
25
25
|
import { readCanonicalKey } from "./content-address.mjs";
|
|
26
26
|
|
|
27
|
-
/** @typedef {{title:string,url?:string,address:string,type:string,subType?:string}} HoldingsEntry */
|
|
27
|
+
/** @typedef {{title:string,sort?:string,url?:string,address:string,type:string,subType?:string}} HoldingsEntry */
|
|
28
28
|
/**
|
|
29
29
|
* @typedef {object} HoldingsNode
|
|
30
30
|
* @property {string} shortcode
|
|
31
31
|
* @property {"place"|"affiliation"} type
|
|
32
32
|
* @property {string} [subType]
|
|
33
33
|
* @property {string} title
|
|
34
|
+
* @property {string} [sort] The note's `name.sort`; the list files by it in place of `title`.
|
|
34
35
|
* @property {string} [url]
|
|
35
36
|
* @property {string} [package] Owning content package.
|
|
36
37
|
* @property {string} [system] Own document system.
|
|
@@ -108,6 +109,7 @@ function listed(node) {
|
|
|
108
109
|
function entryOf(node) {
|
|
109
110
|
return {
|
|
110
111
|
title: node.title,
|
|
112
|
+
...(node.sort ? { sort: node.sort } : {}),
|
|
111
113
|
...(listed(node) ? { url: node.url } : {}),
|
|
112
114
|
address: ownKey(node),
|
|
113
115
|
type: node.type,
|
|
@@ -118,7 +120,7 @@ function entryOf(node) {
|
|
|
118
120
|
function bySubTypeThenTitle(a, b) {
|
|
119
121
|
return (
|
|
120
122
|
(a.subType ?? "").localeCompare(b.subType ?? "", "en") ||
|
|
121
|
-
a.title.localeCompare(b.title, "en")
|
|
123
|
+
(a.sort ?? a.title).localeCompare(b.sort ?? b.title, "en")
|
|
122
124
|
);
|
|
123
125
|
}
|
|
124
126
|
|
|
@@ -141,6 +143,7 @@ export function holdingsNode(
|
|
|
141
143
|
type,
|
|
142
144
|
subType: fm.subType == null ? undefined : String(fm.subType),
|
|
143
145
|
title,
|
|
146
|
+
...(typeof fm.name?.sort === "string" && fm.name.sort ? { sort: fm.name.sort } : {}),
|
|
144
147
|
url,
|
|
145
148
|
...(pkg ? { package: pkg } : {}),
|
|
146
149
|
...(system ? { system } : {}),
|
|
@@ -173,6 +176,9 @@ export function foreignHoldingsNodes(foreignIndex) {
|
|
|
173
176
|
type,
|
|
174
177
|
subType: entry.subType == null ? undefined : String(entry.subType),
|
|
175
178
|
title: String(entry.name ?? tuple.shortcode),
|
|
179
|
+
...(typeof entry.nameSort === "string" && entry.nameSort ?
|
|
180
|
+
{ sort: entry.nameSort }
|
|
181
|
+
: {}),
|
|
176
182
|
url: entry.url,
|
|
177
183
|
parents:
|
|
178
184
|
type === "place" ?
|
|
@@ -296,9 +296,14 @@ function entryToTypst(entry, link) {
|
|
|
296
296
|
}
|
|
297
297
|
|
|
298
298
|
/**
|
|
299
|
-
* One section as Typst
|
|
299
|
+
* One section as Typst.
|
|
300
300
|
*
|
|
301
|
-
*
|
|
301
|
+
* A stat grid is whole and unbreakable. A section of rows or run-in lists may
|
|
302
|
+
* break between its rows, so a section taller than the space left on a page
|
|
303
|
+
* continues on the next one instead of running past the page's foot; each row
|
|
304
|
+
* stays whole, and the section's label and `lead` stay with its first row.
|
|
305
|
+
*
|
|
306
|
+
* `lead` rides **inside** the section rather than above it. A panel breaks
|
|
302
307
|
* between its sections, and a title sitting outside the first one is a break
|
|
303
308
|
* the panel is allowed to take — which leaves the heading and a rule stranded
|
|
304
309
|
* at the foot of a page with nothing under them.
|
|
@@ -309,8 +314,9 @@ function entryToTypst(entry, link) {
|
|
|
309
314
|
* @returns {string} Typst markup.
|
|
310
315
|
*/
|
|
311
316
|
function sectionToTypst(section, link, lead = "") {
|
|
312
|
-
const
|
|
313
|
-
if (section.label)
|
|
317
|
+
const head = lead ? [lead] : [];
|
|
318
|
+
if (section.label) head.push(`#infobox-head[${escapeTypst(section.label)}]`);
|
|
319
|
+
const body = [];
|
|
314
320
|
|
|
315
321
|
if (section.layout === "rows") {
|
|
316
322
|
for (const row of section.rows ?? []) {
|
|
@@ -346,7 +352,12 @@ function sectionToTypst(section, link, lead = "") {
|
|
|
346
352
|
}
|
|
347
353
|
}
|
|
348
354
|
|
|
349
|
-
|
|
355
|
+
if (section.layout === "grid" || body.length < 2)
|
|
356
|
+
return `#block(breakable: false)[\n${[...head, ...body].join("\n")}\n]`;
|
|
357
|
+
const [first, ...rest] = body;
|
|
358
|
+
const kept = `#block(breakable: false)[\n${[...head, first].join("\n")}\n]`;
|
|
359
|
+
const rows = rest.map((row) => `#block(breakable: false)[${row}]`);
|
|
360
|
+
return `#block(breakable: true)[\n${[kept, ...rows].join("\n")}\n]`;
|
|
350
361
|
}
|
|
351
362
|
|
|
352
363
|
/**
|
package/engine/infobox.mjs
CHANGED
|
@@ -593,6 +593,27 @@ export function valueKindOf(field, value) {
|
|
|
593
593
|
return Array.isArray(value) ? "list" : "text";
|
|
594
594
|
}
|
|
595
595
|
|
|
596
|
+
/**
|
|
597
|
+
* A number as a reader reads it in a profile: under a million in full with
|
|
598
|
+
* thousands separators (`850`, `12,500`), and from a million up in words with
|
|
599
|
+
* at most one decimal (`11 million`, `1.2 million`, `3 billion`). Every figure
|
|
600
|
+
* the setting gives at that scale is an estimate, and the words say so.
|
|
601
|
+
* Decimals below a million keep the digits they were written with.
|
|
602
|
+
*
|
|
603
|
+
* @param {number} value - The number.
|
|
604
|
+
* @returns {string} The text the row shows.
|
|
605
|
+
*/
|
|
606
|
+
export function formatQuantity(value) {
|
|
607
|
+
const n = Number(value);
|
|
608
|
+
if (!Number.isFinite(n)) return String(value);
|
|
609
|
+
const size = Math.abs(n);
|
|
610
|
+
if (size < 1e6) return n.toLocaleString("en-US", { maximumFractionDigits: 20 });
|
|
611
|
+
// A figure that rounds up to a thousand million is a billion.
|
|
612
|
+
const [scale, word] = size < 999.95e6 ? [1e6, "million"] : [1e9, "billion"];
|
|
613
|
+
const scaled = Math.round((n / scale) * 10) / 10;
|
|
614
|
+
return `${scaled.toLocaleString("en-US", { maximumFractionDigits: 1 })} ${word}`;
|
|
615
|
+
}
|
|
616
|
+
|
|
596
617
|
/**
|
|
597
618
|
* Build one row's value, resolving whatever the kind says is a reference.
|
|
598
619
|
*
|
|
@@ -616,7 +637,7 @@ function rowValue(kind, raw, resolve, hint, verbatim = false) {
|
|
|
616
637
|
if (kind === "list") {
|
|
617
638
|
return (Array.isArray(raw) ? raw : [raw]).filter(hasValue).map((v) => shown(v));
|
|
618
639
|
}
|
|
619
|
-
if (kind === "number") return raw;
|
|
640
|
+
if (kind === "number") return formatQuantity(raw);
|
|
620
641
|
return shown(raw);
|
|
621
642
|
}
|
|
622
643
|
|
|
@@ -904,7 +925,7 @@ function declaredKeyRows(fields, raw) {
|
|
|
904
925
|
(inner.kind === "number" || inner.kind === "integer") &&
|
|
905
926
|
Number.isFinite(Number(value))
|
|
906
927
|
) {
|
|
907
|
-
rows.push({ label, kind: "number", value: Number(value) });
|
|
928
|
+
rows.push({ label, kind: "number", value: formatQuantity(Number(value)) });
|
|
908
929
|
} else {
|
|
909
930
|
rows.push({ label, kind: "text", value: shown(value) });
|
|
910
931
|
}
|
|
@@ -31,11 +31,12 @@ import { AddressLink } from "./address-values.mjs";
|
|
|
31
31
|
import { readCanonicalKey } from "./content-address.mjs";
|
|
32
32
|
import { LITERATURE_SUBTYPE } from "./literature-notes.mjs";
|
|
33
33
|
|
|
34
|
-
/** @typedef {{title: string, url?: string, address: string, form?: string}} WorkEntry */
|
|
34
|
+
/** @typedef {{title: string, sort?: string, url?: string, address: string, form?: string}} WorkEntry */
|
|
35
35
|
/**
|
|
36
36
|
* @typedef {object} WorksNode
|
|
37
37
|
* @property {string} key Own Address, normalized to the `note` system.
|
|
38
38
|
* @property {string} title
|
|
39
|
+
* @property {string} [sort] The note's `name.sort`; the list files by it in place of `title`.
|
|
39
40
|
* @property {string} [url] Absent for a note that publishes no page.
|
|
40
41
|
* @property {string} [package] Owning content package.
|
|
41
42
|
* @property {string} [form] A work's `data.form`.
|
|
@@ -79,6 +80,7 @@ export function worksNode(fm, { title, url, package: pkg }) {
|
|
|
79
80
|
return {
|
|
80
81
|
key: renderAddress({ package: pkg ?? "local", system: "note", type, shortcode }),
|
|
81
82
|
title,
|
|
83
|
+
...(typeof fm.name?.sort === "string" && fm.name.sort ? { sort: fm.name.sort } : {}),
|
|
82
84
|
...(url ? { url } : {}),
|
|
83
85
|
...(pkg ? { package: pkg } : {}),
|
|
84
86
|
...(work && typeof data.form === "string" && data.form ? { form: data.form } : {}),
|
|
@@ -107,6 +109,9 @@ export function foreignWorksNodes(foreignIndex) {
|
|
|
107
109
|
out.push({
|
|
108
110
|
key: renderAddress({ ...tuple, system: "note" }),
|
|
109
111
|
title: String(entry.name ?? tuple.shortcode),
|
|
112
|
+
...(typeof entry.nameSort === "string" && entry.nameSort ?
|
|
113
|
+
{ sort: entry.nameSort }
|
|
114
|
+
: {}),
|
|
110
115
|
...(entry.url ? { url: entry.url } : {}),
|
|
111
116
|
package: tuple.package,
|
|
112
117
|
...(typeof entry.form === "string" && entry.form ? { form: entry.form } : {}),
|
|
@@ -149,6 +154,7 @@ export function worksPages(nodes, { types }) {
|
|
|
149
154
|
if (!block) lists.set(target.url, (block = { works: [] }));
|
|
150
155
|
block.works.push({
|
|
151
156
|
title: work.title,
|
|
157
|
+
...(work.sort ? { sort: work.sort } : {}),
|
|
152
158
|
...(work.url ? { url: work.url } : {}),
|
|
153
159
|
address: work.key,
|
|
154
160
|
...(work.form ? { form: work.form } : {}),
|
|
@@ -156,6 +162,6 @@ export function worksPages(nodes, { types }) {
|
|
|
156
162
|
}
|
|
157
163
|
}
|
|
158
164
|
for (const block of lists.values())
|
|
159
|
-
block.works.sort((a, b) => a.title.localeCompare(b.title, "en"));
|
|
165
|
+
block.works.sort((a, b) => (a.sort ?? a.title).localeCompare(b.sort ?? b.title, "en"));
|
|
160
166
|
return lists;
|
|
161
167
|
}
|
|
@@ -445,6 +445,9 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
|
|
|
445
445
|
const foundry = record.foundry?.[parts.system];
|
|
446
446
|
index.set(key, {
|
|
447
447
|
name: record.name?.full ?? record.name,
|
|
448
|
+
...(typeof record.name?.sort === "string" && record.name.sort ?
|
|
449
|
+
{ nameSort: record.name.sort }
|
|
450
|
+
: {}),
|
|
448
451
|
aliases: record.name?.aliases,
|
|
449
452
|
// Absent where the package publishes no page for it. A consumer
|
|
450
453
|
// must tolerate that rather than invent an href, exactly as it
|
|
@@ -0,0 +1,91 @@
|
|
|
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 key a name-ordered list files a note under, and the ASCII fold ordering
|
|
16
|
+
* compares it on. A leaf module, so every list that orders notes by name can
|
|
17
|
+
* read the key without importing the content index.
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import unidecode from "unidecode";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A note's display name reduced to printable 7-bit ASCII.
|
|
26
|
+
*
|
|
27
|
+
* Content names carry the setting's orthography — `Kûrbúl Helm`, `Hârn`,
|
|
28
|
+
* `Kèthîra` — and nobody types them. A reader searching the index, or an editor
|
|
29
|
+
* completing a wikilink, needs a form that matches what a keyboard produces, so
|
|
30
|
+
* the record states one rather than leaving every consumer to invent it (and to
|
|
31
|
+
* invent a *different* one, which is how two searches over the same data come
|
|
32
|
+
* to disagree).
|
|
33
|
+
*
|
|
34
|
+
* **Transliterated, not stripped.** `unidecode` — the same table
|
|
35
|
+
* {@link slugify} already runs, so an ASCII name and a slug can never disagree
|
|
36
|
+
* about a character — carries a letter across rather than deleting it:
|
|
37
|
+
* diacritics fold (`â`→`a`, `è`→`e`), ligatures expand (`æ`→`ae`, `Œ`→`OE`,
|
|
38
|
+
* `ß`→`ss`), the runic letters spell out (`þ`→`th`, `Þ`→`Th`, `ð`→`d`), and
|
|
39
|
+
* even a vulgar fraction becomes readable (`¾`→`3/4`). Deleting them instead
|
|
40
|
+
* would collapse `Kûrbúl` to `Krbl`, which is worse than the original.
|
|
41
|
+
*
|
|
42
|
+
* Anything still outside printable ASCII after that becomes a space, and runs
|
|
43
|
+
* of whitespace collapse — a space rather than nothing, so a character that
|
|
44
|
+
* transliterates away cannot silently weld two words together.
|
|
45
|
+
*
|
|
46
|
+
* The value is emitted even when it equals the name, so a consumer matching on
|
|
47
|
+
* it never has to branch on whether the name happened to be ASCII already.
|
|
48
|
+
*
|
|
49
|
+
* @param {unknown} name - The note's `name.full`.
|
|
50
|
+
* @returns {string|null} The ASCII form, or `null` when there is no name, or
|
|
51
|
+
* nothing printable survives.
|
|
52
|
+
*/
|
|
53
|
+
export function asciiName(name) {
|
|
54
|
+
if (typeof name !== "string") return null;
|
|
55
|
+
const folded = unidecode(name)
|
|
56
|
+
.replace(/[^\x20-\x7E]/g, " ")
|
|
57
|
+
.replace(/\s+/g, " ")
|
|
58
|
+
.trim();
|
|
59
|
+
return folded === "" ? null : folded;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The name a note files under: its `name.sort` when it declares one, otherwise
|
|
64
|
+
* its `name.full`.
|
|
65
|
+
*
|
|
66
|
+
* @param {unknown} record - An index record or a note's frontmatter.
|
|
67
|
+
* @returns {string|undefined} The unfolded key, or `undefined` for a record
|
|
68
|
+
* with no name.
|
|
69
|
+
*/
|
|
70
|
+
export function nameSortOf(record) {
|
|
71
|
+
const name = /** @type {any} */ (record)?.name;
|
|
72
|
+
for (const candidate of [name?.sort, name?.full]) {
|
|
73
|
+
if (typeof candidate === "string" && candidate.trim()) return candidate;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The key a name-ordered list compares notes on: {@link nameSortOf} folded to
|
|
80
|
+
* printable ASCII by {@link asciiName}, so an accented sort key files beside
|
|
81
|
+
* its base letters. Case is left for the caller, which decides whether its
|
|
82
|
+
* comparison ignores it.
|
|
83
|
+
*
|
|
84
|
+
* @param {unknown} record - An index record or a note's frontmatter.
|
|
85
|
+
* @returns {string} The key, or "" for a record with no name.
|
|
86
|
+
*/
|
|
87
|
+
export function nameSortKey(record) {
|
|
88
|
+
const key = nameSortOf(record);
|
|
89
|
+
if (key === undefined) return "";
|
|
90
|
+
return asciiName(key) ?? key;
|
|
91
|
+
}
|
|
@@ -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",
|
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 ?? "");
|