@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
@@ -18,7 +18,7 @@ These are the only keys a note writes at its top level, in the order the formatt
18
18
  | Key | Meaning |
19
19
  | ------------- | ----------------------------------------------------------------------------------------- |
20
20
  | `shortcode` | The note's own address segment, unique within its type. |
21
- | `name` | The display names — a required `full`, and any `aliases`. |
21
+ | `name` | The display names — a required `full`, and any `aliases` and `sort`. |
22
22
  | `type` | What the note is about, which decides its vocabulary and its document. |
23
23
  | `subType` | The type's own genre, where it declares one. |
24
24
  | `description` | The short page summary. |
@@ -32,14 +32,15 @@ These are the only keys a note writes at its top level, in the order the formatt
32
32
 
33
33
  These fields are accepted by every note type. A field's value can still be irrelevant to a particular output; the build reports that where it can. `data.icon` is not shared: it is listed under each type whose document carries art.
34
34
 
35
- | Field | Shape | Meaning |
36
- | ----------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
37
- | `data.seeAlso` | list of `{ address, gloss? }` | Deliberate cross-references in reading order, with optional glosses; shown on the site, in journals and in books. |
38
- | `data.id` | string | A fixed document identity when the derived identity is unsuitable. |
39
- | `data.pack` | string | The shared compendium route, with a system block overriding it. |
40
- | `data.packFolder` | an Address, or a map of Addresses keyed by pack | The shared compendium folder Address, with a system block overriding it. |
41
- | `data.harnworld` | `{ realm?, ritual? }` | HârnWorld source details shared by every system — `realm` and `ritual`. |
42
- | `data.banner` | an Address | The page's hero image — an `image` address, cut to 1792×768, or `none` to decline a hero image. Reaches no compiled document. |
35
+ | Field | Shape | Meaning |
36
+ | ----------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
37
+ | `data.seeAlso` | list of `{ address, gloss? }` | Deliberate cross-references in reading order, with optional glosses; shown on the site, in journals and in books. |
38
+ | `data.calendar` | an Address | The calendar this note's dates print in, as a calendar lore note. Absent, the note takes its culture's calendar, then — for a place — its parents', then the configured `defaultCalendar`. |
39
+ | `data.id` | string | A fixed document identity when the derived identity is unsuitable. |
40
+ | `data.pack` | string | The shared compendium route, with a system block overriding it. |
41
+ | `data.packFolder` | an Address, or a map of Addresses keyed by pack | The shared compendium folder Address, with a system block overriding it. |
42
+ | `data.harnworld` | `{ realm?, ritual? }` | HârnWorld source details shared by every system — `realm` and `ritual`. |
43
+ | `data.banner` | an Address | The page's hero image — an `image` address, cut to 1792×768, or `none` to decline a hero image. Reaches no compiled document. |
43
44
 
44
45
  **Inner keys** — a key a field's value does not declare is an error at its own line and column.
45
46
 
