@heroiclands/package-build 23.3.1 → 23.4.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 +14 -0
- package/bin/content-commands.mjs +19 -4
- package/docs/authoring/first-note.md +1 -1
- package/docs/authoring/frontmatter.md +1 -1
- package/docs/commands.md +13 -2
- package/docs/content-format.md +17 -10
- package/docs/guides/release-and-deploy.md +1 -1
- package/docs/reference/format-details.md +150 -14
- package/docs/reference/note-types.md +28 -27
- package/docs/reference/theme-pages.md +5 -4
- package/engine/appearance-features.mjs +418 -0
- package/engine/content-format.yaml +1 -0
- package/engine/content-index.mjs +22 -0
- package/engine/content-links.mjs +9 -0
- package/engine/culture-parents.mjs +301 -0
- package/engine/data-keys.mjs +7 -2
- package/engine/docs-checks.mjs +41 -8
- package/engine/frontmatter-lint.mjs +15 -1
- package/engine/infobox.mjs +5 -5
- package/engine/metadata-index.mjs +11 -0
- package/engine/note-vocabulary.mjs +39 -21
- package/engine/npc-generate.mjs +5 -4
- package/package.json +3 -2
- package/types/engine/appearance-features.d.mts +66 -0
- package/types/engine/culture-parents.d.mts +29 -0
- package/types/engine/data-keys.d.mts +2 -1
- package/types/engine/docs-checks.d.mts +1 -1
- package/types/engine/note-vocabulary.d.mts +3 -3
|
@@ -0,0 +1,418 @@
|
|
|
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
|
+
* **What a being's `data.appearance.extra_features` admits.**
|
|
16
|
+
*
|
|
17
|
+
* Each entry is a short plain description of one visible physical mark: a
|
|
18
|
+
* scar, a missing finger, a tattoo, a limp. The closed look fields carry
|
|
19
|
+
* everything else, so an entry is refused when it states:
|
|
20
|
+
*
|
|
21
|
+
* - **a colour or skin condition** — a hair colour beside the word `hair`, an
|
|
22
|
+
* eye colour beside `eye` or `eyes`, a skin colour beside `skin` or
|
|
23
|
+
* `complexion`, or a skin condition. These belong in `hair_color`,
|
|
24
|
+
* `eye_color`, `skin_color` and `complexion`. The colour and condition words
|
|
25
|
+
* are the being-look vocabulary's own, read at run time, plus the spellings
|
|
26
|
+
* in {@link COLOUR_VARIANTS} and {@link CONDITION_PHRASES}. A localised mark
|
|
27
|
+
* that only mentions skin — a burn scar on the forearm — is a mark;
|
|
28
|
+
* - **anything that is not a physical mark** — the categories of
|
|
29
|
+
* {@link NON_PHYSICAL_FEATURES}: gear, clothing or a weapon; a voice or
|
|
30
|
+
* speech; behaviour, manner or temperament; an impression; handedness; sterility; an
|
|
31
|
+
* apparent-age reading;
|
|
32
|
+
* - **punctuation** — any punctuation or symbol character, apart from a hyphen
|
|
33
|
+
* or an apostrophe standing between two letters (`ink-stained fingers`, `a
|
|
34
|
+
* lion's mane`). A long punctuated entry is several facts, or a labelled one,
|
|
35
|
+
* where a single mark belongs.
|
|
36
|
+
*
|
|
37
|
+
* @module
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { isPlainMap } from "./data-keys.mjs";
|
|
41
|
+
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
42
|
+
|
|
43
|
+
/** A vocabulary word as an author writes it: `dark_brown` is "dark brown" or "dark-brown". */
|
|
44
|
+
const phrase = (word) => word.replaceAll("_", "[ -]");
|
|
45
|
+
|
|
46
|
+
/** A case-insensitive whole-word alternation. */
|
|
47
|
+
const anyOf = (alternatives) => new RegExp(`\\b(?:${alternatives.join("|")})\\b`, "iu");
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Hair and eye colour spellings the vocabulary does not take but an author
|
|
51
|
+
* writes, as regular-expression alternatives. Beside `hair` or `eyes` any of
|
|
52
|
+
* them, or any hair or eye colour word, states a colour.
|
|
53
|
+
*/
|
|
54
|
+
export const COLOUR_VARIANTS = Object.freeze([
|
|
55
|
+
"gr[ae]y(?:ing|ed)?",
|
|
56
|
+
"blond",
|
|
57
|
+
"ginger",
|
|
58
|
+
"milky",
|
|
59
|
+
"yellow",
|
|
60
|
+
"gold(?:en)?",
|
|
61
|
+
"pink",
|
|
62
|
+
"orange",
|
|
63
|
+
"purple",
|
|
64
|
+
"copper",
|
|
65
|
+
"mixed",
|
|
66
|
+
"streaked",
|
|
67
|
+
"dyed",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Skin-condition phrasings that are not complexion words, as
|
|
72
|
+
* regular-expression alternatives. Any of them states a complexion.
|
|
73
|
+
*/
|
|
74
|
+
export const CONDITION_PHRASES = Object.freeze([
|
|
75
|
+
"freckles",
|
|
76
|
+
"pox marks?",
|
|
77
|
+
"pocked",
|
|
78
|
+
"pitted skin",
|
|
79
|
+
"acne",
|
|
80
|
+
"pimples",
|
|
81
|
+
"sunburn",
|
|
82
|
+
"wrinkles",
|
|
83
|
+
"pallid",
|
|
84
|
+
"flushed",
|
|
85
|
+
"weather-beaten",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Complexion words with an everyday sense beyond the skin: refused when the
|
|
90
|
+
* entry is the word alone, or names the skin and describes no mark.
|
|
91
|
+
*/
|
|
92
|
+
const BROAD_CONDITIONS = Object.freeze(["scarred", "rough", "clear", "smooth", "oily", "wan"]);
|
|
93
|
+
|
|
94
|
+
/** An entry describing one of these is a physical mark, whatever else it mentions. */
|
|
95
|
+
const MARK =
|
|
96
|
+
/\b(?:tattoos?|tatoo|scars?|scarred|scarring|scarification|brand(?:ed)?|birthmark|burn marks?|pox marks?)\b/iu;
|
|
97
|
+
|
|
98
|
+
const SKIN = /\b(?:skin|complexion|complected)\b/iu;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The terms that make an `extra_features` entry something other than a
|
|
102
|
+
* physical mark, by category, as case-insensitive whole-word patterns.
|
|
103
|
+
*
|
|
104
|
+
* `exemptMarks` lets an entry describing a tattoo, scar, brand or burn mark
|
|
105
|
+
* pass the category, so "a tattoo of a knight's helmet" stands while "wears a
|
|
106
|
+
* helmet" does not. The voice, impression, handedness, sterility and age
|
|
107
|
+
* categories admit no such exemption: "a beautiful tattoo of a crane" is
|
|
108
|
+
* refused.
|
|
109
|
+
*
|
|
110
|
+
* @type {ReadonlyArray<{rule: string, category: string, pattern: RegExp, exemptMarks: boolean}>}
|
|
111
|
+
*/
|
|
112
|
+
export const NON_PHYSICAL_FEATURES = Object.freeze([
|
|
113
|
+
{
|
|
114
|
+
rule: "gear",
|
|
115
|
+
category: "gear, clothing or a weapon",
|
|
116
|
+
pattern: anyOf([
|
|
117
|
+
"wears?",
|
|
118
|
+
"worn",
|
|
119
|
+
"wearing",
|
|
120
|
+
"carr(?:ies|ied|ying)(?! (?:him|her|them|it)self)",
|
|
121
|
+
"cloak(?:ed|s)?",
|
|
122
|
+
"clothing",
|
|
123
|
+
"clothes",
|
|
124
|
+
"robes?",
|
|
125
|
+
"vestments",
|
|
126
|
+
"tunic",
|
|
127
|
+
"mail",
|
|
128
|
+
"armou?r",
|
|
129
|
+
"shirt",
|
|
130
|
+
"axe",
|
|
131
|
+
"sword",
|
|
132
|
+
"dagger",
|
|
133
|
+
"staff",
|
|
134
|
+
"bag",
|
|
135
|
+
"chain",
|
|
136
|
+
"cord",
|
|
137
|
+
"thong",
|
|
138
|
+
"boots?",
|
|
139
|
+
"hat",
|
|
140
|
+
"helm(?:et)?",
|
|
141
|
+
"ring",
|
|
142
|
+
"amulet",
|
|
143
|
+
"pendant",
|
|
144
|
+
"belt",
|
|
145
|
+
"weapons?",
|
|
146
|
+
"garb",
|
|
147
|
+
"dress(?:ed|es)?",
|
|
148
|
+
"gloves?",
|
|
149
|
+
]),
|
|
150
|
+
exemptMarks: true,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
rule: "voice",
|
|
154
|
+
category: "a voice or speech",
|
|
155
|
+
pattern: anyOf([
|
|
156
|
+
"voice",
|
|
157
|
+
"speech",
|
|
158
|
+
"speaks?",
|
|
159
|
+
"spoken",
|
|
160
|
+
"accent",
|
|
161
|
+
"lisp",
|
|
162
|
+
"stutter\\w*",
|
|
163
|
+
"stammer\\w*",
|
|
164
|
+
"whisper\\w*",
|
|
165
|
+
"tone",
|
|
166
|
+
"laugh\\w*",
|
|
167
|
+
"sings?",
|
|
168
|
+
"singing",
|
|
169
|
+
]),
|
|
170
|
+
exemptMarks: false,
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
rule: "behaviour",
|
|
174
|
+
category: "behaviour, manner or temperament",
|
|
175
|
+
pattern: anyOf([
|
|
176
|
+
"moves?",
|
|
177
|
+
"moving",
|
|
178
|
+
"fidgets?",
|
|
179
|
+
"smil\\w+",
|
|
180
|
+
"grin\\w*",
|
|
181
|
+
"scowl\\w*",
|
|
182
|
+
"frown\\w*",
|
|
183
|
+
"gaze",
|
|
184
|
+
"stares?",
|
|
185
|
+
"glare\\w*",
|
|
186
|
+
"poise",
|
|
187
|
+
"bearing",
|
|
188
|
+
"demeanou?r",
|
|
189
|
+
"manner",
|
|
190
|
+
"habits?",
|
|
191
|
+
"grooming",
|
|
192
|
+
"expressive",
|
|
193
|
+
"intensity",
|
|
194
|
+
"shakes?",
|
|
195
|
+
"air of",
|
|
196
|
+
"carr(?:y|ies|ied|ying) (?:him|her|them|it)self",
|
|
197
|
+
"hyperactive",
|
|
198
|
+
"restless\\w*",
|
|
199
|
+
"jitter\\w*",
|
|
200
|
+
"twitch\\w*",
|
|
201
|
+
"nervous\\w*",
|
|
202
|
+
"anxious\\w*",
|
|
203
|
+
"energetic",
|
|
204
|
+
"lively",
|
|
205
|
+
"cheerful",
|
|
206
|
+
"sullen",
|
|
207
|
+
"shy",
|
|
208
|
+
"calm",
|
|
209
|
+
"placid",
|
|
210
|
+
"fidgety",
|
|
211
|
+
]),
|
|
212
|
+
exemptMarks: true,
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
rule: "impression",
|
|
216
|
+
category: "an impression rather than a mark",
|
|
217
|
+
pattern: anyOf([
|
|
218
|
+
"beautiful",
|
|
219
|
+
"handsome",
|
|
220
|
+
"attractive",
|
|
221
|
+
"ugly",
|
|
222
|
+
"imposing",
|
|
223
|
+
"menacing",
|
|
224
|
+
"unnerv\\w+",
|
|
225
|
+
"disarming",
|
|
226
|
+
"commanding",
|
|
227
|
+
"presence",
|
|
228
|
+
"authority",
|
|
229
|
+
"pretty",
|
|
230
|
+
"lovely",
|
|
231
|
+
"gorgeous",
|
|
232
|
+
"stunning",
|
|
233
|
+
"comely",
|
|
234
|
+
"homely",
|
|
235
|
+
"hideous",
|
|
236
|
+
"striking\\w*",
|
|
237
|
+
"alluring",
|
|
238
|
+
"dashing",
|
|
239
|
+
"rugged",
|
|
240
|
+
"elegant\\w*",
|
|
241
|
+
"grace\\w*",
|
|
242
|
+
"feminine",
|
|
243
|
+
"masculine",
|
|
244
|
+
"girly",
|
|
245
|
+
"good looks",
|
|
246
|
+
]),
|
|
247
|
+
exemptMarks: false,
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
rule: "handedness",
|
|
251
|
+
category: "handedness",
|
|
252
|
+
pattern: /\b(?:left|right)[- ]handed\b|\bambidextrous\b|\bhandedness\b|\bsouthpaw\b/iu,
|
|
253
|
+
exemptMarks: false,
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
rule: "sterility",
|
|
257
|
+
category: "sterility",
|
|
258
|
+
pattern: anyOf(["steril\\w*", "infertil\\w*", "barren", "childless"]),
|
|
259
|
+
exemptMarks: false,
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
rule: "age",
|
|
263
|
+
category: "an apparent-age reading",
|
|
264
|
+
pattern: anyOf([
|
|
265
|
+
"look(?:s|ing)? (?:younger|older|young|old)",
|
|
266
|
+
"younger than",
|
|
267
|
+
"older than",
|
|
268
|
+
"appears? (?:\\w+ )?(?:years|young|old)",
|
|
269
|
+
"apparent age",
|
|
270
|
+
"youthful",
|
|
271
|
+
"boyish",
|
|
272
|
+
"girlish",
|
|
273
|
+
"childlike",
|
|
274
|
+
"ageless",
|
|
275
|
+
"elderly",
|
|
276
|
+
]),
|
|
277
|
+
exemptMarks: false,
|
|
278
|
+
},
|
|
279
|
+
]);
|
|
280
|
+
|
|
281
|
+
/** A hyphen or an apostrophe standing between two letters, which an entry may carry. */
|
|
282
|
+
const JOINER = /(?<=\p{L})[-‐'’](?=\p{L})/gu;
|
|
283
|
+
|
|
284
|
+
/** Any punctuation or symbol character. */
|
|
285
|
+
const PUNCTUATION = /[\p{P}\p{S}]/gu;
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The punctuation an entry carries, a hyphen or apostrophe between letters
|
|
289
|
+
* excepted.
|
|
290
|
+
*
|
|
291
|
+
* @param {string} entry - The entry as written.
|
|
292
|
+
* @returns {string[]} Each distinct character, in order of appearance.
|
|
293
|
+
*/
|
|
294
|
+
export function featurePunctuation(entry) {
|
|
295
|
+
return [...new Set(entry.replace(JOINER, "").match(PUNCTUATION) ?? [])];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The colour and condition patterns, built once per vocabulary. */
|
|
299
|
+
const patterns = new WeakMap();
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The colour and condition patterns of one being-look vocabulary.
|
|
303
|
+
*
|
|
304
|
+
* @param {Readonly<Record<string, readonly {value: string}[]>>} look
|
|
305
|
+
* @returns {{colour: RegExp, skin: RegExp, condition: RegExp, broad: RegExp, bareBroad: RegExp}}
|
|
306
|
+
*/
|
|
307
|
+
function lookPatterns(look) {
|
|
308
|
+
let built = patterns.get(look);
|
|
309
|
+
if (built) return built;
|
|
310
|
+
const words = (field) => (look[field] ?? []).map(({ value }) => value);
|
|
311
|
+
const complexion = words("appearance.complexion");
|
|
312
|
+
const broad = BROAD_CONDITIONS.filter((word) => complexion.includes(word));
|
|
313
|
+
built = {
|
|
314
|
+
colour: anyOf(
|
|
315
|
+
[...new Set([...words("appearance.hair_color"), ...words("appearance.eye_color")])]
|
|
316
|
+
.map(phrase)
|
|
317
|
+
.concat(COLOUR_VARIANTS),
|
|
318
|
+
),
|
|
319
|
+
skin: anyOf(words("appearance.skin_color").map(phrase)),
|
|
320
|
+
condition: anyOf([
|
|
321
|
+
...complexion.filter((word) => !broad.includes(word)).map(phrase),
|
|
322
|
+
...CONDITION_PHRASES,
|
|
323
|
+
]),
|
|
324
|
+
broad: anyOf(broad.map(phrase)),
|
|
325
|
+
bareBroad: new RegExp(`^(?:${broad.map(phrase).join("|")})$`, "iu"),
|
|
326
|
+
};
|
|
327
|
+
patterns.set(look, built);
|
|
328
|
+
return built;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Why an `extra_features` entry is not a short plain description of one
|
|
333
|
+
* physical mark.
|
|
334
|
+
*
|
|
335
|
+
* @param {string} entry - The entry as written.
|
|
336
|
+
* @param {Readonly<Record<string, readonly {value: string}[]>>} look - The
|
|
337
|
+
* being-look vocabulary.
|
|
338
|
+
* @returns {Array<{rule: string, message: string}>} One problem per rule the
|
|
339
|
+
* entry breaks; empty when it is a mark. `rule` is `hair-colour`,
|
|
340
|
+
* `eye-colour`, `skin-colour`, `complexion`, `punctuation`, or a rule of
|
|
341
|
+
* {@link NON_PHYSICAL_FEATURES}.
|
|
342
|
+
*/
|
|
343
|
+
export function featureProblems(entry, look) {
|
|
344
|
+
const p = lookPatterns(look);
|
|
345
|
+
const has = (re) => re.test(entry);
|
|
346
|
+
const isMark = has(MARK);
|
|
347
|
+
const problems = [];
|
|
348
|
+
if (has(/\bhair\b/iu) && has(p.colour))
|
|
349
|
+
problems.push({
|
|
350
|
+
rule: "hair-colour",
|
|
351
|
+
message: "states a hair colour, which belongs in `appearance.hair_color`",
|
|
352
|
+
});
|
|
353
|
+
if (has(/\beyes?\b/iu) && has(p.colour))
|
|
354
|
+
problems.push({
|
|
355
|
+
rule: "eye-colour",
|
|
356
|
+
message: "states an eye colour, which belongs in `appearance.eye_color`",
|
|
357
|
+
});
|
|
358
|
+
if (has(SKIN) && has(p.skin))
|
|
359
|
+
problems.push({
|
|
360
|
+
rule: "skin-colour",
|
|
361
|
+
message: "states a skin colour, which belongs in `appearance.skin_color`",
|
|
362
|
+
});
|
|
363
|
+
if (
|
|
364
|
+
has(p.condition) ||
|
|
365
|
+
p.bareBroad.test(entry.trim()) ||
|
|
366
|
+
(has(p.broad) && has(SKIN) && !isMark)
|
|
367
|
+
)
|
|
368
|
+
problems.push({
|
|
369
|
+
rule: "complexion",
|
|
370
|
+
message: "states a skin condition, which belongs in `appearance.complexion`",
|
|
371
|
+
});
|
|
372
|
+
for (const { rule, category, pattern, exemptMarks } of NON_PHYSICAL_FEATURES) {
|
|
373
|
+
if (exemptMarks && isMark) continue;
|
|
374
|
+
if (has(pattern))
|
|
375
|
+
problems.push({ rule, message: `names ${category}, which is not a physical mark` });
|
|
376
|
+
}
|
|
377
|
+
const marks = featurePunctuation(entry);
|
|
378
|
+
if (marks.length)
|
|
379
|
+
problems.push({
|
|
380
|
+
rule: "punctuation",
|
|
381
|
+
message:
|
|
382
|
+
"carries punctuation " +
|
|
383
|
+
marks.map((mark) => `\`${mark}\``).join(" ") +
|
|
384
|
+
"; an extra feature is a short plain description of one visible mark, " +
|
|
385
|
+
"with no punctuation but a hyphen or apostrophe inside a word",
|
|
386
|
+
});
|
|
387
|
+
return problems;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The frontmatter lint's check of `data.appearance.extra_features`.
|
|
392
|
+
*
|
|
393
|
+
* @param {object} note - The note: `file`, `raw` and parsed `fm`.
|
|
394
|
+
* @param {Readonly<Record<string, readonly {value: string}[]>>} look - The
|
|
395
|
+
* being-look vocabulary.
|
|
396
|
+
* @returns {object[]} Error findings, each located at the entry at fault.
|
|
397
|
+
*/
|
|
398
|
+
export function checkExtraFeatures(note, look) {
|
|
399
|
+
const appearance = note?.fm?.data?.appearance;
|
|
400
|
+
if (!isPlainMap(appearance)) return [];
|
|
401
|
+
const features = appearance.extra_features;
|
|
402
|
+
if (!Array.isArray(features)) return [];
|
|
403
|
+
const raw = note.raw ?? "";
|
|
404
|
+
const findings = [];
|
|
405
|
+
features.forEach((entry, index) => {
|
|
406
|
+
if (typeof entry !== "string") return;
|
|
407
|
+
const path = ["data", "appearance", "extra_features", index];
|
|
408
|
+
for (const { message } of featureProblems(entry, look)) {
|
|
409
|
+
findings.push({
|
|
410
|
+
file: note.file,
|
|
411
|
+
...positionOfFrontmatterPath(raw, path),
|
|
412
|
+
severity: "error",
|
|
413
|
+
message: `\`data.appearance.extra_features[${index}]\` ${message}, but reads ${JSON.stringify(entry)}`,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
return findings;
|
|
418
|
+
}
|
package/engine/content-index.mjs
CHANGED
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
*/
|
|
87
87
|
|
|
88
88
|
import { isAddressSegment } from "./address-charset.mjs";
|
|
89
|
+
import { cultureInheritance, reportCultureParents } from "./culture-parents.mjs";
|
|
89
90
|
import {
|
|
90
91
|
formatDiagnostic,
|
|
91
92
|
locateFrontmatterKey,
|
|
@@ -1178,6 +1179,27 @@ export function collectContentIndex(
|
|
|
1178
1179
|
// is found by its canonical address. Before the sort, which reads nothing
|
|
1179
1180
|
// the pass writes.
|
|
1180
1181
|
resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems });
|
|
1182
|
+
const cultureNotes = records
|
|
1183
|
+
.filter((record) => isNoteRecord(record) && record.type === "lore")
|
|
1184
|
+
.map((record) => ({
|
|
1185
|
+
fm: record,
|
|
1186
|
+
file: noteFile(contentBase, record),
|
|
1187
|
+
raw: fs.readFileSync(noteFile(contentBase, record), "utf8"),
|
|
1188
|
+
}));
|
|
1189
|
+
const cultureByKey = new Map(
|
|
1190
|
+
cultureNotes.map((note) => [
|
|
1191
|
+
canonicalKey(contentPackage, "note", "lore", note.fm.shortcode),
|
|
1192
|
+
note,
|
|
1193
|
+
]),
|
|
1194
|
+
);
|
|
1195
|
+
const inheritance = cultureInheritance({
|
|
1196
|
+
contentPackage,
|
|
1197
|
+
notes: cultureNotes,
|
|
1198
|
+
addressHit: (key) => cultureByKey.get(key) ?? foreignRecord?.(key),
|
|
1199
|
+
});
|
|
1200
|
+
reportCultureParents(inheritance.findings, problems);
|
|
1201
|
+
for (const [note, inherited] of inheritance.values)
|
|
1202
|
+
if (Object.keys(inherited).length) note.fm.data = { ...note.fm.data, ...inherited };
|
|
1181
1203
|
|
|
1182
1204
|
// Sorted at every depth like a note's record, and for the same reason: the
|
|
1183
1205
|
// declaration order of the `asset` fields is a fact about the emitter, not
|
package/engine/content-links.mjs
CHANGED
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
import { readWikilink } from "./wikilink-syntax.mjs";
|
|
56
56
|
import { encodeAddresses } from "./address-values.mjs";
|
|
57
57
|
import fs from "node:fs";
|
|
58
|
+
import { cultureRecordProblems, cultureInheritedFields } from "./culture-parents.mjs";
|
|
58
59
|
import path from "node:path";
|
|
59
60
|
|
|
60
61
|
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
@@ -224,6 +225,14 @@ export function buildLinkIndex(
|
|
|
224
225
|
const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, "");
|
|
225
226
|
const type = record.type.toLowerCase();
|
|
226
227
|
const note = { file: absPath, rel, fm, body, raw, type };
|
|
228
|
+
const hierarchyProblems = cultureRecordProblems(record);
|
|
229
|
+
note.inheritedCultureFields = cultureInheritedFields(record);
|
|
230
|
+
if (hierarchyProblems)
|
|
231
|
+
note.reportedCultureParents = new Set(
|
|
232
|
+
hierarchyProblems
|
|
233
|
+
.filter((problem) => problem.file === absPath)
|
|
234
|
+
.map((problem) => problem.parentIndex),
|
|
235
|
+
);
|
|
227
236
|
|
|
228
237
|
for (const hit of frontmatterWikilinks(fm)) {
|
|
229
238
|
frontmatterLinks.push({ note, ...hit });
|