@heroiclands/package-build 23.12.5 → 23.12.7

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 (95) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/assets/styles/content-base.css +25 -0
  3. package/bin/content-commands.mjs +264 -3
  4. package/bin/package-build.mjs +25 -0
  5. package/content-config.mjs +98 -0
  6. package/docs/api.md +18 -14
  7. package/docs/authoring/dates-and-calendars.md +29 -1
  8. package/docs/authoring/links-and-markup.md +442 -50
  9. package/docs/commands.md +127 -0
  10. package/docs/configuration.md +90 -3
  11. package/docs/content-format.md +3 -1
  12. package/docs/guides/book.md +11 -0
  13. package/docs/guides/module-project.md +17 -1
  14. package/docs/guides/site.md +19 -0
  15. package/docs/reference/format-details.md +434 -23
  16. package/docs/reference/note-types.md +45 -21
  17. package/engine/base-compiler.mjs +2 -0
  18. package/engine/bundles.mjs +7 -2
  19. package/engine/compile-corpus.mjs +3 -1
  20. package/engine/content-charset.mjs +21 -2
  21. package/engine/content-format.yaml +1 -2
  22. package/engine/content-index.mjs +33 -2
  23. package/engine/content-links.mjs +3 -0
  24. package/engine/content-poetry.mjs +22 -4
  25. package/engine/content-scripts.mjs +1219 -0
  26. package/engine/content-session.mjs +2 -0
  27. package/engine/data-keys.mjs +5 -1
  28. package/engine/dictionary-layout.mjs +18 -12
  29. package/engine/dictionary-rows.mjs +11 -2
  30. package/engine/event-rows.mjs +63 -18
  31. package/engine/event-views.mjs +28 -15
  32. package/engine/helpers.mjs +8 -2
  33. package/engine/index-records.mjs +2 -0
  34. package/engine/infobox.mjs +4 -8
  35. package/engine/journals.mjs +14 -3
  36. package/engine/language-data.mjs +137 -1
  37. package/engine/language-tag.mjs +103 -0
  38. package/engine/language-terms.mjs +16 -4
  39. package/engine/map-lexicon.mjs +762 -0
  40. package/engine/markdown-expressions.mjs +72 -17
  41. package/engine/markup-validation.mjs +17 -3
  42. package/engine/names.mjs +1 -1
  43. package/engine/note-calendars.mjs +314 -0
  44. package/engine/note-vocabulary.mjs +124 -16
  45. package/engine/pdf-build.mjs +28 -10
  46. package/engine/pdf-render.mjs +116 -11
  47. package/engine/reckoning-markers.mjs +63 -3
  48. package/engine/run-rows.mjs +119 -0
  49. package/engine/run-words.mjs +266 -0
  50. package/engine/scenes.mjs +1 -0
  51. package/engine/script-font-delivery.mjs +321 -0
  52. package/engine/script-fonts.mjs +172 -0
  53. package/engine/site-build.mjs +10 -3
  54. package/engine/site-config.mjs +5 -0
  55. package/engine/site-root.mjs +19 -5
  56. package/engine/sql-tables.mjs +120 -4
  57. package/engine/text-maps.mjs +607 -0
  58. package/engine/verb-pattern.mjs +99 -0
  59. package/engine/word-pattern.mjs +27 -0
  60. package/engine/word-rows.mjs +8 -2
  61. package/engine/word-terms.mjs +44 -0
  62. package/hugo-theme/layouts/_default/baseof.html +3 -0
  63. package/hugo-theme/static/css/style.css +10 -0
  64. package/manifest.mjs +9 -1
  65. package/package.json +1 -1
  66. package/types/content-config.d.mts +38 -0
  67. package/types/engine/compile-corpus.d.mts +2 -1
  68. package/types/engine/content-index.d.mts +17 -3
  69. package/types/engine/content-poetry.d.mts +7 -0
  70. package/types/engine/content-scripts.d.mts +226 -0
  71. package/types/engine/data-keys.d.mts +2 -2
  72. package/types/engine/dictionary-rows.d.mts +15 -0
  73. package/types/engine/event-rows.d.mts +16 -1
  74. package/types/engine/event-views.d.mts +2 -1
  75. package/types/engine/helpers.d.mts +5 -1
  76. package/types/engine/journals.d.mts +7 -2
  77. package/types/engine/language-data.d.mts +2 -0
  78. package/types/engine/language-tag.d.mts +8 -0
  79. package/types/engine/language-terms.d.mts +2 -1
  80. package/types/engine/map-lexicon.d.mts +274 -0
  81. package/types/engine/markup-validation.d.mts +7 -2
  82. package/types/engine/note-calendars.d.mts +60 -0
  83. package/types/engine/pdf-build.d.mts +26 -3
  84. package/types/engine/pdf-render.d.mts +17 -2
  85. package/types/engine/reckoning-markers.d.mts +32 -1
  86. package/types/engine/run-rows.d.mts +89 -0
  87. package/types/engine/run-words.d.mts +86 -0
  88. package/types/engine/script-font-delivery.d.mts +134 -0
  89. package/types/engine/script-fonts.d.mts +168 -0
  90. package/types/engine/site-build.d.mts +2 -1
  91. package/types/engine/site-root.d.mts +11 -3
  92. package/types/engine/text-maps.d.mts +175 -0
  93. package/types/engine/verb-pattern.d.mts +65 -0
  94. package/types/engine/word-pattern.d.mts +13 -0
  95. package/types/engine/word-terms.d.mts +6 -0