@@ -71,7 +72,6 @@ These fields are accepted by every note type. A field's value can still be irrel
71
72
  | `data.gender` | string | One of `female`, `male`, `nonbinary`, `none` or `other`. `none` says the being has no gender; an absent field says its gender is unrecorded. See [a being's look](format-details.md#a-beings-look) for what each word means. |
72
73
  | `data.species` | an Address | The being's species, as a lore note. |
73
74
  | `data.born` | string | When the being was born — a date, or `unknown` where the birth is unrecorded. Absent, the being was never born. |
74
- | `data.calendar` | an Address | Calendar that displays this being's dates. |
75
75
  | `data.died` | string | When the being died — a date, or `unknown` where the death is unrecorded. Absent, the being is alive. |
76
76
  | `data.age` | string | Age in years, stated only to override what `born` says — `34`, or `~34` for an estimate. Unstated beside a dated `born` it is computed; unstated beside an unknown or absent `born` the age is unknown. |
77
77
  | `data.ageYears` | number | Written by the compiler beside an `age` estimate — the `~` stripped, the magnitude alone. A note never authors this. |
@@ -867,7 +867,6 @@ Lore records in-world knowledge. A `culture` describes a people; a `custom` desc
867
867
  | Field | Shape | Meaning |
868
868
  | -------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
869
869
  | `data.events` | list of event entries | What happened to or at this subject — each entry one dated, attributed event: a founding, a war, a fall, or an occasion that recurs. |
870
- | `data.calendar` | an Address | Calendar that displays this place's dates. |
871
870
  | `data.demonym` | string | What a person from this place is called — a Vylarian. |
872
871
  | `data.purpose` | string | The reason this settlement, site, or structure exists, selected from its placeCharacter tags. |
873
872
  | `data.lore` | list of Addresses | Lore concerning this place — its peoples, its law, its calendar, its history. |
@@ -15,7 +15,6 @@ types:
15
15
  - gender
16
16
  - species
17
17
  - born
18
- - calendar
19
18
  - died
20
19
  - age
21
20
  - ageYears
@@ -315,7 +314,6 @@ types:
315
314
  place:
316
315
  data:
317
316
  - events
318
- - calendar
319
317
  - demonym
320
318
  - purpose
321
319
  - lore
@@ -86,6 +86,7 @@
86
86
  */
87
87
 
88
88
  import { isAddressSegment } from "./address-charset.mjs";
89
+ import { configFileIn, resolveNoteCalendars } from "./note-calendars.mjs";
89
90
  import { cultureInheritance, reportCultureParents } from "./culture-parents.mjs";
90
91
  import {
91
92
  formatDiagnostic,
@@ -108,8 +109,9 @@ import { analyzeNameSpans } from "./names.mjs";
108
109
  import fs from "node:fs";
109
110
  import path from "node:path";
110
111
 
111
- import unidecode from "unidecode";
112
+ import { asciiName } from "./name-sort.mjs";
112
113
 
114
+ export { asciiName };
113
115
  import { loadForeignIndexes, metadataFileName } from "./metadata-index.mjs";
114
116
  import { collectAssetRecords } from "./asset-index.mjs";
115
117
  import { checkForeignAssetBindings } from "./asset-bindings.mjs";
@@ -161,7 +163,7 @@ import { resolveNoteId } from "./note-ids.mjs";
161
163
  // The `package:` refusal, so a note declaring one is reported where it is
162
164
  // rather than as a bare abort.
163
165
  import { assertNoDeclaredPackage } from "./note-package.mjs";
164
- import { loadPackConfig } from "./pack-config.mjs";
166
+ import { CONFIG_FILENAMES, loadPackConfig } from "./pack-config.mjs";
165
167
  // The record accessors, which live apart so that a module the compilers load
166
168
  // can read a record without importing this one and closing a cycle.
167
169
  // Re-exported because this is where callers have always addressed them.
@@ -245,44 +247,6 @@ export function noteAddress(frontmatter, contentPackage) {
245
247
  };
246
248
  }
247
249
 
248
- /**
249
- * A note's display name reduced to printable 7-bit ASCII.
250
- *
251
- * Content names carry the setting's orthography — `Kûrbúl Helm`, `Hârn`,
252
- * `Kèthîra` — and nobody types them. A reader searching the index, or an editor
253
- * completing a wikilink, needs a form that matches what a keyboard produces, so
254
- * the record states one rather than leaving every consumer to invent it (and to
255
- * invent a *different* one, which is how two searches over the same data come
256
- * to disagree).
257
- *
258
- * **Transliterated, not stripped.** `unidecode` — the same table
259
- * {@link slugify} already runs, so an ASCII name and a slug can never disagree
260
- * about a character — carries a letter across rather than deleting it:
261
- * diacritics fold (`â`→`a`, `è`→`e`), ligatures expand (`æ`→`ae`, `Œ`→`OE`,
262
- * `ß`→`ss`), the runic letters spell out (`þ`→`th`, `Þ`→`Th`, `ð`→`d`), and
263
- * even a vulgar fraction becomes readable (`¾`→`3/4`). Deleting them instead
264
- * would collapse `Kûrbúl` to `Krbl`, which is worse than the original.
265
- *
266
- * Anything still outside printable ASCII after that becomes a space, and runs
267
- * of whitespace collapse — a space rather than nothing, so a character that
268
- * transliterates away cannot silently weld two words together.
269
- *
270
- * The value is emitted even when it equals the name, so a consumer matching on
271
- * it never has to branch on whether the name happened to be ASCII already.
272
- *
273
- * @param {unknown} name - The note's `name.full`.
274
- * @returns {string|null} The ASCII form, or `null` when there is no name, or
275
- * nothing printable survives.
276
- */
277
- export function asciiName(name) {
278
- if (typeof name !== "string") return null;
279
- const folded = unidecode(name)
280
- .replace(/[^\x20-\x7E]/g, " ")
281
- .replace(/\s+/g, " ")
282
- .trim();
283
- return folded === "" ? null : folded;
284
- }
285
-
286
250
  /**
287
251
  * A note's `name.aliases` reduced to printable 7-bit ASCII, in order.
288
252
  *
@@ -1099,6 +1063,10 @@ export function resolveCultures(
1099
1063
  * reader receives each as a finding, and the emitter throws once, listing
1100
1064
  * every one.
1101
1065
  * @param {string} [options.currentDate="720.1"] - Setting-wide neutral current date.
1066
+ * @param {string} [options.defaultCalendar] - The calendar of last resort, a
1067
+ * calendar note's shortcode; see {@link module:engine/note-calendars}.
1068
+ * @param {string} [options.configFile] - The configuration file, where an
1069
+ * unknown `defaultCalendar` is reported.
1102
1070
  * @param {(key: string) => ({culture?: unknown}|undefined)} [options.foreignRecord] -
1103
1071
  * Another package's entry for a canonical address, read for a parent that
1104
1072
  * package publishes; see {@link resolveCultures}.
@@ -1116,6 +1084,8 @@ export function collectContentIndex(
1116
1084
  {
1117
1085
  contentPackage,
1118
1086
  currentDate,
1087
+ defaultCalendar,
1088
+ configFile,
1119
1089
  skipDirectories,
1120
1090
  assetsBase,
1121
1091
  manifest,
@@ -1271,7 +1241,17 @@ export function collectContentIndex(
1271
1241
 
1272
1242
  // Every note record exists and every Address in it is a tuple, so a parent
1273
1243
  // is found by its canonical address. Before the sort, which reads nothing
1274
- // the pass writes.
1244
+ // the pass writes. Calendars first: they read the culture a note states,
1245
+ // which the culture pass then replaces with the one it resolves.
1246
+ resolveNoteCalendars(records, {
1247
+ contentPackage,
1248
+ defaultCalendar,
1249
+ configFile,
1250
+ contentBase,
1251
+ foreignRecord,
1252
+ problems,
1253
+ parsed,
1254
+ });
1275
1255
  resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems, parsed });
1276
1256
  const cultureNotes = records
1277
1257
  .filter((record) => isNoteRecord(record) && record.type === "lore")
@@ -1496,6 +1476,8 @@ export function indexRecordsFor({
1496
1476
  return collectContentIndex(tree, {
1497
1477
  contentPackage: resolved.contentPackage,
1498
1478
  currentDate: resolved.currentDate,
1479
+ defaultCalendar: resolved.defaultCalendar ?? undefined,
1480
+ configFile: configFileIn(resolved.rootDir, CONFIG_FILENAMES),
1499
1481
  addressContext: {
1500
1482
  ...noteAddressContext(resolved),
1501
1483
  declaresAddress: dependencyAddresses(resolved),
@@ -239,6 +239,9 @@ export function buildLinkIndex(
239
239
  const body = splitFrontmatter(raw)?.body ?? raw;
240
240
  const type = record.type.toLowerCase();
241
241
  const note = { file: absPath, rel, fm, body, raw, type };
242
+ // The calendar the index resolved, which the reckoning context built
243
+ // over these notes hands to every date printed for this note.
244
+ if (record.resolvedCalendar) note.resolvedCalendar = record.resolvedCalendar;
242
245
  const hierarchyProblems = cultureRecordProblems(record);
243
246
  note.inheritedCultureFields = cultureInheritedFields(record);
244
247
  if (hierarchyProblems)
@@ -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
  *
@@ -25,11 +25,27 @@
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 of the owning note
29
+ * sees them**: printed as the `dateformat` helper prints a date, in the
30
+ * calendar the owning note resolves to — see
31
+ * {@link module:engine/note-calendars} — else in the era the date itself
32
+ * names. A date that does not resolve keeps its authored text.
33
+ *
34
+ * **`whenIn` and `untilIn` are the same dates in every calendar**: a struct
35
+ * keyed by each calendar note's canonical Address, every calendar the corpus
36
+ * holds or any note resolves to, so a query reads a date in the calendar it
37
+ * chooses — `e.whenIn['thalorna-note-lore-commoncal']`. Every row carries every
38
+ * key; a date a calendar cannot print reads there as it would in `whenText`
39
+ * with no calendar, and an absent date is `NULL`. The event views select the
40
+ * key of the page's calendar.
41
+ *
28
42
  * @module
29
43
  */
30
44
 
31
45
  import { ownDocumentSystem, renderAddress } from "./address.mjs";
46
+ import { printEventDateIn } from "./event-fields.mjs";
32
47
  import { EVENT_ENTRY_KEYS } from "./note-event-terms.mjs";
48
+ import { calendarAddress } from "./reckoning-markers.mjs";
33
49
 
34
50
  /** The columns a row carries besides the event's own keys, before them. */
35
51
  const IDENTITY_COLUMNS = Object.freeze(["note", "address"]);
@@ -37,11 +53,19 @@ const IDENTITY_COLUMNS = Object.freeze(["note", "address"]);
37
53
  /** The sort keys a row carries, after the event's own keys. */
38
54
  export const EVENT_SORT_COLUMNS = Object.freeze(["whenSort", "whenYear", "untilSort", "untilYear"]);
39
55
 
56
+ /** The printed dates a row carries, after the sort keys. */
57
+ export const EVENT_TEXT_COLUMNS = Object.freeze(["whenText", "untilText"]);
58
+
59
+ /** The dates a row carries in every calendar, after the printed ones. */
60
+ export const EVENT_CALENDAR_COLUMNS = Object.freeze(["whenIn", "untilIn"]);
61
+
40
62
  /** Every column of the `events` relation, in order. */
41
63
  export const EVENT_COLUMNS = Object.freeze([
42
64
  ...IDENTITY_COLUMNS,
43
65
  ...EVENT_ENTRY_KEYS,
44
66
  ...EVENT_SORT_COLUMNS,
67
+ ...EVENT_TEXT_COLUMNS,
68
+ ...EVENT_CALENDAR_COLUMNS,
45
69
  ]);
46
70
 
47
71
  /** The keys whose authored value is a date, printed as the text it was written as. */
@@ -84,22 +108,62 @@ function axisKeys(date) {
84
108
  };
85
109
  }
