@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/content-config.mjs +26 -0
  3. package/docs/api.md +9 -5
  4. package/docs/authoring/dates-and-calendars.md +29 -1
  5. package/docs/authoring/frontmatter.md +1 -1
  6. package/docs/authoring/links-and-markup.md +7 -7
  7. package/docs/configuration.md +30 -4
  8. package/docs/guides/book.md +12 -12
  9. package/docs/guides/module-project.md +1 -1
  10. package/docs/reference/format-details.md +81 -24
  11. package/docs/reference/note-types.md +10 -11
  12. package/engine/content-format.yaml +0 -2
  13. package/engine/content-index.mjs +23 -41
  14. package/engine/content-links.mjs +3 -0
  15. package/engine/event-fields.mjs +24 -1
  16. package/engine/event-rows.mjs +82 -2
  17. package/engine/event-views.mjs +74 -17
  18. package/engine/frontmatter-lint.mjs +11 -0
  19. package/engine/generated-sections.mjs +8 -1
  20. package/engine/holdings.mjs +8 -2
  21. package/engine/index-records.mjs +1 -0
  22. package/engine/infobox-render.mjs +16 -5
  23. package/engine/infobox.mjs +27 -10
  24. package/engine/literature-works.mjs +8 -2
  25. package/engine/markdown-expressions.mjs +72 -17
  26. package/engine/metadata-index.mjs +3 -0
  27. package/engine/name-sort.mjs +91 -0
  28. package/engine/note-calendars.mjs +314 -0
  29. package/engine/note-frontmatter.mjs +1 -1
  30. package/engine/note-vocabulary.mjs +12 -17
  31. package/engine/page-lists.mjs +3 -2
  32. package/engine/pdf-index.mjs +11 -4
  33. package/engine/pdf-pages.mjs +2 -1
  34. package/engine/pdf-render.mjs +43 -13
  35. package/engine/pdf-toc.mjs +4 -3
  36. package/engine/reckoning-markers.mjs +63 -3
  37. package/engine/related-pages.mjs +12 -2
  38. package/engine/site-build.mjs +3 -0
  39. package/engine/sql-tables.mjs +28 -7
  40. package/package.json +1 -1
  41. package/types/content-config.d.mts +12 -0
  42. package/types/engine/content-index.d.mts +9 -32
  43. package/types/engine/event-fields.d.mts +11 -0
  44. package/types/engine/event-rows.d.mts +19 -1
  45. package/types/engine/event-views.d.mts +8 -3
  46. package/types/engine/generated-sections.d.mts +6 -0
  47. package/types/engine/holdings.d.mts +7 -1
  48. package/types/engine/infobox.d.mts +11 -0
  49. package/types/engine/literature-works.d.mts +5 -0
  50. package/types/engine/name-sort.d.mts +49 -0
  51. package/types/engine/note-calendars.d.mts +60 -0
  52. package/types/engine/pdf-render.d.mts +8 -0
  53. package/types/engine/pdf-toc.d.mts +1 -1
  54. package/types/engine/reckoning-markers.d.mts +32 -1
  55. 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,
@@ -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, folded so a circumflex sorts with its letter. */
199
- const nameKey = (record) => String(record?.nameAscii ?? record?.name?.full ?? "").toLowerCase();
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 ?? "");
@@ -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 "./content-index.mjs";
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?.name?.full ?? "").trim();
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 = byLength.find((one) => name.startsWith(one) && name.slice(one.length).trim());
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));
@@ -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?.name?.full ?? "").trim(),
263
+ name: String(nameSortOf(entry.record) ?? "").trim(),
263
264
  title: entry.title,
264
265
  abbreviation: entry.abbreviation,
265
266
  page,
@@ -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
- if (columns > WIDE_TABLE_COLUMNS)
1170
- return `${caption ? "" : "\n#pagebreak(weak: true)\n"}\n#book-wide[\n${labelled}\n]\n\n`;
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
- out.push(renderLink(child.attrGet?.("href") ?? "", inner, ctx));
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
- if (away.abbreviation) {
1466
- return `${inner} (${escapeTypst(away.abbreviation)}~p${away.page})`;
1467
- }
1468
- return `${inner} (#emph[${escapeTypst(away.title)}], p.~${away.page})`;
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: 1pt, " +
1706
- "rotate(45deg, rect(width: 3pt, height: 3pt, fill: book-accent)))",
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(size: 8pt, fill: book-faint)\n" +
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.
@@ -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
- * `nameAscii` order — the ASCII fold `buildIndexRecord` already derives, so a
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?.nameAscii ?? a?.name?.full ?? "";
570
- const bn = b?.nameAscii ?? b?.name?.full ?? "";
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 ?? "";