@@ -15,11 +15,12 @@ import Handlebars from "handlebars";
15
15
 
16
16
  import { isAddressTuple, renderAddress } from "./address.mjs";
17
17
  import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
18
- import { formatDateInCalendar, parseNoteDate } from "./note-dates.mjs";
18
+ import { calendarEras, formatDateInCalendar, parseNoteDate } from "./note-dates.mjs";
19
19
  import { numberWords, numberDigits } from "./number-words.mjs";
20
20
  import { slugify } from "./content-slug.mjs";
21
21
  import { WIKILINK, authoredLabel, isSamePage, parseWikilink } from "./wikilink-syntax.mjs";
22
- import { EVENT_REFERENCE_FIELDS, eventFieldText } from "./event-fields.mjs";
22
+ import { EVENT_REFERENCE_FIELDS, eventFieldText, printEventDate } from "./event-fields.mjs";
23
+ import { noteCalendar } from "./note-calendars.mjs";
23
24
 
24
25
  /**
25
26
  * An inline expression, as an author writes one.
@@ -33,6 +34,48 @@ import { EVENT_REFERENCE_FIELDS, eventFieldText } from "./event-fields.mjs";
33
34
  */
34
35
  export const EXPRESSION = /(?<![\\{])\{\{(?![{<%])([^{}\n]+)\}\}(?!\})/g;
35
36
 
37
+ /**
38
+ * Which of `dateformat`'s arguments is which. Three are a calendar, a date and
39
+ * a format. Two are a calendar and a date, unless the first is absent — a
40
+ * `data.calendar` the note leaves unset is no calendar — or is a date that
41
+ * names no calendar, when they are a date and a format. One is a date.
42
+ *
43
+ * @param {unknown[]} given - The arguments, without Handlebars' options.
44
+ * @param {object} [dates] - The reckoning context.
45
+ * @returns {{calendar?: unknown, authored: unknown, format?: unknown}} The roles.
46
+ */
47
+ function dateformatArguments(given, dates) {
48
+ if (given.length >= 3) {
49
+ const [calendar, authored, format] = given;
50
+ return { calendar: calendar ?? undefined, authored, format };
51
+ }
52
+ if (given.length < 2) return { authored: given[0] };
53
+ const [first, second] = given;
54
+ if (first === undefined || first === null) return { authored: second };
55
+ if (typeof first === "number") return { authored: first, format: second };
56
+ if (typeof first === "string" && !namesCalendar(first, dates)) {
57
+ const parsed = parseNoteDate(first, { ...dates, allowUnknown: false });
58
+ if (parsed.date && !parsed.findings.some((item) => item.severity === "error"))
59
+ return { authored: first, format: second };
60
+ }
61
+ return { calendar: first, authored: second };
62
+ }
63
+
64
+ /**
65
+ * Whether a string names a calendar or one of its eras.
66
+ *
67
+ * @param {string} value - The string.
68
+ * @param {object} [dates] - The reckoning context.
69
+ * @returns {boolean} Whether it resolves to one.
70
+ */
71
+ function namesCalendar(value, dates) {
72
+ try {
73
+ return calendarEras(value, dates).length > 0;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
36
79
  /**
37
80
  * Every helper an expression may call, with the parameters it takes.
38
81
  *
@@ -135,30 +178,42 @@ const HELPERS = Object.freeze([
135
178
  }),
136
179
  Object.freeze({
137
180
  name: "dateformat",
138
- params: 'calendar date ["format"]',
181
+ params: '[calendar] date ["format"]',
139
182
  summary:
140
- "A date written in a calendar. The calendar is an Address or its shortcode, the date " +
141
- "is the value a note carries, and the optional third argument names an output pattern.",
183
+ "A date written in a calendar. The calendar is an Address or its shortcode; left " +
184
+ "out, it is the note's own calendar — its `data.calendar`, else its culture's, " +
185
+ "else for a place its parents', else the configured `defaultCalendar` — and a " +
186
+ "note with none prints the date as written. The date is the value a note " +
187
+ "carries, and the optional last argument names an output pattern.",
142
188
  make:
143
- ({ dates }) =>
144
- (calendar, authored, formatOrOptions) => {
189
+ ({ dates, fm }) =>
190
+ (...args) => {
191
+ const { calendar, authored, format } = dateformatArguments(
192
+ args.slice(0, -1),
193
+ dates,
194
+ );
145
195
  if (
146
- !(typeof calendar === "string" || isAddressTuple(calendar)) ||
147
- !["string", "number"].includes(typeof authored)
196
+ !(
197
+ calendar === undefined ||
198
+ typeof calendar === "string" ||
199
+ isAddressTuple(calendar)
200
+ ) ||
201
+ !["string", "number"].includes(typeof authored) ||
202
+ !(format === undefined || typeof format === "string")
148
203
  )
149
- throw new TypeError("dateformat needs a calendar Address and a date");
204
+ throw new TypeError("dateformat needs a date, and takes a calendar Address");
150
205
  const parsed = parseNoteDate(authored, { ...dates, allowUnknown: false });
151
206
  const error = parsed.findings.find((item) => item.severity === "error");
152
207
  if (error || !parsed.date)
153
208
  throw new RangeError(error?.message ?? "date does not resolve");
154
- const printable = formatDateInCalendar(
155
- parsed.date,
156
- isAddressTuple(calendar) ? renderAddress(calendar) : calendar,
157
- dates,
158
- typeof formatOrOptions === "string" ? formatOrOptions : undefined,
159
- );
209
+ const reference =
210
+ calendar === undefined ? noteCalendar(fm, dates)
211
+ : isAddressTuple(calendar) ? renderAddress(calendar)
212
+ : calendar;
213
+ if (reference === undefined) return printEventDate(authored, parsed.date, dates);
214
+ const printable = formatDateInCalendar(parsed.date, reference, dates, format);
160
215
  if (!printable)
161
- throw new RangeError(`date cannot be printed in calendar ${calendar}`);
216
+ throw new RangeError(`date cannot be printed in calendar ${reference}`);
162
217
  return printable.prose ?? printable.text;
163
218
  },
164
219
  }),
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { scanBlocks } from "./content-blocks.mjs";
15
15
  import { scanAlerts } from "./content-alerts.mjs";
16
+ import { scanRuns } from "./content-scripts.mjs";
16
17
 
17
18
  /**
18
19
  * Validate authored block syntax with positions in the original file.
@@ -20,20 +21,33 @@ import { scanAlerts } from "./content-alerts.mjs";
20
21
  * @param {string} source - Complete Markdown source, optionally with frontmatter.
21
22
  * @param {string} file - Diagnostic filename.
22
23
  * @param {import("./content-session.mjs").ContentSyntax} [syntax] - Exact-text scanner projections.
23
- * @returns {Array<{file: string, line: number, column: number, severity: string, message: string}>} Syntax errors.
24
+ * @param {import("./content-scripts.mjs").RunContext} [runs] - The package's
25
+ * languages, faces and maps. Given, text marked with `lang=`, `font=` and
26
+ * `map=` is checked against them; a caller with no package configuration
27
+ * leaves it out.
28
+ * @returns {Array<{file: string, line: number, column: number, severity: string, message: string}>} Syntax errors,
29
+ * then warnings.
24
30
  */
25
- export function markupFindings(source, file, syntax) {
31
+ export function markupFindings(source, file, syntax, runs) {
26
32
  const body = String(source ?? "").replace(
27
33
  /^\uFEFF?---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/,
28
34
  (frontmatter) => frontmatter.replace(/[^\r\n]/g, " "),
29
35
  );
30
36
  const scanned = syntax?.(body);
31
- return [
37
+ const marked = runs ? scanRuns(body, runs, syntax) : undefined;
38
+ const errors = [
32
39
  ...(scanned?.blocks ?? scanBlocks(body)).errors,
33
40
  ...(scanned?.alerts ?? scanAlerts(body)).errors,
41
+ ...(marked?.errors ?? []),
34
42
  ].map((error) => ({
35
43
  file,
36
44
  ...error,
37
45
  severity: "error",
38
46
  }));
47
+ const warnings = (marked?.warnings ?? []).map((warning) => ({
48
+ file,
49
+ ...warning,
50
+ severity: "warning",
51
+ }));
52
+ return [...errors, ...warnings];
39
53
  }
package/engine/names.mjs CHANGED
@@ -19,8 +19,8 @@ import { formatLocator } from "./diagnostics.mjs";
19
19
  import { FOLDER_TYPE } from "./folder-notes.mjs";
20
20
  import { isNoteRecord } from "./index-records.mjs";
21
21
  import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
22
+ import { WORD } from "./word-pattern.mjs";
22
23
 
23
- const WORD = /[\p{L}\p{M}\p{N}]+(?:[’'–-][\p{L}\p{M}\p{N}]+)*/gu;
24
24
  const compare = (a, b) =>
25
25
  a < b ? -1
26
26
  : a > b ? 1
@@ -0,0 +1,314 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * **The calendar a note's dates print in.** A note's calendar is the first of:
16
+ *
17
+ * 1. its own `data.calendar`, on any type;
18
+ * 2. its culture's calendar: the culture note its `data.culture` names,
19
+ * resolved by these same rules;
20
+ * 3. for a `place`, its `data.parents`, each resolved by these same rules. Two
21
+ * or more distinct calendars among them are an error at the place's
22
+ * `data.parents`, fixed by stating `data.calendar` on the place;
23
+ * 4. the package's configured `defaultCalendar`;
24
+ * 5. otherwise none, and a date prints as it was written.
25
+ *
26
+ * Steps 2 and 3 take only a calendar some note states: a culture or parent
27
+ * that itself falls through to the default contributes nothing, so parents
28
+ * that all fall through give the default and raise no error. A note met again
29
+ * while it is being resolved — a cycle through `data.parents` or
30
+ * `data.culture` — contributes nothing either, and the chain goes on to the
31
+ * default.
32
+ *
33
+ * The content index writes the result onto each note record as
34
+ * `resolvedCalendar`, the calendar note's Address, and
35
+ * every reader of a note's calendar reads it from there: the event views, the
36
+ * `dateformat` helper and the infobox's dates.
37
+ *
38
+ * @module
39
+ */
40
+
41
+ import fs from "node:fs";
42
+ import path from "node:path";
43
+
44
+ import { canonicalKey, isAddressTuple, readCanonicalKey, renderAddress } from "./address.mjs";
45
+ import { isAddressSegment } from "./address-charset.mjs";
46
+ import { AddressLink } from "./address-values.mjs";
47
+ import { formatDiagnostic, positionOfFrontmatterPath, positionOfYamlPath } from "./diagnostics.mjs";
48
+ import { isNoteRecord, noteFile } from "./index-records.mjs";
49
+ import { noteCalendarKey } from "./reckoning-markers.mjs";
50
+ import { NOTE_SYSTEM } from "./systems.mjs";
51
+
52
+ /** The configuration key naming the calendar of last resort. */
53
+ export const DEFAULT_CALENDAR_KEY = "defaultCalendar";
54
+
55
+ /**
56
+ * The canonical key an Address names, without any anchor.
57
+ *
58
+ * @param {unknown} value - A tuple, an {@link AddressLink}, or anything else.
59
+ * @returns {string|undefined} The key, or `undefined` for anything else.
60
+ */
61
+ function addressKey(value) {
62
+ const tuple = value instanceof AddressLink ? value.target : value;
63
+ return isAddressTuple(tuple) ? renderAddress(tuple) : undefined;
64
+ }
65
+
66
+ /**
67
+ * Whether a record is a calendar note.
68
+ *
69
+ * @param {Record<string, any>|undefined} record - An index record.
70
+ * @returns {boolean} Whether it is a `lore` note with `subType: calendar`.
71
+ */
72
+ function isCalendarNote(record) {
73
+ return record?.type === "lore" && String(record.subType ?? "").toLowerCase() === "calendar";
74
+ }
75
+
76
+ /**
77
+ * The canonical key of the calendar `defaultCalendar` names, or why it names none.
78
+ *
79
+ * @param {unknown} shortcode - The configured value.
80
+ * @param {string} contentPackage - The package the shortcode is read in.
81
+ * @param {(key: string) => Record<string, any>|undefined} recordOf - A local
82
+ * note by canonical key.
83
+ * @returns {{key?: string, problem?: string}} The calendar, or the problem.
84
+ */
85
+ function defaultCalendarKey(shortcode, contentPackage, recordOf) {
86
+ if (shortcode === undefined || shortcode === null) return {};
87
+ const named = String(shortcode);
88
+ const key =
89
+ isAddressSegment(named) ?
90
+ canonicalKey(contentPackage, NOTE_SYSTEM, "lore", named)
91
+ : undefined;
92
+ if (key && isCalendarNote(recordOf(key))) return { key };
93
+ return {
94
+ problem:
95
+ `${DEFAULT_CALENDAR_KEY} ${named} names no calendar note in ${contentPackage}; ` +
96
+ `name the shortcode of a lore note with subType: calendar`,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Write each note's resolved calendar into its record, as `resolvedCalendar`.
102
+ *
103
+ * Reads each note's authored `data.calendar`, `data.culture` and
104
+ * `data.parents`, so it runs before the index writes a resolved culture over
105
+ * `data.culture`. A culture or parent in another package contributes the
106
+ * calendar its published entry states in `data.calendar`, and nothing else.
107
+ *
108
+ * @param {Array<Record<string, any>>} records - Index records, every Address a
109
+ * tuple. Records that are not notes are passed over.
110
+ * @param {object} options - Options.
111
+ * @param {string} options.contentPackage - The package the records belong to.
112
+ * @param {string} [options.defaultCalendar] - The configured calendar of last
113
+ * resort, a calendar note's shortcode.
114
+ * @param {string} [options.configFile] - The configuration file, where an
115
+ * unknown `defaultCalendar` is reported.
116
+ * @param {string} [options.contentBase] - The content tree, for opening a note
117
+ * to locate a finding.
118
+ * @param {(key: string) => Record<string, any>|undefined} [options.foreignRecord] -
119
+ * Another package's entry for a canonical address.
120
+ * @param {object[]} [options.problems] - Receives each finding. Omitted, the
121
+ * findings are collected and one Error lists them all.
122
+ * @param {Map<string, {raw: string}>} [options.parsed] - Notes already read, keyed by file.
123
+ * @returns {Array<Record<string, any>>} The same records.
124
+ * @throws {Error} When `problems` is omitted and anything is reported.
125
+ */
126
+ export function resolveNoteCalendars(
127
+ records,
128
+ { contentPackage, defaultCalendar, configFile, contentBase, foreignRecord, problems, parsed },
129
+ ) {
130
+ const notes = new Map();
131
+ for (const record of records) {
132
+ if (!isNoteRecord(record) || !isAddressSegment(String(record.shortcode ?? ""))) continue;
133
+ const key = canonicalKey(contentPackage, NOTE_SYSTEM, record.type, record.shortcode);
134
+ if (!notes.has(key)) notes.set(key, record);
135
+ }
136
+ const findings = [];
137
+
138
+ const fallback = defaultCalendarKey(defaultCalendar, contentPackage, (key) => notes.get(key));
139
+ if (fallback.problem) {
140
+ const file = configFile ?? "package-build.config.yaml";
141
+ const source =
142
+ configFile && /\.ya?ml$/.test(configFile) && fs.existsSync(configFile) ?
143
+ fs.readFileSync(configFile, "utf8")
144
+ : undefined;
145
+ findings.push({
146
+ file,
147
+ ...(source ? positionOfYamlPath(source, [DEFAULT_CALENDAR_KEY], { key: true }) : {}),
148
+ severity: "error",
149
+ message: fallback.problem,
150
+ });
151
+ }
152
+
153
+ const memo = new Map();
154
+ const inProgress = new Set();
155
+
156
+ /**
157
+ * The calendar some note states for a source Address, local or foreign.
158
+ *
159
+ * @param {unknown} value - A `data.culture` or `data.parents` Address.
160
+ * @returns {string|undefined} The calendar's canonical key.
161
+ */
162
+ const statedThrough = (value) => {
163
+ const key = addressKey(value);
164
+ if (!key) return undefined;
165
+ const tuple = value instanceof AddressLink ? value.target : value;
166
+ if (tuple.package === contentPackage) {
167
+ const record = notes.get(
168
+ canonicalKey(contentPackage, NOTE_SYSTEM, tuple.type, tuple.shortcode),
169
+ );
170
+ return record ? resolve(record) : undefined;
171
+ }
172
+ return addressKey(foreignRecord?.(key)?.data?.calendar);
173
+ };
174
+
175
+ /**
176
+ * Report parents that disagree.
177
+ *
178
+ * @param {Record<string, any>} record - The place.
179
+ * @param {Array<{key: string, via: unknown}>} distinct - The calendars and
180
+ * the parent each came through.
181
+ */
182
+ const conflict = (record, distinct) => {
183
+ const file = contentBase ? noteFile(contentBase, record) : record.file?.path;
184
+ const raw =
185
+ parsed?.get(file)?.raw ??
186
+ (contentBase && fs.existsSync(file) ? fs.readFileSync(file, "utf8") : undefined);
187
+ let position = positionOfFrontmatterPath(raw, ["data", "parents"], { key: true });
188
+ if (position.line === undefined)
189
+ position = positionOfFrontmatterPath(raw, ["data"], { key: true });
190
+ const via = (value) => {
191
+ const tuple = value instanceof AddressLink ? value.target : value;
192
+ return tuple.package === contentPackage ? tuple.shortcode : addressKey(tuple);
193
+ };
194
+ const named = distinct.map(({ key, via: source }) => `${key} via ${via(source)}`);
195
+ findings.push({
196
+ file,
197
+ ...position,
198
+ severity: "error",
199
+ message:
200
+ `place ${record.shortcode}: data.parents resolve to different calendars ` +
201
+ `(${named.join(", ")}); state data.calendar`,
202
+ });
203
+ };
204
+
205
+ /**
206
+ * The calendar a note states through its own field, its culture or — for a
207
+ * place — its parents, ignoring the default.
208
+ *
209
+ * @param {Record<string, any>} record - The note.
210
+ * @returns {string|undefined} The calendar's canonical key.
211
+ */
212
+ const resolveRecord = (record) => {
213
+ const own = addressKey(record.data?.calendar);
214
+ if (own) return own;
215
+ const culture = statedThrough(record.data?.culture);
216
+ if (culture) return culture;
217
+ if (record.type !== "place") return undefined;
218
+ const parents = Array.isArray(record.data?.parents) ? record.data.parents : [];
219
+ const distinct = new Map();
220
+ for (const parent of parents) {
221
+ const calendar = statedThrough(parent);
222
+ if (calendar && !distinct.has(calendar)) distinct.set(calendar, parent);
223
+ }
224
+ if (distinct.size > 1) {
225
+ conflict(
226
+ record,
227
+ [...distinct].map(([key, via]) => ({ key, via })),
228
+ );
229
+ return undefined;
230
+ }
231
+ return distinct.keys().next().value;
232
+ };
233
+
234
+ /**
235
+ * A local note's stated calendar, memoised; a note already being resolved
236
+ * is a cycle and states nothing.
237
+ *
238
+ * @param {Record<string, any>} record - The note.
239
+ * @returns {string|undefined} The calendar's canonical key.
240
+ */
241
+ const resolve = (record) => {
242
+ if (memo.has(record)) return memo.get(record);
243
+ if (inProgress.has(record)) return undefined;
244
+ inProgress.add(record);
245
+ const result = resolveRecord(record);
246
+ inProgress.delete(record);
247
+ memo.set(record, result);
248
+ return result;
249
+ };
250
+
251
+ // Walked in address order, so which note of a cycle is met first is a fact
252
+ // about the content rather than about directory-read order.
253
+ const calendars = new Map();
254
+ for (const key of [...notes.keys()].sort())
255
+ calendars.set(key, resolve(notes.get(key)) ?? fallback.key);
256
+ // A note's second record — an item's documentation journal — is the same
257
+ // note, and reads in the same calendar.
258
+ for (const record of records) {
259
+ if (!isNoteRecord(record) || !isAddressSegment(String(record.shortcode ?? ""))) continue;
260
+ const calendar = calendars.get(
261
+ canonicalKey(contentPackage, NOTE_SYSTEM, record.type, record.shortcode),
262
+ );
263
+ if (calendar) record.resolvedCalendar = readCanonicalKey(calendar);
264
+ else delete record.resolvedCalendar;
265
+ }
266
+
267
+ findings.sort(
268
+ (a, b) =>
269
+ String(a.file).localeCompare(String(b.file), "en") ||
270
+ (a.line ?? 0) - (b.line ?? 0) ||
271
+ (a.column ?? 0) - (b.column ?? 0),
272
+ );
273
+ if (problems) problems.push(...findings);
274
+ else if (findings.length)
275
+ throw Object.assign(new Error(findings.map(formatDiagnostic).join("\n")), {
276
+ located: true,
277
+ });
278
+ return records;
279
+ }
280
+
281
+ /**
282
+ * The configuration file in a package root, where a configured value is
283
+ * reported.
284
+ *
285
+ * @param {string|undefined} rootDir - The package root.
286
+ * @param {readonly string[]} names - The file names a configuration may take.
287
+ * @returns {string|undefined} The first that exists.
288
+ */
289
+ export function configFileIn(rootDir, names) {
290
+ if (!rootDir) return undefined;
291
+ for (const name of names) {
292
+ const file = path.join(rootDir, name);
293
+ if (fs.existsSync(file)) return file;
294
+ }
295
+ return undefined;
296
+ }
297
+
298
+ /**
299
+ * The calendar a note's dates print in: the one the content index resolved for
300
+ * it, where the reckoning context carries it, else the note's own
301
+ * `data.calendar`.
302
+ *
303
+ * @param {object|undefined} fm - The note's frontmatter.
304
+ * @param {{noteCalendars?: Map<string, string>}} [dates] - The reckoning context.
305
+ * @returns {string|undefined} The calendar's Address or shortcode.
306
+ */
307
+ export function noteCalendar(fm, dates) {
308
+ const key = noteCalendarKey(fm);
309
+ const resolved = key ? dates?.noteCalendars?.get(key) : undefined;
310
+ if (resolved) return resolved;
311
+ const own = fm?.data?.calendar;
312
+ if (own === undefined || own === null || own === "") return undefined;
313
+ return addressKey(own) ?? String(own);
314
+ }
@@ -153,6 +153,7 @@ import { checkCitedPopulations, checkPopulation } from "./populations.mjs";
153
153
  import { MARKET_CLASSES, checkMarket } from "./market-class.mjs";
154
154
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
155
155
  import { checkSettlementTables } from "./settlement-tables.mjs";
156
+ import { checkMapField } from "./text-maps.mjs";
156
157
 
157
158
  /**
158
159
  * One `data:` key a note type may carry.
@@ -1030,6 +1031,17 @@ export const SHARED_DATA_FIELDS = Object.freeze([
1030
1031
  describe:
1031
1032
  "Deliberate cross-references in reading order, with optional glosses; shown on the site, in journals and in books.",
1032
1033
  }),
1034
+ Object.freeze({
1035
+ name: "calendar",
1036
+ ...LINK,
1037
+ ref: "lore",
1038
+ accepts: Object.freeze(["lore"]),
1039
+ check: checkCalendarChoice,
1040
+ describe:
1041
+ "The calendar this note's dates print in, as a calendar lore note. Absent, the " +
1042
+ "note takes its culture's calendar, then — for a place — its parents', then the " +
1043
+ "configured `defaultCalendar`.",
1044
+ }),
1033
1045
  Object.freeze({
1034
1046
  name: "id",
1035
1047
  ...TEXT,
@@ -1503,6 +1515,47 @@ export const BEING_ARCHETYPES = Object.freeze({
1503
1515
  guildsperson: "Has professional training, standing, or connections that open doors.",
1504
1516
  });
1505
1517
 
1518
+ /**
1519
+ * One row of a mapping note's `data.map.rows` or of a face's own `rows`.
1520
+ *
1521
+ * @type {object}
1522
+ */
1523
+ const MAP_ROW_ENTRY = Object.freeze({
1524
+ kind: "list-or-map",
1525
+ shape: "`[from, to]` or `{ from, to, before?, at? }`",
1526
+ entries: Object.freeze({ ...TEXT, shape: "a string" }),
1527
+ fields: Object.freeze([
1528
+ {
1529
+ name: "from",
1530
+ ...TEXT,
1531
+ shape: "a string of one or more characters",
1532
+ required: true,
1533
+ describe: "The one or more characters the row matches.",
1534
+ },
1535
+ {
1536
+ name: "to",
1537
+ ...TEXT,
1538
+ shape: "a string, which may be empty",
1539
+ required: true,
1540
+ allowEmpty: true,
1541
+ describe: "What the row writes in their place; empty to drop them.",
1542
+ },
1543
+ {
1544
+ name: "before",
1545
+ ...TEXT,
1546
+ allowed: Object.freeze(["vowel", "consonant"]),
1547
+ describe:
1548
+ "`vowel` or `consonant`: what must follow the match for the " + "row to apply.",
1549
+ },
1550
+ {
1551
+ name: "at",
1552
+ ...TEXT,
1553
+ allowed: Object.freeze(["start", "end"]),
1554
+ describe: "`start` or `end`: where the match must sit for the row to " + "apply.",
1555
+ },
1556
+ ]),
1557
+ });
1558
+
1506
1559
  /**
1507
1560
  * Every note type this toolchain compiles, and the closed vocabulary it
1508
1561
  * declares.
@@ -1596,14 +1649,6 @@ export const NOTE_VOCABULARY = Object.freeze({
1596
1649
  "When the being was born — a date, or `unknown` where the birth is " +
1597
1650
  "unrecorded. Absent, the being was never born.",
1598
1651
  },
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
1652
  {
1608
1653
  name: "died",
1609
1654
  ...TEXT,
@@ -2145,6 +2190,77 @@ export const NOTE_VOCABULARY = Object.freeze({
2145
2190
  "The culture a setting guide introduces, as a culture lore note. " +
2146
2191
  "Required on `settingguide`; no other subType states it.",
2147
2192
  },
2193
+ {
2194
+ name: "map",
2195
+ kind: "map",
2196
+ shape: "a map — `{ name, vowels?, fonts, rows }`",
2197
+ check: checkMapField,
2198
+ fields: Object.freeze([
2199
+ {
2200
+ name: "name",
2201
+ ...TEXT,
2202
+ required: true,
2203
+ describe:
2204
+ "What `map=` names the map by: lowercase letters, digits and `-`, " +
2205
+ "unique in the package.",
2206
+ },
2207
+ {
2208
+ name: "vowels",
2209
+ ...TEXT,
2210
+ describe:
2211
+ "The characters a row's `before: vowel` reads as vowels; any other " +
2212
+ "letter is a consonant.",
2213
+ },
2214
+ {
2215
+ name: "rows",
2216
+ kind: "list",
2217
+ shape: "list of `[from, to]` or `{ from, to, before?, at? }`",
2218
+ required: true,
2219
+ entries: MAP_ROW_ENTRY,
2220
+ describe:
2221
+ "The rewrites, each `[from, to]` or `{ from, to, before?, at? }`: " +
2222
+ "`before` is `vowel` or `consonant`, `at` is `start` or `end`. The " +
2223
+ "longest `from` wins, and a character no row matches passes through.",
2224
+ },
2225
+ {
2226
+ name: "fonts",
2227
+ kind: "list",
2228
+ shape: "list of `{ font, rows? }`",
2229
+ required: true,
2230
+ entries: Object.freeze({
2231
+ kind: "map",
2232
+ shape: "a map — `{ font, rows? }`",
2233
+ fields: Object.freeze([
2234
+ {
2235
+ name: "font",
2236
+ ...TEXT,
2237
+ required: true,
2238
+ shape: "an installed face's slug",
2239
+ describe:
2240
+ "A face the map is drawn in, by the slug `font=` names it by.",
2241
+ },
2242
+ {
2243
+ name: "rows",
2244
+ kind: "list",
2245
+ shape: "list of `[from, to]` or `{ from, to, before?, at? }`",
2246
+ entries: MAP_ROW_ENTRY,
2247
+ describe:
2248
+ "Rows for this face alone, tried before the shared `rows`; " +
2249
+ "the same shapes and conditions.",
2250
+ },
2251
+ ]),
2252
+ }),
2253
+ describe:
2254
+ "The faces the map is drawn in, at least one, each with any rows of " +
2255
+ "its own. A run in another face is refused, and each face must draw " +
2256
+ "every character its rows and the lexicon's native forms write.",
2257
+ },
2258
+ ]),
2259
+ describe:
2260
+ "A mapping, which text marked `map=` with its `name` is rewritten by " +
2261
+ "before it is set. See " +
2262
+ "[mapping notes](format-details.md#mapping-notes).",
2263
+ },
2148
2264
  ]),
2149
2265
  }),
2150
2266
 
@@ -2281,14 +2397,6 @@ export const NOTE_VOCABULARY = Object.freeze({
2281
2397
  check: checkPlace,
2282
2398
  data: Object.freeze([
2283
2399
  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
2400
  {
2293
2401
  name: "demonym",
2294
2402
  ...TEXT,