@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.
- package/CHANGELOG.md +22 -0
- package/assets/styles/content-base.css +25 -0
- package/bin/content-commands.mjs +264 -3
- package/bin/package-build.mjs +25 -0
- package/content-config.mjs +98 -0
- package/docs/api.md +18 -14
- package/docs/authoring/dates-and-calendars.md +29 -1
- package/docs/authoring/links-and-markup.md +442 -50
- package/docs/commands.md +127 -0
- package/docs/configuration.md +90 -3
- package/docs/content-format.md +3 -1
- package/docs/guides/book.md +11 -0
- package/docs/guides/module-project.md +17 -1
- package/docs/guides/site.md +19 -0
- package/docs/reference/format-details.md +434 -23
- package/docs/reference/note-types.md +45 -21
- package/engine/base-compiler.mjs +2 -0
- package/engine/bundles.mjs +7 -2
- package/engine/compile-corpus.mjs +3 -1
- package/engine/content-charset.mjs +21 -2
- package/engine/content-format.yaml +1 -2
- package/engine/content-index.mjs +33 -2
- package/engine/content-links.mjs +3 -0
- package/engine/content-poetry.mjs +22 -4
- package/engine/content-scripts.mjs +1219 -0
- package/engine/content-session.mjs +2 -0
- package/engine/data-keys.mjs +5 -1
- package/engine/dictionary-layout.mjs +18 -12
- package/engine/dictionary-rows.mjs +11 -2
- package/engine/event-rows.mjs +63 -18
- package/engine/event-views.mjs +28 -15
- package/engine/helpers.mjs +8 -2
- package/engine/index-records.mjs +2 -0
- package/engine/infobox.mjs +4 -8
- package/engine/journals.mjs +14 -3
- package/engine/language-data.mjs +137 -1
- package/engine/language-tag.mjs +103 -0
- package/engine/language-terms.mjs +16 -4
- package/engine/map-lexicon.mjs +762 -0
- package/engine/markdown-expressions.mjs +72 -17
- package/engine/markup-validation.mjs +17 -3
- package/engine/names.mjs +1 -1
- package/engine/note-calendars.mjs +314 -0
- package/engine/note-vocabulary.mjs +124 -16
- package/engine/pdf-build.mjs +28 -10
- package/engine/pdf-render.mjs +116 -11
- package/engine/reckoning-markers.mjs +63 -3
- package/engine/run-rows.mjs +119 -0
- package/engine/run-words.mjs +266 -0
- package/engine/scenes.mjs +1 -0
- package/engine/script-font-delivery.mjs +321 -0
- package/engine/script-fonts.mjs +172 -0
- package/engine/site-build.mjs +10 -3
- package/engine/site-config.mjs +5 -0
- package/engine/site-root.mjs +19 -5
- package/engine/sql-tables.mjs +120 -4
- package/engine/text-maps.mjs +607 -0
- package/engine/verb-pattern.mjs +99 -0
- package/engine/word-pattern.mjs +27 -0
- package/engine/word-rows.mjs +8 -2
- package/engine/word-terms.mjs +44 -0
- package/hugo-theme/layouts/_default/baseof.html +3 -0
- package/hugo-theme/static/css/style.css +10 -0
- package/manifest.mjs +9 -1
- package/package.json +1 -1
- package/types/content-config.d.mts +38 -0
- package/types/engine/compile-corpus.d.mts +2 -1
- package/types/engine/content-index.d.mts +17 -3
- package/types/engine/content-poetry.d.mts +7 -0
- package/types/engine/content-scripts.d.mts +226 -0
- package/types/engine/data-keys.d.mts +2 -2
- package/types/engine/dictionary-rows.d.mts +15 -0
- package/types/engine/event-rows.d.mts +16 -1
- package/types/engine/event-views.d.mts +2 -1
- package/types/engine/helpers.d.mts +5 -1
- package/types/engine/journals.d.mts +7 -2
- package/types/engine/language-data.d.mts +2 -0
- package/types/engine/language-tag.d.mts +8 -0
- package/types/engine/language-terms.d.mts +2 -1
- package/types/engine/map-lexicon.d.mts +274 -0
- package/types/engine/markup-validation.d.mts +7 -2
- package/types/engine/note-calendars.d.mts +60 -0
- package/types/engine/pdf-build.d.mts +26 -3
- package/types/engine/pdf-render.d.mts +17 -2
- package/types/engine/reckoning-markers.d.mts +32 -1
- package/types/engine/run-rows.d.mts +89 -0
- package/types/engine/run-words.d.mts +86 -0
- package/types/engine/script-font-delivery.d.mts +134 -0
- package/types/engine/script-fonts.d.mts +168 -0
- package/types/engine/site-build.d.mts +2 -1
- package/types/engine/site-root.d.mts +11 -3
- package/types/engine/text-maps.d.mts +175 -0
- package/types/engine/verb-pattern.d.mts +65 -0
- package/types/engine/word-pattern.d.mts +13 -0
- 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
|
|
141
|
-
"is the
|
|
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
|
-
(
|
|
189
|
+
({ dates, fm }) =>
|
|
190
|
+
(...args) => {
|
|
191
|
+
const { calendar, authored, format } = dateformatArguments(
|
|
192
|
+
args.slice(0, -1),
|
|
193
|
+
dates,
|
|
194
|
+
);
|
|
145
195
|
if (
|
|
146
|
-
!(
|
|
147
|
-
|
|
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
|
|
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
|
|
155
|
-
|
|
156
|
-
isAddressTuple(calendar) ? renderAddress(calendar)
|
|
157
|
-
|
|
158
|
-
|
|
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 ${
|
|
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
|
-
* @
|
|
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
|
-
|
|
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,
|