86
110
 
111
+ /**
112
+ * Every calendar a set of records can print an event's date in: each calendar
113
+ * note's canonical Address, and every calendar a note resolves to.
114
+ *
115
+ * @param {Iterable<Record<string, any>>} records - Index records, every
116
+ * Address a string.
117
+ * @returns {string[]} The calendars, sorted.
118
+ */
119
+ export function eventCalendars(records) {
120
+ const calendars = new Set();
121
+ for (const record of records) {
122
+ const resolved = calendarAddress(record?.resolvedCalendar);
123
+ if (resolved) calendars.add(resolved);
124
+ if (
125
+ record?.type === "lore" &&
126
+ String(record.subType ?? "").toLowerCase() === "calendar" &&
127
+ record.package &&
128
+ record.shortcode &&
129
+ !record.documents
130
+ )
131
+ calendars.add(
132
+ renderAddress({
133
+ package: String(record.package),
134
+ system: "note",
135
+ type: "lore",
136
+ shortcode: String(record.shortcode),
137
+ }),
138
+ );
139
+ }
140
+ return [...calendars].sort();
141
+ }
142
+
87
143
  /**
88
144
  * The `events` rows of a set of index records.
89
145
  *
90
146
  * @param {Iterable<Record<string, any>>} records - Index records, every
91
147
  * Address a string, already narrowed to the audience being published to.
148
+ * @param {object} [dates] - The reckoning context the dates are printed in.
149
+ * @param {readonly string[]} [calendars] - The calendars `whenIn` and
150
+ * `untilIn` are keyed by: {@link eventCalendars} of every record of the
151
+ * corpus, whatever the audience, so every surface's relation has the same
152
+ * keys. Absent, those of `records`.
92
153
  * @returns {object[]} One row per event entry, in record then entry order.
93
154
  */
