@heroiclands/package-build 23.3.0 → 23.3.2
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 +18 -0
- package/bin/content-commands.mjs +308 -2
- package/docs/commands.md +191 -0
- package/docs/content-format.md +9 -0
- package/docs/reference/format-details.md +321 -44
- package/docs/reference/note-types.md +98 -80
- package/engine/appearance-profile.mjs +330 -0
- package/engine/content-format.yaml +3 -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 +20 -4
- package/engine/frontmatter-lint.mjs +15 -1
- package/engine/language-names.mjs +634 -0
- package/engine/literature-notes.mjs +62 -27
- package/engine/metadata-index.mjs +11 -0
- package/engine/note-vocabulary.mjs +171 -2
- package/engine/npc-generate.mjs +633 -0
- package/package.json +1 -1
- package/types/engine/appearance-profile.d.mts +94 -0
- package/types/engine/culture-parents.d.mts +29 -0
- package/types/engine/data-keys.d.mts +2 -2
- package/types/engine/language-names.d.mts +140 -0
- package/types/engine/literature-notes.d.mts +14 -3
- package/types/engine/npc-generate.d.mts +124 -0
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 });
|
|
@@ -0,0 +1,301 @@
|
|
|
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
|
+
import { isDeepStrictEqual } from "node:util";
|
|
15
|
+
import { canonicalKey, parseAddress, renderAddress } from "./address.mjs";
|
|
16
|
+
import { positionOfFrontmatterPath, formatDiagnostic } from "./diagnostics.mjs";
|
|
17
|
+
|
|
18
|
+
const fields = ["language", "appearanceProfile"];
|
|
19
|
+
const payload = (note) => note.fm ?? note;
|
|
20
|
+
const dataOf = (note) => payload(note).data ?? payload(note);
|
|
21
|
+
const culture = (note) => payload(note).type === "lore" && payload(note).subType === "culture";
|
|
22
|
+
const parentsOf = (note) => (Array.isArray(dataOf(note).parents) ? dataOf(note).parents : []);
|
|
23
|
+
|
|
24
|
+
/** Read a culture parent against the field's Address defaults. */
|
|
25
|
+
function address(value, index) {
|
|
26
|
+
const tuple = parseAddress(value, {
|
|
27
|
+
package: index.contentPackage,
|
|
28
|
+
system: "note",
|
|
29
|
+
type: "lore",
|
|
30
|
+
types: index.types,
|
|
31
|
+
packages: index.packages,
|
|
32
|
+
});
|
|
33
|
+
return tuple.reason ? undefined : tuple;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The authored edge graph, with every cyclic edge identified. */
|
|
37
|
+
function graph(index) {
|
|
38
|
+
const out = new Map();
|
|
39
|
+
const queue = [...(index.notes ?? [])].filter(culture);
|
|
40
|
+
while (queue.length) {
|
|
41
|
+
const note = queue.pop();
|
|
42
|
+
if (out.has(note)) continue;
|
|
43
|
+
const edges = [];
|
|
44
|
+
out.set(note, edges);
|
|
45
|
+
parentsOf(note).forEach((value, position) => {
|
|
46
|
+
const tuple = address(value, index);
|
|
47
|
+
if (!tuple || tuple.type !== "lore") return;
|
|
48
|
+
const target = index.addressHit?.(renderAddress(tuple));
|
|
49
|
+
if (!target || !culture(target)) return;
|
|
50
|
+
edges.push({ target, position });
|
|
51
|
+
queue.push(target);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const reaches = (start, end) => {
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
const queue = [start];
|
|
57
|
+
while (queue.length) {
|
|
58
|
+
const current = queue.pop();
|
|
59
|
+
if (current === end) return true;
|
|
60
|
+
if (seen.has(current)) continue;
|
|
61
|
+
seen.add(current);
|
|
62
|
+
queue.push(...(out.get(current) ?? []).map((edge) => edge.target));
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
};
|
|
66
|
+
const cyclic = new Map();
|
|
67
|
+
for (const [note, edges] of out)
|
|
68
|
+
cyclic.set(
|
|
69
|
+
note,
|
|
70
|
+
new Set(
|
|
71
|
+
edges.filter((edge) => reaches(edge.target, note)).map((edge) => edge.position),
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
return { out, cyclic };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A located hierarchy finding with the Address of the culture it concerns. */
|
|
78
|
+
function finding(note, index, path, message) {
|
|
79
|
+
const fm = payload(note);
|
|
80
|
+
return {
|
|
81
|
+
file: note.file,
|
|
82
|
+
...positionOfFrontmatterPath(note.raw ?? "", ["data", "parents", ...path]),
|
|
83
|
+
severity: "error",
|
|
84
|
+
code: "culture-parents",
|
|
85
|
+
cultureAddress: canonicalKey(index.contentPackage, "note", "lore", fm.shortcode),
|
|
86
|
+
parentIndex: path[0],
|
|
87
|
+
message,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Validate subtype scope and culture targets; unresolved targets use the ordinary reference check. */
|
|
92
|
+
function parentFindings(note, index, analysis, unresolved) {
|
|
93
|
+
const data = dataOf(note);
|
|
94
|
+
if (data.parents === undefined || data.parents === null) return [];
|
|
95
|
+
if (!culture(note))
|
|
96
|
+
return [finding(note, index, [], "data.parents is stated by only culture lore notes")];
|
|
97
|
+
const findings = [];
|
|
98
|
+
parentsOf(note).forEach((value, position) => {
|
|
99
|
+
const tuple = address(value, index);
|
|
100
|
+
if (!tuple) return;
|
|
101
|
+
const key = renderAddress(tuple);
|
|
102
|
+
const target = tuple.type === "lore" ? index.addressHit?.(key) : undefined;
|
|
103
|
+
if (tuple.type !== "lore") {
|
|
104
|
+
if (unresolved)
|
|
105
|
+
findings.push(
|
|
106
|
+
finding(
|
|
107
|
+
note,
|
|
108
|
+
index,
|
|
109
|
+
[position],
|
|
110
|
+
`data.parents.${position} accepts lore, not ${tuple.type}`,
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
} else if (!target) {
|
|
114
|
+
if (unresolved)
|
|
115
|
+
findings.push(
|
|
116
|
+
finding(
|
|
117
|
+
note,
|
|
118
|
+
index,
|
|
119
|
+
[position],
|
|
120
|
+
`data.parents.${position} names ${key}, which does not resolve in this package or its declared dependencies`,
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
} else if (!culture(target))
|
|
124
|
+
findings.push(
|
|
125
|
+
finding(
|
|
126
|
+
note,
|
|
127
|
+
index,
|
|
128
|
+
[position],
|
|
129
|
+
`data.parents.${position} names ${key}, whose subType is not culture`,
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
else if (analysis.cyclic.get(note)?.has(position))
|
|
133
|
+
findings.push(
|
|
134
|
+
finding(
|
|
135
|
+
note,
|
|
136
|
+
index,
|
|
137
|
+
[position],
|
|
138
|
+
`data.parents.${position} forms a culture parent cycle; a culture is never its own ancestor`,
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
return findings;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const graphs = new WeakMap();
|
|
146
|
+
const hierarchyProblems = new WeakMap();
|
|
147
|
+
const inheritedFields = new WeakMap();
|
|
148
|
+
const inheritedSources = new WeakMap();
|
|
149
|
+
|
|
150
|
+
/** The authored source of one inherited field, retained outside the published record. */
|
|
151
|
+
export function cultureFieldSource(records, key, field) {
|
|
152
|
+
const record = records.find(
|
|
153
|
+
(entry) =>
|
|
154
|
+
entry.type === "lore" &&
|
|
155
|
+
entry.subType === "culture" &&
|
|
156
|
+
canonicalKey(entry.package, "note", "lore", entry.shortcode) === key,
|
|
157
|
+
);
|
|
158
|
+
return record && inheritedSources.get(record)?.get(field);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Fields supplied by an ancestor rather than authored on this record. */
|
|
162
|
+
export function cultureInheritedFields(record) {
|
|
163
|
+
return inheritedFields.get(record);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Read the hierarchy findings retained beside a resolved index record. */
|
|
167
|
+
export function cultureRecordProblems(record) {
|
|
168
|
+
return hierarchyProblems.get(record);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Read hierarchy findings attached to one culture's resolved index record. */
|
|
172
|
+
export function cultureHierarchyProblems(records, key) {
|
|
173
|
+
return records.flatMap((record) =>
|
|
174
|
+
(
|
|
175
|
+
record.type === "lore" &&
|
|
176
|
+
record.subType === "culture" &&
|
|
177
|
+
canonicalKey(record.package, "note", "lore", record.shortcode) === key
|
|
178
|
+
) ?
|
|
179
|
+
(hierarchyProblems.get(record) ?? [])
|
|
180
|
+
: [],
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Check the declared culture-parent field in the normal frontmatter lint.
|
|
186
|
+
* @param {object} note - The note's frontmatter and source location.
|
|
187
|
+
* @param {{index?: object}} [options] - The corpus and its Address lookup.
|
|
188
|
+
* @returns {object[]} Located findings.
|
|
189
|
+
*/
|
|
190
|
+
export function checkCultureParents(note, { index } = {}) {
|
|
191
|
+
if (note.reportedCultureParents) return [];
|
|
192
|
+
const context = index ?? { contentPackage: undefined, notes: [note] };
|
|
193
|
+
let analysis = graphs.get(context);
|
|
194
|
+
if (!analysis) {
|
|
195
|
+
analysis = graph(context);
|
|
196
|
+
graphs.set(context, analysis);
|
|
197
|
+
}
|
|
198
|
+
return parentFindings(note, context, analysis, false);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Resolve each omitted culture field independently, searching parents in listed depth-first order.
|
|
203
|
+
* Same-depth disagreement withholds the inherited field and produces a finding.
|
|
204
|
+
* @param {object} index - The authored culture nodes and Address lookup.
|
|
205
|
+
* @returns {{values: Map<object, object>, findings: object[]}}
|
|
206
|
+
*/
|
|
207
|
+
export function cultureInheritance(index) {
|
|
208
|
+
const analysis = graph(index);
|
|
209
|
+
const findings = [];
|
|
210
|
+
const values = new Map();
|
|
211
|
+
for (const note of index.notes ?? []) {
|
|
212
|
+
if (!culture(note)) continue;
|
|
213
|
+
findings.push(...parentFindings(note, index, analysis, true));
|
|
214
|
+
const inherited = {};
|
|
215
|
+
const sources = new Map();
|
|
216
|
+
for (const field of fields) {
|
|
217
|
+
if (dataOf(note)[field] !== undefined) continue;
|
|
218
|
+
const candidates = [];
|
|
219
|
+
const work = [{ node: note, depth: 0 }];
|
|
220
|
+
const seen = new Map();
|
|
221
|
+
while (work.length) {
|
|
222
|
+
const { node, depth, via } = work.pop();
|
|
223
|
+
if (analysis.cyclic.get(node)?.size) continue;
|
|
224
|
+
if (seen.has(node) && seen.get(node) <= depth) continue;
|
|
225
|
+
seen.set(node, depth);
|
|
226
|
+
const value = dataOf(node)[field];
|
|
227
|
+
if (depth && value !== undefined) {
|
|
228
|
+
candidates.push({ value, depth, via, node });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const edges = analysis.out.get(node) ?? [];
|
|
232
|
+
for (let i = edges.length - 1; i >= 0; i--) {
|
|
233
|
+
const edge = edges[i];
|
|
234
|
+
if (!analysis.cyclic.get(node)?.has(edge.position))
|
|
235
|
+
work.push({
|
|
236
|
+
node: edge.target,
|
|
237
|
+
depth: depth + 1,
|
|
238
|
+
via: depth ? via : edge.position,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const atDepth = new Map();
|
|
243
|
+
let conflicting;
|
|
244
|
+
for (const candidate of candidates) {
|
|
245
|
+
if (
|
|
246
|
+
atDepth.has(candidate.depth) &&
|
|
247
|
+
!isDeepStrictEqual(atDepth.get(candidate.depth), candidate.value)
|
|
248
|
+
)
|
|
249
|
+
conflicting = candidate;
|
|
250
|
+
else atDepth.set(candidate.depth, candidate.value);
|
|
251
|
+
}
|
|
252
|
+
if (conflicting)
|
|
253
|
+
findings.push(
|
|
254
|
+
finding(
|
|
255
|
+
note,
|
|
256
|
+
index,
|
|
257
|
+
[conflicting.via],
|
|
258
|
+
`culture parents at the same depth disagree on data.${field}; state the field on this culture`,
|
|
259
|
+
),
|
|
260
|
+
);
|
|
261
|
+
else if (candidates.length) {
|
|
262
|
+
inherited[field] = candidates[0].value;
|
|
263
|
+
const source = candidates[0].node;
|
|
264
|
+
sources.set(field, { file: source.file, raw: source.raw, fm: payload(source) });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
inheritedSources.set(payload(note), sources);
|
|
268
|
+
values.set(note, inherited);
|
|
269
|
+
}
|
|
270
|
+
for (const note of index.notes ?? []) {
|
|
271
|
+
if (!culture(note)) continue;
|
|
272
|
+
inheritedFields.set(payload(note), new Set(Object.keys(values.get(note) ?? {})));
|
|
273
|
+
const relevant = new Set();
|
|
274
|
+
const work = [note];
|
|
275
|
+
while (work.length) {
|
|
276
|
+
const current = work.pop();
|
|
277
|
+
if (relevant.has(current)) continue;
|
|
278
|
+
relevant.add(current);
|
|
279
|
+
work.push(...(analysis.out.get(current) ?? []).map((edge) => edge.target));
|
|
280
|
+
}
|
|
281
|
+
const addresses = new Set(
|
|
282
|
+
[...relevant].map((entry) =>
|
|
283
|
+
canonicalKey(index.contentPackage, "note", "lore", payload(entry).shortcode),
|
|
284
|
+
),
|
|
285
|
+
);
|
|
286
|
+
hierarchyProblems.set(
|
|
287
|
+
payload(note),
|
|
288
|
+
findings.filter((problem) => addresses.has(problem.cultureAddress)),
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
return { values, findings };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Emit or collect all findings from culture inheritance. */
|
|
295
|
+
export function reportCultureParents(findings, problems) {
|
|
296
|
+
if (problems) problems.push(...findings);
|
|
297
|
+
else if (findings.length)
|
|
298
|
+
throw Object.assign(new Error(findings.map(formatDiagnostic).join("\n")), {
|
|
299
|
+
located: true,
|
|
300
|
+
});
|
|
301
|
+
}
|
package/engine/data-keys.mjs
CHANGED
|
@@ -33,6 +33,11 @@
|
|
|
33
33
|
* Shortcode, a pack, a name), and this describes the value under each key.
|
|
34
34
|
* The keys themselves are checked as the field's `keyKind` says.
|
|
35
35
|
*
|
|
36
|
+
* A map declaring both `fields` and `values` holds its declared keys, each
|
|
37
|
+
* checked by its own declaration, beside keys named by the author, each
|
|
38
|
+
* checked by `values`. A language's `data.names` is one: `pattern` and
|
|
39
|
+
* `given` beside the lists the language names for itself.
|
|
40
|
+
*
|
|
36
41
|
* Dotted field names (`appearance.eye_color`) declare a container's keys by
|
|
37
42
|
* their segments, and the container is closed to them just the same.
|
|
38
43
|
*
|
|
@@ -57,7 +62,7 @@ import { nearest } from "./near-miss.mjs";
|
|
|
57
62
|
* @property {string} name - The key as an author writes it.
|
|
58
63
|
* @property {string} [kind] - Its value's shape: `string`, `date`, `number`,
|
|
59
64
|
* `integer`, `boolean`, `address`, `list`, `map`, `scalar-or-map`,
|
|
60
|
-
* or `string-or-
|
|
65
|
+
* `string-or-list`, or `string-list-or-map` — see {@link fitsKind}. Absent, the check
|
|
61
66
|
* makes no claim about the value.
|
|
62
67
|
* @property {readonly string[]} [allowed] - The closed set a present value is
|
|
63
68
|
* one of, where the key takes a fixed word.
|
|
@@ -145,6 +150,10 @@ export function fitsKind(value, kind) {
|
|
|
145
150
|
return isScalar(value) || fitsKind(value, "map");
|
|
146
151
|
case "string-or-list":
|
|
147
152
|
return isScalar(value) || Array.isArray(value);
|
|
153
|
+
// One value, a list of them described by `entries`, or a map of them
|
|
154
|
+
// described by `values`.
|
|
155
|
+
case "string-list-or-map":
|
|
156
|
+
return fitsKind(value, "string-or-list") || fitsKind(value, "map");
|
|
148
157
|
default:
|
|
149
158
|
return true;
|
|
150
159
|
}
|
|
@@ -258,7 +267,7 @@ export function checkDataKeys(note, fields) {
|
|
|
258
267
|
*/
|
|
259
268
|
const walk = (spec, value, path) => {
|
|
260
269
|
if (spec.fields && isPlainMap(value)) {
|
|
261
|
-
closeMap(spec.fields, value, path);
|
|
270
|
+
closeMap(spec.fields, value, path, spec.values);
|
|
262
271
|
return;
|
|
263
272
|
}
|
|
264
273
|
if (Array.isArray(value)) {
|
|
@@ -308,11 +317,18 @@ export function checkDataKeys(note, fields) {
|
|
|
308
317
|
return false;
|
|
309
318
|
};
|
|
310
319
|
|
|
311
|
-
/**
|
|
312
|
-
|
|
320
|
+
/**
|
|
321
|
+
* Close one map to its declared keys, and walk each that is present. With
|
|
322
|
+
* `rest`, a key it does not declare is walked against `rest` instead.
|
|
323
|
+
*/
|
|
324
|
+
const closeMap = (inner, value, path, rest) => {
|
|
313
325
|
const names = inner.map((spec) => spec.name);
|
|
314
326
|
for (const key of Object.keys(value)) {
|
|
315
327
|
if (names.includes(key)) continue;
|
|
328
|
+
if (rest) {
|
|
329
|
+
visit(rest, value[key], [...path, key]);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
316
332
|
const guess = nearest(key, names);
|
|
317
333
|
findings.push(
|
|
318
334
|
finding(
|
|
@@ -412,6 +412,7 @@ function checkDataContainer(note, { type, fields, packs, addressContext, index }
|
|
|
412
412
|
}
|
|
413
413
|
|
|
414
414
|
for (const field of fields) {
|
|
415
|
+
if (note.inheritedCultureFields?.has(field.name)) continue;
|
|
415
416
|
if (!field.kind) continue;
|
|
416
417
|
const segments = field.name.split(".");
|
|
417
418
|
let value = entries;
|
|
@@ -457,7 +458,12 @@ function checkDataContainer(note, { type, fields, packs, addressContext, index }
|
|
|
457
458
|
|
|
458
459
|
// One level down and every level below: each field's value is closed to
|
|
459
460
|
// the inner keys its declaration states, as the container is to its own.
|
|
460
|
-
findings.push(
|
|
461
|
+
findings.push(
|
|
462
|
+
...checkDataKeys(
|
|
463
|
+
note,
|
|
464
|
+
fields.filter((field) => !note.inheritedCultureFields?.has(field.name)),
|
|
465
|
+
),
|
|
466
|
+
);
|
|
461
467
|
|
|
462
468
|
return findings;
|
|
463
469
|
}
|
|
@@ -600,6 +606,13 @@ function checkDataReferences(note, field, value, segments, context, index) {
|
|
|
600
606
|
: `; write \`${slot.declines}\` to decline the image on purpose`);
|
|
601
607
|
}
|
|
602
608
|
if (!reason) continue;
|
|
609
|
+
if (
|
|
610
|
+
note.fm?.type === "lore" &&
|
|
611
|
+
note.fm?.subType === "culture" &&
|
|
612
|
+
field.name === "parents" &&
|
|
613
|
+
note.reportedCultureParents?.has(check.path[0])
|
|
614
|
+
)
|
|
615
|
+
continue;
|
|
603
616
|
const path = ["data", ...segments, ...check.path];
|
|
604
617
|
findings.push({
|
|
605
618
|
file: note.file,
|
|
@@ -1526,6 +1539,7 @@ export function lintNote(
|
|
|
1526
1539
|
// way a wikilink would. The linter names no such field itself; the
|
|
1527
1540
|
// vocabulary does, beside the field.
|
|
1528
1541
|
for (const field of fields) {
|
|
1542
|
+
if (note.inheritedCultureFields?.has(field.name)) continue;
|
|
1529
1543
|
if (typeof field.check !== "function") continue;
|
|
1530
1544
|
findings.push(...field.check(note, { index }));
|
|
1531
1545
|
}
|