@heroiclands/package-build 25.5.4 → 25.6.0
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 +40 -0
- package/README.md +7 -0
- package/assets/styles/content-base.css +1 -1
- package/bin/content-commands.mjs +32 -8
- package/docs/api.md +81 -32
- package/docs/authoring/frontmatter.md +1 -1
- package/docs/authoring/links-and-markup.md +59 -10
- package/docs/commands.md +60 -17
- package/docs/configuration.md +3 -2
- package/docs/content-format.md +50 -1
- package/docs/diagnostics.md +17 -0
- package/docs/guides/module-project.md +11 -3
- package/docs/guides/reference-pages.md +4 -1
- package/docs/guides/site.md +13 -3
- package/docs/guides/writing-in-invented-scripts.md +5 -2
- package/docs/reference/format-details.md +233 -138
- package/docs/reference/note-types.md +165 -152
- package/docs/reference/theme-pages.md +1 -0
- package/e2e.mjs +1 -1
- package/engine/base-compiler.mjs +2 -0
- package/engine/compile-corpus.mjs +6 -2
- package/engine/content-charset.mjs +1 -1
- package/engine/content-format.yaml +9 -7
- package/engine/content-html.mjs +3 -2
- package/engine/content-index.mjs +23 -0
- package/engine/content-links.mjs +8 -0
- package/engine/content-render.mjs +14 -2
- package/engine/content-scripts.mjs +44 -6
- package/engine/content-session.mjs +11 -7
- package/engine/content-spans.mjs +3 -4
- package/engine/content-tables.mjs +12 -5
- package/engine/content-verse.mjs +52 -10
- package/engine/core-report.mjs +27 -18
- package/engine/culture-reach.mjs +3 -0
- package/engine/declared-sections.mjs +100 -0
- package/engine/derived-sections.mjs +36 -2
- package/engine/dictionary-rows.mjs +2 -1
- package/engine/generated-sections.mjs +2 -0
- package/engine/holdings.mjs +107 -3
- package/engine/index-records.mjs +3 -1
- package/engine/index.mjs +1 -1
- package/engine/language-data.mjs +0 -1
- package/engine/language-names.mjs +53 -23
- package/engine/language-rows.mjs +1 -3
- package/engine/language-terms.mjs +7 -10
- package/engine/map-dot.mjs +22 -0
- package/engine/markdown-blocks.mjs +23 -8
- package/engine/markdownlint-run.mjs +1 -1
- package/engine/markup-validation.mjs +2 -0
- package/engine/metadata-index.mjs +1 -0
- package/engine/note-addresses.mjs +16 -0
- package/engine/note-vocabulary.mjs +29 -13
- package/engine/npc-generate.mjs +52 -7
- package/engine/office-holders.mjs +76 -0
- package/engine/page-breaks.mjs +143 -0
- package/engine/pdf-build.mjs +12 -4
- package/engine/pdf-render.mjs +12 -2
- package/engine/place-kinds.mjs +106 -0
- package/engine/preview-index.mjs +4 -2
- package/engine/settlement-tables.mjs +410 -675
- package/engine/settlement-terms.mjs +118 -0
- package/engine/site-build.mjs +14 -7
- package/engine/site-preview.mjs +3 -0
- package/engine/site-render.mjs +18 -4
- package/engine/site-root.mjs +10 -1
- package/engine/site-serve-http.mjs +151 -0
- package/engine/site-serve.mjs +196 -126
- package/engine/sql-tables.mjs +101 -7
- package/engine/standing-terms.mjs +9 -5
- package/engine/standings.mjs +83 -31
- package/engine/unique-items.mjs +85 -0
- package/engine/verse-metre.mjs +50 -5
- package/engine/verse-regions.mjs +16 -0
- package/package.json +9 -4
- package/site-theme/data/banner-kinds.yaml +2 -0
- package/sohl/actors.mjs +4 -3
- package/types/e2e.d.mts +1 -1
- package/types/engine/content-html.d.mts +2 -1
- package/types/engine/content-scripts.d.mts +9 -1
- package/types/engine/content-verse.d.mts +11 -2
- package/types/engine/core-report.d.mts +6 -2
- package/types/engine/culture-reach.d.mts +3 -0
- package/types/engine/declared-sections.d.mts +30 -0
- package/types/engine/derived-sections.d.mts +5 -1
- package/types/engine/holdings.d.mts +34 -2
- package/types/engine/index-records.d.mts +2 -1
- package/types/engine/language-names.d.mts +18 -6
- package/types/engine/language-terms.d.mts +5 -1
- package/types/engine/markdown-blocks.d.mts +5 -1
- package/types/engine/npc-generate.d.mts +3 -0
- package/types/engine/office-holders.d.mts +33 -0
- package/types/engine/page-breaks.d.mts +38 -0
- package/types/engine/pdf-render.d.mts +2 -0
- package/types/engine/place-kinds.d.mts +18 -0
- package/types/engine/settlement-tables.d.mts +25 -158
- package/types/engine/settlement-terms.d.mts +53 -0
- package/types/engine/site-build.d.mts +3 -1
- package/types/engine/site-render.d.mts +6 -2
- package/types/engine/site-root.d.mts +3 -1
- package/types/engine/site-serve-http.d.mts +1 -0
- package/types/engine/site-serve.d.mts +12 -6
- package/types/engine/sql-tables.d.mts +35 -3
- package/types/engine/standing-terms.d.mts +3 -2
- package/types/engine/standings.d.mts +14 -2
- package/types/engine/unique-items.d.mts +12 -0
- package/types/engine/verse-metre.d.mts +2 -1
- package/types/engine/verse-regions.d.mts +8 -0
- package/vendor/markdownlint.mjs +855 -569
|
@@ -152,8 +152,12 @@ export const JOIN_MEETINGS = Object.freeze(["C+C", "V+C", "V+V"]);
|
|
|
152
152
|
*/
|
|
153
153
|
export const JOIN_RULES = Object.freeze(["merge", "insert", "drop", "double"]);
|
|
154
154
|
|
|
155
|
-
/**
|
|
156
|
-
|
|
155
|
+
/**
|
|
156
|
+
* What a written mark says of its letter: `stress` falls on it, `length` holds
|
|
157
|
+
* it longer, `apart` says it apart from its neighbour, and `quality` changes
|
|
158
|
+
* which vowel is said.
|
|
159
|
+
*/
|
|
160
|
+
export const MARK_KINDS = Object.freeze(["stress", "length", "apart", "quality"]);
|
|
157
161
|
|
|
158
162
|
/** How many of a mark a word may carry: `one` at most, or `any` number. */
|
|
159
163
|
export const MARK_LIMITS = Object.freeze(["one", "any"]);
|
|
@@ -320,13 +324,6 @@ export const SCOPE_FIELDS = Object.freeze([
|
|
|
320
324
|
entries: Object.freeze({ kind: "address", shape: "an Address" }),
|
|
321
325
|
describe: "These notes themselves.",
|
|
322
326
|
}),
|
|
323
|
-
Object.freeze({
|
|
324
|
-
name: "paths",
|
|
325
|
-
kind: "list",
|
|
326
|
-
shape: "a list of content paths",
|
|
327
|
-
entries: Object.freeze({ kind: "string", shape: "a path such as `Regions/Aelwyth/`" }),
|
|
328
|
-
describe: "Every note whose file sits below one of these paths of the content tree.",
|
|
329
|
-
}),
|
|
330
327
|
Object.freeze({
|
|
331
328
|
name: "types",
|
|
332
329
|
kind: "list",
|
|
@@ -1926,7 +1923,7 @@ export const LANGUAGE_DATA_FIELDS = Object.freeze([
|
|
|
1926
1923
|
Object.freeze({
|
|
1927
1924
|
name: "scope",
|
|
1928
1925
|
kind: "map",
|
|
1929
|
-
shape: "a map — `{ folders?, cultures?, lore?, parents?, addresses?,
|
|
1926
|
+
shape: "a map — `{ folders?, cultures?, lore?, parents?, addresses?, types?, skip? }`",
|
|
1930
1927
|
fields: SCOPE_FIELDS,
|
|
1931
1928
|
describe: "The notes the register covers. A language skill note only.",
|
|
1932
1929
|
}),
|
package/engine/map-dot.mjs
CHANGED
|
@@ -131,6 +131,26 @@ export const STYLE = Object.freeze({
|
|
|
131
131
|
penwidth: 2.0,
|
|
132
132
|
extraStyle: "rounded",
|
|
133
133
|
},
|
|
134
|
+
district: {
|
|
135
|
+
shape: "box",
|
|
136
|
+
fill: "#C5E1A5",
|
|
137
|
+
border: DEFAULT_BORDER,
|
|
138
|
+
fontsize: 9,
|
|
139
|
+
width: 0.8,
|
|
140
|
+
height: 0.4,
|
|
141
|
+
penwidth: 1.0,
|
|
142
|
+
extraStyle: "rounded,dashed",
|
|
143
|
+
},
|
|
144
|
+
route: {
|
|
145
|
+
shape: "parallelogram",
|
|
146
|
+
fill: "#FFE082",
|
|
147
|
+
border: DEFAULT_BORDER,
|
|
148
|
+
fontsize: 9,
|
|
149
|
+
width: 0.9,
|
|
150
|
+
height: 0.4,
|
|
151
|
+
penwidth: 1.0,
|
|
152
|
+
extraStyle: "",
|
|
153
|
+
},
|
|
134
154
|
settlement: {
|
|
135
155
|
shape: "ellipse",
|
|
136
156
|
fill: "#A5D6A7",
|
|
@@ -187,10 +207,12 @@ export const STYLE = Object.freeze({
|
|
|
187
207
|
const LEGEND = Object.freeze([
|
|
188
208
|
["world", "the root note"],
|
|
189
209
|
["region", "continent, region, sub-region"],
|
|
210
|
+
["district", "quarter, ward, shire"],
|
|
190
211
|
["settlement", "city, town, village"],
|
|
191
212
|
["site", "ruin, landmark, dungeon, wonder"],
|
|
192
213
|
["feature", "river, mountain range, sea"],
|
|
193
214
|
["structure", "a single building"],
|
|
215
|
+
["route", "road, pass, sea lane"],
|
|
194
216
|
["polity", "affiliation, dashed edge to its region"],
|
|
195
217
|
]);
|
|
196
218
|
|
|
@@ -25,35 +25,50 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import MarkdownIt from "markdown-it";
|
|
28
|
+
import footnotePlugin from "markdown-it-footnote";
|
|
28
29
|
import { verseDivPlugin } from "./verse-regions.mjs";
|
|
29
30
|
|
|
30
31
|
/** The renderer's block grammar, with inline parsing and its passes switched off. */
|
|
31
32
|
const parser = new MarkdownIt({ html: true }).use(verseDivPlugin);
|
|
32
|
-
|
|
33
|
+
const footnoteParser = new MarkdownIt({ html: true }).use(footnotePlugin).use(verseDivPlugin);
|
|
34
|
+
for (const projection of [parser, footnoteParser])
|
|
35
|
+
projection.core.ruler.disable([
|
|
36
|
+
"inline",
|
|
37
|
+
"linkify",
|
|
38
|
+
"replacements",
|
|
39
|
+
"smartquotes",
|
|
40
|
+
"text_join",
|
|
41
|
+
]);
|
|
42
|
+
// Keep definition containers and their original maps in place for scanners.
|
|
43
|
+
footnoteParser.core.ruler.disable("footnote_tail");
|
|
33
44
|
|
|
34
45
|
/** How many recent sources keep their tokens. */
|
|
35
46
|
const RECENT = 16;
|
|
36
47
|
|
|
37
48
|
/** @type {Map<string, import("markdown-it").Token[]>} */
|
|
38
49
|
const recent = new Map();
|
|
50
|
+
const recentFootnotes = new Map();
|
|
39
51
|
|
|
40
52
|
/**
|
|
41
53
|
* The block tokens of a Markdown source. The array is shared between callers
|
|
42
54
|
* and must not be modified; an `inline` token carries no `children`.
|
|
43
55
|
*
|
|
44
56
|
* @param {unknown} source - Markdown.
|
|
57
|
+
* @param {object} [options] - Optional block projection.
|
|
58
|
+
* @param {boolean} [options.footnotes=false] - Include footnote definition containers.
|
|
45
59
|
* @returns {readonly import("markdown-it").Token[]} The block tokens, in order.
|
|
46
60
|
*/
|
|
47
|
-
export function blockTokens(source) {
|
|
61
|
+
export function blockTokens(source, { footnotes = false } = {}) {
|
|
48
62
|
const text = String(source ?? "");
|
|
49
|
-
const
|
|
63
|
+
const cache = footnotes ? recentFootnotes : recent;
|
|
64
|
+
const hit = cache.get(text);
|
|
50
65
|
if (hit) {
|
|
51
|
-
|
|
52
|
-
|
|
66
|
+
cache.delete(text);
|
|
67
|
+
cache.set(text, hit);
|
|
53
68
|
return hit;
|
|
54
69
|
}
|
|
55
|
-
const tokens = parser.parse(text, { verseSource: text });
|
|
56
|
-
|
|
57
|
-
if (
|
|
70
|
+
const tokens = (footnotes ? footnoteParser : parser).parse(text, { verseSource: text });
|
|
71
|
+
cache.set(text, tokens);
|
|
72
|
+
if (cache.size > RECENT) cache.delete(cache.keys().next().value);
|
|
58
73
|
return tokens;
|
|
59
74
|
}
|
|
@@ -669,7 +669,7 @@ function protectLintVerse(source) {
|
|
|
669
669
|
};
|
|
670
670
|
for (const region of verseRegions(syntax)) {
|
|
671
671
|
if (!region.closed) continue;
|
|
672
|
-
referencesInVerse(region.
|
|
672
|
+
referencesInVerse(region.logicalBody, parseVerse(region.logicalBody, { footnotes }));
|
|
673
673
|
ranges.push([region.bodyStart, starts[region.end]]);
|
|
674
674
|
for (let line = region.start + 1; line < region.end; line++) {
|
|
675
675
|
bodyLines.add(line + 1);
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { scanPageBreaks } from "./page-breaks.mjs";
|
|
14
15
|
import { scanBlocks } from "./content-blocks.mjs";
|
|
15
16
|
import { scanAlerts } from "./content-alerts.mjs";
|
|
16
17
|
import { scanRuns } from "./content-scripts.mjs";
|
|
@@ -74,6 +75,7 @@ export function markupFindings(source, file, syntax, runs) {
|
|
|
74
75
|
const errors = [
|
|
75
76
|
...(scanned?.blocks ?? scanBlocks(body)).errors,
|
|
76
77
|
...(scanned?.alerts ?? scanAlerts(body)).errors,
|
|
78
|
+
...scanPageBreaks(body).errors,
|
|
77
79
|
...verseErrors,
|
|
78
80
|
...(marked?.errors ?? []),
|
|
79
81
|
...shortcodeErrors(body),
|
|
@@ -507,6 +507,7 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
|
|
|
507
507
|
}
|
|
508
508
|
: {}),
|
|
509
509
|
population: record.data?.population ?? undefined,
|
|
510
|
+
market: record.data?.market ?? undefined,
|
|
510
511
|
...(Object.hasOwn(record.data ?? {}, "government") ?
|
|
511
512
|
{ government: record.data.government }
|
|
512
513
|
: {}),
|
|
@@ -43,6 +43,22 @@ export {
|
|
|
43
43
|
|
|
44
44
|
/** Structured positions whose Address is nested inside a declared container. */
|
|
45
45
|
export const STRUCTURED_ADDRESSES = Object.freeze([
|
|
46
|
+
{
|
|
47
|
+
noteType: "place",
|
|
48
|
+
path: ["data", "settlements", "rows", "*", "place"],
|
|
49
|
+
shape: "value",
|
|
50
|
+
type: "place",
|
|
51
|
+
accepts: ["place"],
|
|
52
|
+
lenient: true,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
noteType: "place",
|
|
56
|
+
path: ["data", "settlements", "rows", "*", "heldBy"],
|
|
57
|
+
shape: "value",
|
|
58
|
+
type: "affiliation",
|
|
59
|
+
accepts: ["affiliation", "being"],
|
|
60
|
+
lenient: true,
|
|
61
|
+
},
|
|
46
62
|
{ path: ["data", "seeAlso", "*", "address"], shape: "value", lenient: true },
|
|
47
63
|
{
|
|
48
64
|
path: ["packFolder"],
|
|
@@ -104,6 +104,8 @@ import {
|
|
|
104
104
|
STANDING_FIELDS,
|
|
105
105
|
} from "./standing-terms.mjs";
|
|
106
106
|
import { checkDatedOffices } from "./office-holders.mjs";
|
|
107
|
+
import { checkUniqueItem } from "./unique-items.mjs";
|
|
108
|
+
import { checkPlaceKind } from "./place-kinds.mjs";
|
|
107
109
|
import { checkAffiliationRankFloor, checkRankLadder } from "./rank-ladder.mjs";
|
|
108
110
|
import { checkCalendarChoice } from "./calendar-choice.mjs";
|
|
109
111
|
import { checkNoteEvents } from "./note-events.mjs";
|
|
@@ -160,6 +162,7 @@ import { checkCitedPopulations, checkPopulation } from "./populations.mjs";
|
|
|
160
162
|
import { MARKET_CLASSES, checkMarket } from "./market-class.mjs";
|
|
161
163
|
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
162
164
|
import { checkSettlementTables } from "./settlement-tables.mjs";
|
|
165
|
+
import { SETTLEMENTS_FIELD } from "./settlement-terms.mjs";
|
|
163
166
|
import { checkConceptList, checkCoreFlag } from "./core-vocabulary.mjs";
|
|
164
167
|
import { METRE_KINDS, checkMetresField } from "./metre-declaration.mjs";
|
|
165
168
|
import { checkLanguageEmphasis, checkScriptField } from "./script-emphasis.mjs";
|
|
@@ -771,7 +774,7 @@ const NAMES_FIELD = Object.freeze({
|
|
|
771
774
|
describe:
|
|
772
775
|
"How this language's speakers are named, for `package-build npc new`: `pattern` " +
|
|
773
776
|
'builds a name from placeholders such as "{given} {clan}" and literal text, `given` ' +
|
|
774
|
-
"
|
|
777
|
+
"is one list of given names for everyone or a map by gender with " +
|
|
775
778
|
`${wordList([...requiredGivenGenders()]).replace(" or ", " and ")} required, and ` +
|
|
776
779
|
"`en` holds groups of English names, and every other key is a list of names a " +
|
|
777
780
|
"placeholder of the same name draws from. A " +
|
|
@@ -807,12 +810,17 @@ const NAMES_FIELD = Object.freeze({
|
|
|
807
810
|
}),
|
|
808
811
|
Object.freeze({
|
|
809
812
|
name: "given",
|
|
810
|
-
kind: "map",
|
|
813
|
+
kind: "list-or-map",
|
|
811
814
|
required: true,
|
|
812
|
-
shape: "a map of genders to lists of names",
|
|
815
|
+
shape: "a list of names or a map of genders to lists of names",
|
|
813
816
|
describe:
|
|
814
|
-
"
|
|
815
|
-
"list together.",
|
|
817
|
+
"One shared list supplies every gender. A gender map requires female and male; " +
|
|
818
|
+
"a gender with no list draws from every given list together.",
|
|
819
|
+
entries: Object.freeze({
|
|
820
|
+
kind: "string",
|
|
821
|
+
shape: "a name",
|
|
822
|
+
describe: "One name, as written.",
|
|
823
|
+
}),
|
|
816
824
|
values: Object.freeze({
|
|
817
825
|
kind: "list",
|
|
818
826
|
shape: "a list of names",
|
|
@@ -1246,8 +1254,8 @@ const EVENT_ADDRESS = Object.freeze({
|
|
|
1246
1254
|
});
|
|
1247
1255
|
|
|
1248
1256
|
/**
|
|
1249
|
-
* The values every carried thing declares: the four a game reads,
|
|
1250
|
-
* events of its history.
|
|
1257
|
+
* The values every carried thing declares: the four a game reads, the two
|
|
1258
|
+
* events of its history, and whether it is the only one of its kind.
|
|
1251
1259
|
*
|
|
1252
1260
|
* @type {readonly DataFieldSpec[]}
|
|
1253
1261
|
*/
|
|
@@ -1270,6 +1278,15 @@ const GEAR = Object.freeze([
|
|
|
1270
1278
|
"The event in which the thing was lost — `lore-flood`, or one event of a note " +
|
|
1271
1279
|
"as `place-ironfells#sack`.",
|
|
1272
1280
|
},
|
|
1281
|
+
{
|
|
1282
|
+
name: "unique",
|
|
1283
|
+
kind: "boolean",
|
|
1284
|
+
shape: "`true` or `false`",
|
|
1285
|
+
check: checkUniqueItem,
|
|
1286
|
+
describe:
|
|
1287
|
+
"`true` for a one-of-a-kind item, such as a named weapon; its note links the " +
|
|
1288
|
+
"places and beings involved. Absent, the item is common.",
|
|
1289
|
+
},
|
|
1273
1290
|
]);
|
|
1274
1291
|
|
|
1275
1292
|
/** Gear that is counted rather than carried singly. */
|
|
@@ -1299,7 +1316,7 @@ const CHARGES = Object.freeze([
|
|
|
1299
1316
|
* Three questions about one subject, and they are independent: who governs
|
|
1300
1317
|
* this inhabited place, what the body states about itself where the note is a
|
|
1301
1318
|
* body rather than somewhere within one, and whether a region's settlements
|
|
1302
|
-
* table agrees with
|
|
1319
|
+
* table agrees with the notes it links. A type declares
|
|
1303
1320
|
* one whole-note check, so the three are composed here rather than any being
|
|
1304
1321
|
* folded into another.
|
|
1305
1322
|
*
|
|
@@ -1312,6 +1329,7 @@ function checkPlace(note, opts) {
|
|
|
1312
1329
|
...checkGovernment(note),
|
|
1313
1330
|
...checkWorldFacts(note, opts),
|
|
1314
1331
|
...checkSettlementTables(note, opts),
|
|
1332
|
+
...checkPlaceKind(note, opts),
|
|
1315
1333
|
];
|
|
1316
1334
|
}
|
|
1317
1335
|
|
|
@@ -2492,10 +2510,12 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
2492
2510
|
subTypes: Object.freeze([
|
|
2493
2511
|
"world",
|
|
2494
2512
|
"region",
|
|
2513
|
+
"district",
|
|
2495
2514
|
"settlement",
|
|
2496
2515
|
"site",
|
|
2497
2516
|
"structure",
|
|
2498
2517
|
"feature",
|
|
2518
|
+
"route",
|
|
2499
2519
|
"celestial",
|
|
2500
2520
|
]),
|
|
2501
2521
|
check: checkPlace,
|
|
@@ -2551,6 +2571,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
2551
2571
|
"Explicit null means complete anarchy. " +
|
|
2552
2572
|
"A positive population with no government key produces an advisory.",
|
|
2553
2573
|
},
|
|
2574
|
+
SETTLEMENTS_FIELD,
|
|
2554
2575
|
{
|
|
2555
2576
|
name: "market",
|
|
2556
2577
|
...NUM,
|
|
@@ -2633,11 +2654,6 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
2633
2654
|
accepts: ["scenario"],
|
|
2634
2655
|
describe: "Scenarios that should be played before this one.",
|
|
2635
2656
|
},
|
|
2636
|
-
{
|
|
2637
|
-
name: "status",
|
|
2638
|
-
...TEXT,
|
|
2639
|
-
describe: "`draft`, `playtested` or `published`.",
|
|
2640
|
-
},
|
|
2641
2657
|
{
|
|
2642
2658
|
name: "party.size",
|
|
2643
2659
|
...TEXT,
|
package/engine/npc-generate.mjs
CHANGED
|
@@ -32,9 +32,11 @@
|
|
|
32
32
|
*
|
|
33
33
|
* - `name.full` — drawn from the culture's language's name lists by their
|
|
34
34
|
* pattern, the given name from the list for `data.gender` (see
|
|
35
|
-
* `language-names.mjs`).
|
|
36
|
-
*
|
|
37
|
-
* the
|
|
35
|
+
* `language-names.mjs`). Each drawn name word starts with a capital in the
|
|
36
|
+
* English display form; the pattern's literal connectors keep their case.
|
|
37
|
+
* A different source form with no English list part is retained under the
|
|
38
|
+
* language code. A name the spec states is kept, and must fit the source or
|
|
39
|
+
* display-form lists.
|
|
38
40
|
* - `shortcode` — the name transliterated to lowercase ASCII letters and
|
|
39
41
|
* digits, cut to twelve characters, with a counter replacing its tail where a
|
|
40
42
|
* being in the package already holds it.
|
|
@@ -70,7 +72,7 @@ import { parseNoteDate } from "./note-dates.mjs";
|
|
|
70
72
|
import { eraYear } from "./calendars.mjs";
|
|
71
73
|
import { parseAddress, renderAddress } from "./address.mjs";
|
|
72
74
|
import { isPlainMap } from "./data-keys.mjs";
|
|
73
|
-
import {
|
|
75
|
+
import { drawNameParts, nameMisfit } from "./language-names.mjs";
|
|
74
76
|
|
|
75
77
|
/** The youngest and oldest age, in whole years, a generated person is born to. */
|
|
76
78
|
export const ADULT_AGE = Object.freeze({ min: 18, max: 60 });
|
|
@@ -152,6 +154,27 @@ export function canonicalSpec(value) {
|
|
|
152
154
|
/** Whether a spec value counts as given — any value but `undefined`. */
|
|
153
155
|
const given = (value) => value !== undefined;
|
|
154
156
|
|
|
157
|
+
/** Each name word starts with a capital and keeps its remaining letters. */
|
|
158
|
+
const displayNameWords = (name) =>
|
|
159
|
+
name.replace(/\p{L}[\p{L}\p{M}'’]*/gu, (word) =>
|
|
160
|
+
word.replace(/^\p{L}/u, (letter) => letter.toUpperCase()),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
/** Name lists in English display case; literal pattern text keeps its case. */
|
|
164
|
+
function displayNameLists(names) {
|
|
165
|
+
const display = (value) =>
|
|
166
|
+
Array.isArray(value) ? value.map(displayNameWords)
|
|
167
|
+
: isPlainMap(value) ?
|
|
168
|
+
Object.fromEntries(Object.entries(value).map(([key, list]) => [key, display(list)]))
|
|
169
|
+
: value;
|
|
170
|
+
return Object.fromEntries(
|
|
171
|
+
Object.entries(names).map(([key, value]) => [
|
|
172
|
+
key,
|
|
173
|
+
key === "pattern" ? value : display(value),
|
|
174
|
+
]),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
155
178
|
/** The value at a dotted path, or `undefined`. */
|
|
156
179
|
function at(object, path) {
|
|
157
180
|
let value = object;
|
|
@@ -433,6 +456,8 @@ function frontmatterYaml(frontmatter) {
|
|
|
433
456
|
* by `namesProblems`.
|
|
434
457
|
* @param {string} [context.language] - The language's Address, for an error
|
|
435
458
|
* about a name that does not fit.
|
|
459
|
+
* @param {string} [context.languageCode] - The language code under which a
|
|
460
|
+
* generated name's distinct source form is retained.
|
|
436
461
|
* @param {string} [context.seed] - Text joined to the spec's own seed, to draw a
|
|
437
462
|
* different person from the same spec.
|
|
438
463
|
* @param {unknown} [context.currentDate] - The setting's neutral current date, defaulting to "720.1".
|
|
@@ -460,7 +485,9 @@ export function generateNpc(spec, context) {
|
|
|
460
485
|
const gender = data.gender;
|
|
461
486
|
const statedName = spec.name?.full;
|
|
462
487
|
if (given(statedName)) {
|
|
463
|
-
const
|
|
488
|
+
const displayNames = displayNameLists(names);
|
|
489
|
+
const misfit =
|
|
490
|
+
nameMisfit(statedName, names, gender) && nameMisfit(statedName, displayNames, gender);
|
|
464
491
|
if (misfit)
|
|
465
492
|
throw Object.assign(
|
|
466
493
|
new Error(
|
|
@@ -470,7 +497,25 @@ export function generateNpc(spec, context) {
|
|
|
470
497
|
{ keyPath: ["name", "full"] },
|
|
471
498
|
);
|
|
472
499
|
}
|
|
473
|
-
const
|
|
500
|
+
const nameParts = given(statedName) ? [] : drawNameParts(names, gender, draw("name.full"));
|
|
501
|
+
const sourceName = given(statedName) ? statedName : nameParts.map((part) => part.text).join("");
|
|
502
|
+
const name =
|
|
503
|
+
given(statedName) ? statedName : (
|
|
504
|
+
nameParts
|
|
505
|
+
.map((part) => ("literal" in part ? part.text : displayNameWords(part.text)))
|
|
506
|
+
.join("")
|
|
507
|
+
);
|
|
508
|
+
const languageCode = context.languageCode;
|
|
509
|
+
const sourceAlias =
|
|
510
|
+
(
|
|
511
|
+
!given(statedName) &&
|
|
512
|
+
languageCode &&
|
|
513
|
+
languageCode !== "en" &&
|
|
514
|
+
sourceName !== name &&
|
|
515
|
+
!nameParts.some((part) => "list" in part && part.list === "en")
|
|
516
|
+
) ?
|
|
517
|
+
{ [languageCode]: [sourceName] }
|
|
518
|
+
: {};
|
|
474
519
|
|
|
475
520
|
const height =
|
|
476
521
|
given(data.height) ?
|
|
@@ -537,7 +582,7 @@ export function generateNpc(spec, context) {
|
|
|
537
582
|
subType: "npc",
|
|
538
583
|
tags: ["generated", "draft"],
|
|
539
584
|
...frontmatterSpec,
|
|
540
|
-
name: { full: name, ...(isPlainMap(spec.name) ? spec.name : {}) },
|
|
585
|
+
name: { full: name, ...sourceAlias, ...(isPlainMap(spec.name) ? spec.name : {}) },
|
|
541
586
|
data: fullData,
|
|
542
587
|
},
|
|
543
588
|
TOP_ORDER,
|
|
@@ -17,9 +17,85 @@ import { parseNoteDate } from "./note-dates.mjs";
|
|
|
17
17
|
import { reckoningContext } from "./reckoning-markers.mjs";
|
|
18
18
|
|
|
19
19
|
import { HOLDER_FIELDS, OFFICE_FIELDS } from "./standing-terms.mjs";
|
|
20
|
+
import { isNoteRecord } from "./index-records.mjs";
|
|
21
|
+
import { isAddressTuple } from "./address.mjs";
|
|
22
|
+
import { officeRoster, readStandings, standingOffices } from "./standings.mjs";
|
|
20
23
|
|
|
21
24
|
export { HOLDER_FIELDS, OFFICE_FIELDS };
|
|
22
25
|
|
|
26
|
+
/**
|
|
27
|
+
* A note's identity across its systems: `package-type-shortcode`, lowercased.
|
|
28
|
+
*
|
|
29
|
+
* A body's Item and its journal carry one page, and a being names the body by
|
|
30
|
+
* its `note` Address, so the system is left out.
|
|
31
|
+
*
|
|
32
|
+
* @param {{package?: string, type?: string, shortcode?: string}} tuple
|
|
33
|
+
* @returns {string} The key, or `""` where a part is missing.
|
|
34
|
+
*/
|
|
35
|
+
export function officeBodyKey(tuple) {
|
|
36
|
+
const parts = [tuple?.package, tuple?.type, tuple?.shortcode].map((part) =>
|
|
37
|
+
String(part ?? "").toLowerCase(),
|
|
38
|
+
);
|
|
39
|
+
return parts.every(Boolean) ? parts.join("-") : "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Each body's offices with the living beings that hold them.
|
|
44
|
+
*
|
|
45
|
+
* Nothing is authored on the office for this: a being states the post it holds
|
|
46
|
+
* in its own `data.affiliations.<body>.office`, and this collects, for every
|
|
47
|
+
* office in a body's `governance.offices`, each being naming it. A being whose
|
|
48
|
+
* `data.died` is set holds nothing, so a former holder drops out and a vacant
|
|
49
|
+
* office lists no one. A post the body does not declare is the standing check's
|
|
50
|
+
* finding and is listed nowhere.
|
|
51
|
+
*
|
|
52
|
+
* Offices keep the body's declared order; holders are ordered by name.
|
|
53
|
+
*
|
|
54
|
+
* @param {object[]} records - Content-index records; only note records are
|
|
55
|
+
* read.
|
|
56
|
+
* @returns {Map<string, Map<string, object[]>>} Body identity
|
|
57
|
+
* (`package-type-shortcode`) → office → the holding beings' records, for every
|
|
58
|
+
* body with at least one held office.
|
|
59
|
+
*/
|
|
60
|
+
export function officeHolders(records) {
|
|
61
|
+
const notes = records.filter((record) => isNoteRecord(record) && record.shortcode);
|
|
62
|
+
/** Body identity → office → holding beings. */
|
|
63
|
+
const named = new Map();
|
|
64
|
+
for (const record of notes) {
|
|
65
|
+
if (record.type !== "being" || record.data?.died != null) continue;
|
|
66
|
+
for (const { body, standing } of readStandings(record.data?.affiliations).entries) {
|
|
67
|
+
const key = isAddressTuple(body) ? officeBodyKey(body) : "";
|
|
68
|
+
if (!key || !mapping(standing)) continue;
|
|
69
|
+
const offices = named.get(key) ?? new Map();
|
|
70
|
+
for (const office of standingOffices(standing)) {
|
|
71
|
+
const holders = offices.get(office) ?? [];
|
|
72
|
+
if (!holders.includes(record)) holders.push(record);
|
|
73
|
+
offices.set(office, holders);
|
|
74
|
+
}
|
|
75
|
+
named.set(key, offices);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const nameOf = (record) => String(record.name?.full ?? record.shortcode);
|
|
79
|
+
const out = new Map();
|
|
80
|
+
for (const record of notes) {
|
|
81
|
+
if (record.type !== "affiliation") continue;
|
|
82
|
+
const key = officeBodyKey(record);
|
|
83
|
+
const claims = named.get(key);
|
|
84
|
+
if (!claims) continue;
|
|
85
|
+
const held = new Map();
|
|
86
|
+
for (const office of officeRoster(record.data?.governance?.offices).keys()) {
|
|
87
|
+
const holders = claims.get(office);
|
|
88
|
+
if (holders?.length)
|
|
89
|
+
held.set(
|
|
90
|
+
office,
|
|
91
|
+
holders.toSorted((a, b) => nameOf(a).localeCompare(nameOf(b), "en")),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (held.size) out.set(key, held);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
23
99
|
const mapping = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
24
100
|
|
|
25
101
|
/**
|
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
/** Authored book page breaks and their positioned syntax findings. @module */
|
|
15
|
+
|
|
16
|
+
import { blockTokens } from "./markdown-blocks.mjs";
|
|
17
|
+
import { scanBlocks } from "./content-blocks.mjs";
|
|
18
|
+
import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
|
|
19
|
+
|
|
20
|
+
/** A standalone attributed break, including Markdown container prefixes. */
|
|
21
|
+
const BREAK_LINE =
|
|
22
|
+
/^(?<prefix>(?:[ \t]*(?:>[ \t]?|(?:[-+*]|\d{1,9}[.)])[ \t]+|\[\^[^\]\s]+\]:[ \t]*))*[ \t]*)(?<marker>---|\*\*\*|___)[ \t]+(?<attributes>\{[^\r\n]*\})[ \t]*$/;
|
|
23
|
+
const PLACEMENT = "a page break stands between blocks at the top level of a note";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read page-break markers before any surface rewrites their source.
|
|
27
|
+
* Code, HTML examples and entire verse divs remain literal. Parser ranges
|
|
28
|
+
* retain list and quote membership, including lazy continuation lines.
|
|
29
|
+
* @param {string} source - Authored Markdown body.
|
|
30
|
+
* @returns {{breaks: Array<{line: number, column: number}>, errors: Array<{line: number, column: number, message: string}>}}
|
|
31
|
+
*/
|
|
32
|
+
export function scanPageBreaks(source) {
|
|
33
|
+
const text = String(source ?? "");
|
|
34
|
+
const lines = text.split(/\r?\n/);
|
|
35
|
+
if (!lines.some((line) => BREAK_LINE.test(line))) return { breaks: [], errors: [] };
|
|
36
|
+
const literal = new Set();
|
|
37
|
+
const nested = new Set();
|
|
38
|
+
let footnoteDepth = 0;
|
|
39
|
+
for (const token of blockTokens(text, { footnotes: true })) {
|
|
40
|
+
if (token.type === "footnote_reference_open") footnoteDepth++;
|
|
41
|
+
if (token.type === "footnote_reference_close") footnoteDepth--;
|
|
42
|
+
if (!token.map) continue;
|
|
43
|
+
if (footnoteDepth)
|
|
44
|
+
for (let line = token.map[0]; line < token.map[1]; line++) nested.add(line);
|
|
45
|
+
const target =
|
|
46
|
+
["fence", "code_block", "html_block", "verse_div"].includes(token.type) ? literal
|
|
47
|
+
: ["blockquote_open", "list_item_open"].includes(token.type) ? nested
|
|
48
|
+
: undefined;
|
|
49
|
+
if (target) for (let line = token.map[0]; line < token.map[1]; line++) target.add(line);
|
|
50
|
+
}
|
|
51
|
+
for (const block of scanBlocks(text).blocks)
|
|
52
|
+
for (let line = block.start + 1; line < block.end; line++) nested.add(line);
|
|
53
|
+
const breaks = [],
|
|
54
|
+
errors = [];
|
|
55
|
+
for (let i = 0; i < lines.length; i++) {
|
|
56
|
+
if (literal.has(i)) continue;
|
|
57
|
+
const match = BREAK_LINE.exec(lines[i]);
|
|
58
|
+
if (!match) continue;
|
|
59
|
+
const { prefix, marker, attributes } = match.groups;
|
|
60
|
+
const parsed = parseExtensionAttributes(attributes.slice(1, -1));
|
|
61
|
+
if (marker !== "---" && !parsed.classes.includes("page-break")) continue;
|
|
62
|
+
const column = prefix.length + 1;
|
|
63
|
+
const attributeColumn = lines[i].lastIndexOf(attributes) + 1;
|
|
64
|
+
const at = (message) => ({ line: i + 1, column: attributeColumn, message });
|
|
65
|
+
const problems = [...parsed.problems, ...refusedAttributes(parsed.values)];
|
|
66
|
+
if (
|
|
67
|
+
parsed.classes.some((name) => name !== "page-break") ||
|
|
68
|
+
(!parsed.classes.length &&
|
|
69
|
+
!parsed.id &&
|
|
70
|
+
!Object.keys(parsed.values).length &&
|
|
71
|
+
!problems.length)
|
|
72
|
+
)
|
|
73
|
+
problems.push("a page break accepts only .page-break");
|
|
74
|
+
if (parsed.id) problems.push("a page break does not accept an id");
|
|
75
|
+
if (Object.keys(parsed.values).length)
|
|
76
|
+
problems.push("a page break does not accept attributes");
|
|
77
|
+
if (marker !== "---")
|
|
78
|
+
errors.push({ line: i + 1, column, message: "only --- accepts {.page-break}" });
|
|
79
|
+
if (
|
|
80
|
+
nested.has(i) ||
|
|
81
|
+
(i > 0 && lines[i - 1].trim()) ||
|
|
82
|
+
(i + 1 < lines.length && lines[i + 1].trim())
|
|
83
|
+
)
|
|
84
|
+
errors.push({ line: i + 1, column, message: PLACEMENT });
|
|
85
|
+
errors.push(...problems.map(at));
|
|
86
|
+
if (marker === "---" && !problems.length && !errors.some((error) => error.line === i + 1))
|
|
87
|
+
breaks.push({ line: i + 1, column });
|
|
88
|
+
}
|
|
89
|
+
return { breaks, errors };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Omit valid page breaks on a screen, preserving the surrounding block boundaries.
|
|
94
|
+
* @param {string} source - Markdown body.
|
|
95
|
+
* @returns {string} Markdown without valid page-break lines.
|
|
96
|
+
*/
|
|
97
|
+
export function omitPageBreaks(source) {
|
|
98
|
+
const lines = String(source ?? "").split("\n");
|
|
99
|
+
for (const marker of scanPageBreaks(source).breaks) lines[marker.line - 1] = "";
|
|
100
|
+
return lines.join("\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Preserve refused markers as literal Markdown before the book slices blocks.
|
|
105
|
+
* A slice must not make a marker eligible by discarding its original neighbour.
|
|
106
|
+
* Findings are collected from the original source before this rewrite.
|
|
107
|
+
* @param {string} source - Complete Markdown body, before splitting containers.
|
|
108
|
+
* @returns {string} Markdown with invalid markers escaped for literal rendering.
|
|
109
|
+
*/
|
|
110
|
+
export function escapeInvalidPageBreaks(source) {
|
|
111
|
+
const text = String(source ?? "");
|
|
112
|
+
const refused = new Set(scanPageBreaks(text).errors.map((error) => error.line));
|
|
113
|
+
if (!refused.size) return text;
|
|
114
|
+
return text
|
|
115
|
+
.split("\n")
|
|
116
|
+
.map((line, i) => {
|
|
117
|
+
if (!refused.has(i + 1)) return line;
|
|
118
|
+
const match = BREAK_LINE.exec(line.replace(/\r$/, ""));
|
|
119
|
+
const prefix = match.groups.prefix;
|
|
120
|
+
return `${prefix}\\${line.slice(prefix.length)}`;
|
|
121
|
+
})
|
|
122
|
+
.join("\n");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Produce the book's page-break token, leaving ordinary thematic breaks alone.
|
|
127
|
+
* @param {import("markdown-it")} md - The book parser.
|
|
128
|
+
* @returns {void}
|
|
129
|
+
*/
|
|
130
|
+
export function pageBreakPlugin(md) {
|
|
131
|
+
md.block.ruler.before("hr", "page_break", (state, startLine, _endLine, silent) => {
|
|
132
|
+
if (state.env.pageBreaks === false) return false;
|
|
133
|
+
const line = state.src.slice(state.bMarks[startLine], state.eMarks[startLine]);
|
|
134
|
+
if (!BREAK_LINE.test(line)) return false;
|
|
135
|
+
if (!scanPageBreaks(state.src).breaks.some((marker) => marker.line === startLine + 1))
|
|
136
|
+
return false;
|
|
137
|
+
if (silent) return true;
|
|
138
|
+
const token = state.push("page_break", "", 0);
|
|
139
|
+
token.map = [startLine, startLine + 1];
|
|
140
|
+
state.line = startLine + 1;
|
|
141
|
+
return true;
|
|
142
|
+
});
|
|
143
|
+
}
|