94
- export function eventRows(records) {
155
+ export function eventRows(records, dates, calendars) {
95
156
  const rows = [];
96
- for (const record of records) {
157
+ const all = [...records];
158
+ const keys = calendars ?? eventCalendars(all);
159
+ for (const record of all) {
97
160
  const events = record?.data?.events;
98
161
  if (!Array.isArray(events) || !events.length) continue;
99
162
  const note = noteAddressOf(record);
100
163
  if (!note) continue;
101
164
  const resolved =
102
165
  Array.isArray(record.resolvedDates?.events) ? record.resolvedDates.events : [];
166
+ const calendar = calendarAddress(record.resolvedCalendar);
103
167
  events.forEach((entry, position) => {
104
168
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) return;
105
169
  const row = {
@@ -119,6 +183,22 @@ export function eventRows(records) {
119
183
  row.whenYear = when.year;
120
184
  row.untilSort = until.sort;
121
185
  row.untilYear = until.year;
186
+ for (const key of DATE_KEYS) {
187
+ const date = resolved[position]?.[key];
188
+ row[`${key}Text`] =
189
+ row[key] === null ? null : printEventDateIn(row[key], date, dates, calendar);
190
+ row[`${key}In`] =
191
+ keys.length ?
192
+ Object.fromEntries(
193
+ keys.map((each) => [
194
+ each,
195
+ row[key] === null ?
196
+ null
197
+ : printEventDateIn(row[key], date, dates, each),
198
+ ]),
199
+ )
200
+ : null;
201
+ }
122
202
  rows.push(row);
123
203
  });
124
204
  }
@@ -41,6 +41,7 @@
41
41
 
42
42
  import { ownDocumentSystem, renderAddress } from "./address.mjs";
43
43
  import { authoredSection, sectionHeading } from "./generated-sections.mjs";
44
+ import { calendarAddress } from "./reckoning-markers.mjs";
44
45
 
45
46
  /** A literal SQL string. */
46
47
  const literal = (text) => `'${String(text).replace(/'/g, "''")}'`;
@@ -48,6 +49,16 @@ const literal = (text) => `'${String(text).replace(/'/g, "''")}'`;
48
49
  /** A list of SQL string literals, for an `IN (…)`. */
49
50
  const literals = (texts) => texts.map(literal).join(", ");
50
51
 
52
+ /**
53
+ * The `When` column of a view on a page whose dates print in `calendar`: the
54
+ * event's date in that calendar, or — on a page with no calendar — in its
55
+ * owning note's.
56
+ *
57
+ * @param {string} [calendar] - The page's calendar, a canonical Address.
58
+ * @returns {string} The column's expression.
59
+ */
60
+ const whenColumn = (calendar) => (calendar ? `e.whenIn[${literal(calendar)}]` : "e.whenText");
61
+
51
62
  /** The order every view sorts its rows in: by date, undated last. */
52
63
  const BY_DATE = "ORDER BY e.whenSort NULLS LAST, e.address";
53
64
 
@@ -65,8 +76,9 @@ const BY_DATE = "ORDER BY e.whenSort NULLS LAST, e.address";
65
76
  *
66
77
  * @type {ReadonlyArray<Readonly<{slug: string, heading: string,
67
78
  * 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>>}>>}
79
+ * query: (self: string[], shape: CorpusShape, columns?: {felt?: boolean, calendar?: string}) => {fence: string, own?: string}|null,
80
+ * owners?: (db: object, selves: string[][], shape: CorpusShape) => Promise<Set<string>>,
81
+ * felt?: (db: object, selves: string[][], shape: CorpusShape) => Promise<Set<string>>}>>}
70
82
  */
71
83
  export const EVENT_VIEWS = Object.freeze([
72
84
  Object.freeze({
@@ -75,6 +87,7 @@ export const EVENT_VIEWS = Object.freeze([
75
87
  applies: (record) => record.type === "place",
76
88
  query: chronologyQuery,
77
89
  owners: chronologyOwners,
90
+ felt: chronologyFeltOwners,
78
91
  }),
79
92
  Object.freeze({
80
93
  slug: "events",
@@ -85,14 +98,14 @@ export const EVENT_VIEWS = Object.freeze([
85
98
  (record.type === "lore" && record.subType === "culture"),
86
99
  owners: (db, selves, shape) =>
87
100
  namedOwners(db, selves, "SELECT unnest(who).ref AS ref FROM events", shape.who),
88
- query: (self, shape) => {
101
+ query: (self, shape, columns = {}) => {
89
102
  if (!shape.who) return null;
90
103
  const named = `list_filter(e.who, w -> w.ref IN (${literals(self)}))`;
91
104
  return {
92
105
  fence: [
93
106
  "SELECT e.note AS _ref,",
107
+ ` ${whenColumn(columns.calendar)} AS "When",`,
94
108
  ' e.summary AS "What happened",',
95
- ' e."when" AS "When",',
96
109
  ` array_to_string(list_transform(${named}, w -> w.role), ', ') AS "Role"`,
97
110
  "FROM events e",
98
111
  `WHERE len(${named}) > 0`,
@@ -115,13 +128,13 @@ export const EVENT_VIEWS = Object.freeze([
115
128
  'SELECT account."by" AS ref FROM (SELECT unnest(accounts) AS account FROM events)',
116
129
  shape.accounts,
117
130
  ),
118
- query: (self, shape) => {
131
+ query: (self, shape, columns = {}) => {
119
132
  if (!shape.accounts) return null;
120
133
  return {
121
134
  fence: [
122
135
  "SELECT e.note AS _ref,",
136
+ ` ${whenColumn(columns.calendar)} AS "When",`,
123
137
  ' e.summary AS "Event",',
124
- ' e."when" AS "When",',
125
138
  ' e.account.says AS "What they say",',
126
139
  ' e.account.agrees AS "Agrees"',
127
140
  "FROM (SELECT *, unnest(accounts) AS account FROM events) e",
@@ -143,7 +156,7 @@ export const EVENT_VIEWS = Object.freeze([
143
156
  "FROM (SELECT unnest(follows) AS edge FROM events)",
144
157
  shape.follows,
145
158
  ),
146
- query: (self, shape) => {
159
+ query: (self, shape, columns = {}) => {
147
160
  if (!shape.follows) return null;
148
161
  const edges =
149
162
  `list_filter(e.follows, f -> split_part(f.event, '#', 1) ` +
@@ -151,8 +164,8 @@ export const EVENT_VIEWS = Object.freeze([
151
164
  return {
152
165
  fence: [
153
166
  "SELECT e.note AS _ref,",
167
+ ` ${whenColumn(columns.calendar)} AS "When",`,
154
168
  ' e.summary AS "What followed",',
155
- ' e."when" AS "When",',
156
169
  ` array_to_string(list_transform(${edges}, f -> f.how), ', ') AS "How"`,
157
170
  "FROM events e",
158
171
  `WHERE len(${edges}) > 0`,
@@ -237,6 +250,36 @@ function chronologyOwnRows(shape) {
237
250
  * @returns {Promise<Set<string>>} The roots with at least one own row.
238
251
  */
239
252
  async function chronologyOwners(db, selves, shape) {
253
+ return chronologyRoots(db, selves, shape, chronologyOwnRows(shape));
254
+ }
255
+
256
+ /**
257
+ * Which places have a row felt there, asked of every place in one query. A
258
+ * chronology prints its `Felt here` column only for these.
259
+ *
260
+ * @param {object} db - From {@link module:engine/sql-tables.openNotesDatabase}.
261
+ * @param {string[][]} selves - Each place's Address, in each form.
262
+ * @param {CorpusShape} shape - What the corpus states.
263
+ * @returns {Promise<Set<string>>} The roots with at least one felt row.
264
+ */
265
+ async function chronologyFeltOwners(db, selves, shape) {
266
+ if (!shape.reach) return new Set();
267
+ return chronologyRoots(db, selves, shape, [
268
+ `len(list_filter(${chronologyFelt()}, r -> coalesce(r.how, '') <> '')) > 0`,
269
+ ]);
270
+ }
271
+
272
+ /**
273
+ * The roots among `selves` for which an event satisfies one of `conditions`,
274
+ * over the places at or below each root.
275
+ *
276
+ * @param {object} db - From {@link module:engine/sql-tables.openNotesDatabase}.
277
+ * @param {string[][]} selves - Each place's Address, in each form.
278
+ * @param {CorpusShape} shape - What the corpus states.
279
+ * @param {string[]} conditions - Alternatives over `e` and `here.places`.
280
+ * @returns {Promise<Set<string>>} The roots with at least one such event.
281
+ */
282
+ async function chronologyRoots(db, selves, shape, conditions) {
240
283
  const values = [...new Set(selves.map((self) => self[0]))]
241
284
  .map((root) => `(${literal(root)})`)
242
285
  .join(", ");
@@ -258,7 +301,7 @@ async function chronologyOwners(db, selves, shape) {
258
301
  "here AS (SELECT root, list(place) AS places FROM below GROUP BY root)",
259
302
  "SELECT DISTINCT here.root AS root",
260
303
  "FROM events e, here",
261
- `WHERE ${chronologyOwnRows(shape).join(" OR ")}`,
304
+ `WHERE ${conditions.join(" OR ")}`,
262
305
  ].join("\n"),
263
306
  );
264
307
  return new Set(rows.map((row) => String(row.root)));
@@ -275,10 +318,13 @@ async function chronologyOwners(db, selves, shape) {
275
318
  *
276
319
  * @param {string[]} self - The place's Address, in each form.
277
320
  * @param {CorpusShape} shape - What the corpus states.
321
+ * @param {{felt?: boolean, calendar?: string}} [columns] - `felt: false` when
322
+ * no row of this place is felt there, which leaves the `Felt here` column
323
+ * out; `calendar`, the page's calendar, which the `When` column prints in.
278
324
  * @returns {{fence: string, own: string}|null} The query, and the one that
279
325
  * counts the place's own rows.
280
326
  */
281
- function chronologyQuery(self, shape) {
327
+ function chronologyQuery(self, shape, columns = {}) {
282
328
  if (!shape.locus && !shape.reach) return null;
283
329
  const below =
284
330
  shape.parents ?
@@ -298,11 +344,13 @@ function chronologyQuery(self, shape) {
298
344
  ...below,
299
345
  "here AS (SELECT list(place) AS places FROM below)",
300
346
  "SELECT e.note AS _ref,",
301
- ' e.summary AS "What happened",',
302
- ' e."when" AS "When",',
303
- shape.reach ?
304
- ` array_to_string(list_transform(${felt}, r -> r.how), '; ') AS "Felt here"`
305
- : ' NULL AS "Felt here"',
347
+ ` ${whenColumn(columns.calendar)} AS "When",`,
348
+ shape.reach && columns.felt !== false ?
349
+ ' e.summary AS "What happened",'
350
+ : ' e.summary AS "What happened"',
351
+ ...(shape.reach && columns.felt !== false ?
352
+ [` array_to_string(list_transform(${felt}, r -> r.how), '; ') AS "Felt here"`]
353
+ : []),
306
354
  "FROM events e, here",
307
355
  ];
308
356
  const context = shape.depth ? ["e.depth = 'world'"] : [];
@@ -394,6 +442,7 @@ export async function eventViewSections(db, records, fileOf, only) {
394
442
  // A view that can say which notes have rows in one query is asked once, over
395
443
  // every note it will be given, and the per-note count is skipped.
396
444
  const owned = new Map();
445
+ const felt = new Map();
397
446
  for (const view of EVENT_VIEWS) {
398
447
  if (!view.owners) continue;
399
448
  const selves = [];
@@ -404,7 +453,10 @@ export async function eventViewSections(db, records, fileOf, only) {
404
453
  const self = selfForms(record);
405
454
  if (view.query(self, shape)) selves.push(self);
406
455
  }
407
- if (selves.length) owned.set(view.slug, await view.owners(db, selves, shape));
456
+ if (selves.length) {
457
+ owned.set(view.slug, await view.owners(db, selves, shape));
458
+ if (view.felt) felt.set(view.slug, await view.felt(db, selves, shape));
459
+ }
408
460
  }
409
461
  for (const record of all) {
410
462
  if (!record?.address || record.documents || !record.shortcode) continue;
@@ -413,7 +465,12 @@ export async function eventViewSections(db, records, fileOf, only) {
413
465
  const sections = new Map();
414
466
  for (const view of EVENT_VIEWS) {
415
467
  if (!view.applies(record) || authoredSection(record, view.slug)) continue;
416
- const query = view.query(self, shape);
468
+ const query = view.query(self, shape, {
469
+ ...(felt.has(view.slug) ? { felt: felt.get(view.slug).has(self[0]) } : {}),
470
+ ...(calendarAddress(record.resolvedCalendar) ?
471
+ { calendar: calendarAddress(record.resolvedCalendar) }
472
+ : {}),
473
+ });
417
474
  if (!query) continue;
418
475
  const has =
419
476
  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: "chronology", heading: "Chronology" },
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" },