@heroiclands/package-build 23.8.0 → 23.10.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 +53 -0
- package/README.md +2 -0
- package/bin/content-commands.mjs +207 -70
- package/docs/api.md +646 -78
- package/docs/authoring/first-note.md +1 -1
- package/docs/authoring/links-and-markup.md +253 -102
- package/docs/commands.md +111 -13
- package/docs/content-format.md +7 -2
- package/docs/diagnostics.md +1 -1
- package/docs/guides/packs.md +2 -1
- package/docs/guides/reference-pages.md +86 -63
- package/docs/reference/format-details.md +398 -137
- package/docs/reference/note-types.md +188 -174
- package/engine/anchors.mjs +15 -26
- package/engine/base-compiler.mjs +6 -4
- package/engine/changelog-group.mjs +58 -34
- package/engine/changelog-lint.mjs +6 -2
- package/engine/compendiums.mjs +15 -1
- package/engine/compile-corpus.mjs +17 -3
- package/engine/content-charset.mjs +13 -1
- package/engine/content-format.yaml +1 -0
- package/engine/content-html.mjs +18 -4
- package/engine/content-icons.mjs +15 -1
- package/engine/content-images.mjs +28 -9
- package/engine/content-index.mjs +496 -16
- package/engine/content-links.mjs +70 -27
- package/engine/content-session.mjs +247 -0
- package/engine/content-sources.mjs +110 -0
- package/engine/content-spans.mjs +87 -8
- package/engine/content-tables.mjs +9 -4
- package/engine/content-tasklists.mjs +20 -4
- package/engine/culture-reach.mjs +61 -34
- package/engine/data-keys.mjs +3 -1
- package/engine/diagnostics.mjs +17 -6
- package/engine/dictionary-layout.mjs +151 -19
- package/engine/dictionary-rows.mjs +129 -79
- package/engine/extension-attributes.mjs +28 -5
- package/engine/first-use.mjs +371 -0
- package/engine/foundry-entries.mjs +29 -8
- package/engine/generate.mjs +13 -4
- package/engine/glossary-entries.mjs +11 -11
- package/engine/heading-attributes.mjs +21 -28
- package/engine/helpers.mjs +54 -19
- package/engine/index-records.mjs +1 -0
- package/engine/index.mjs +3 -0
- package/engine/journals.mjs +13 -6
- package/engine/language-data.mjs +55 -2
- package/engine/language-rows.mjs +4 -4
- package/engine/language-terms.mjs +90 -4
- package/engine/language-words.mjs +2 -2
- package/engine/lexical-sections.mjs +247 -0
- package/engine/markup-validation.mjs +7 -2
- package/engine/metadata-index.mjs +1 -0
- package/engine/name-markup.mjs +114 -0
- package/engine/name-rows.mjs +70 -0
- package/engine/names.mjs +460 -0
- package/engine/note-addresses.mjs +25 -4
- package/engine/note-claims.mjs +2 -0
- package/engine/note-state.mjs +9 -3
- package/engine/note-vocabulary.mjs +7 -6
- package/engine/path-charset.mjs +24 -1
- package/engine/pdf-build.mjs +9 -9
- package/engine/pdf-render.mjs +15 -1
- package/engine/site-build.mjs +16 -2
- package/engine/site-index.mjs +5 -1
- package/engine/sql-tables.mjs +324 -44
- package/engine/stub-lint.mjs +10 -5
- package/engine/web-wikilinks.mjs +8 -6
- package/engine/word-rows.mjs +20 -12
- package/engine/word-terms.mjs +15 -6
- package/engine/yaml-lint.mjs +3 -5
- package/hugo-theme/static/css/style.css +4 -0
- package/package.json +1 -1
- package/types/engine/anchors.d.mts +4 -2
- package/types/engine/compendiums.d.mts +5 -1
- package/types/engine/compile-corpus.d.mts +5 -2
- package/types/engine/content-charset.d.mts +5 -1
- package/types/engine/content-html.d.mts +5 -1
- package/types/engine/content-icons.d.mts +5 -1
- package/types/engine/content-images.d.mts +5 -1
- package/types/engine/content-index.d.mts +102 -18
- package/types/engine/content-links.d.mts +22 -8
- package/types/engine/content-session.d.mts +108 -0
- package/types/engine/content-sources.d.mts +32 -0
- package/types/engine/content-spans.d.mts +3 -5
- package/types/engine/content-tables.d.mts +2 -1
- package/types/engine/content-tasklists.d.mts +5 -1
- package/types/engine/culture-reach.d.mts +22 -2
- package/types/engine/data-keys.d.mts +7 -0
- package/types/engine/diagnostics.d.mts +10 -0
- package/types/engine/dictionary-layout.d.mts +35 -4
- package/types/engine/dictionary-rows.d.mts +9 -0
- package/types/engine/extension-attributes.d.mts +16 -2
- package/types/engine/first-use.d.mts +31 -0
- package/types/engine/foundry-entries.d.mts +14 -5
- package/types/engine/generate.d.mts +5 -1
- package/types/engine/heading-attributes.d.mts +2 -2
- package/types/engine/helpers.d.mts +33 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/journals.d.mts +5 -2
- package/types/engine/language-terms.d.mts +7 -0
- package/types/engine/lexical-sections.d.mts +96 -0
- package/types/engine/markup-validation.d.mts +2 -1
- package/types/engine/name-markup.d.mts +176 -0
- package/types/engine/name-rows.d.mts +35 -0
- package/types/engine/names.d.mts +356 -0
- package/types/engine/note-addresses.d.mts +3 -3
- package/types/engine/note-state.d.mts +2 -1
- package/types/engine/path-charset.d.mts +16 -1
- package/types/engine/site-build.d.mts +6 -3
- package/types/engine/site-index.d.mts +3 -1
- package/types/engine/sql-tables.d.mts +39 -4
- package/types/engine/stub-lint.d.mts +3 -1
- package/types/engine/word-rows.d.mts +9 -7
package/engine/names.mjs
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
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 path from "node:path";
|
|
15
|
+
import { canonicalKey, expandAddress, readCanonicalKey, readQualifier } from "./address.mjs";
|
|
16
|
+
import { isAddressSegment } from "./address-charset.mjs";
|
|
17
|
+
import { encodeAddresses } from "./address-values.mjs";
|
|
18
|
+
import { formatLocator } from "./diagnostics.mjs";
|
|
19
|
+
import { FOLDER_TYPE } from "./folder-notes.mjs";
|
|
20
|
+
import { isNoteRecord } from "./index-records.mjs";
|
|
21
|
+
import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
|
|
22
|
+
|
|
23
|
+
const WORD = /[\p{L}\p{M}\p{N}]+(?:[’'–-][\p{L}\p{M}\p{N}]+)*/gu;
|
|
24
|
+
const compare = (a, b) =>
|
|
25
|
+
a < b ? -1
|
|
26
|
+
: a > b ? 1
|
|
27
|
+
: 0;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* An indexed whole-label match. Ambiguous labels retain every candidate.
|
|
31
|
+
* @typedef {object} NameMatch
|
|
32
|
+
* @property {string} key - Canonical page identity, or the ambiguous label identity.
|
|
33
|
+
* @property {string} full - Declared full name.
|
|
34
|
+
* @property {true} indexed - The identity belongs to the indexed catalog.
|
|
35
|
+
* @property {boolean} [fullUse] - The label is a full-name form rather than an alias.
|
|
36
|
+
* @property {NameMatch[]} [candidates] - Indexed identities sharing the label.
|
|
37
|
+
* @property {string} [destination] - Canonical Address used by a resolved link.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The prepared name and declared-alias lookup.
|
|
42
|
+
* @typedef {object} NameCatalog
|
|
43
|
+
* @property {(label: string, preferred?: {key: string}) => NameMatch|undefined} recognize - Match a whole visible label.
|
|
44
|
+
* @property {(target: string) => NameMatch|undefined} resolve - Resolve an Address target without its anchor to its indexed identity.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One captured marked-name occurrence.
|
|
49
|
+
* @typedef {object} MarkedName
|
|
50
|
+
* @property {string} name - Rendered visible text.
|
|
51
|
+
* @property {string|null} type - Supplied valid type, or null.
|
|
52
|
+
* @property {string|null} subType - Supplied valid subtype, or null.
|
|
53
|
+
* @property {number} line - Original 1-based file line.
|
|
54
|
+
* @property {number} column - Original 1-based UTF-16 file column.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One reported source use.
|
|
59
|
+
* @typedef {object} NameUse
|
|
60
|
+
* @property {string|null} owner - Canonical NOTE owner, or null for an unaddressed record.
|
|
61
|
+
* @property {string|undefined} file - Stored path relative to the content root.
|
|
62
|
+
* @property {number} line - Original 1-based file line.
|
|
63
|
+
* @property {number} column - Original 1-based UTF-16 file column.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Unwritten names sharing one supplied kind.
|
|
68
|
+
* @typedef {object} UnwrittenNameGroup
|
|
69
|
+
* @property {string|null} type - Supplied type, or null.
|
|
70
|
+
* @property {string|null} subType - Supplied subtype, or null.
|
|
71
|
+
* @property {Array<{name: string, uses: NameUse[]}>} names - Normalized labels and every source use.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A parsed span with its original source sites and visible text.
|
|
76
|
+
* @typedef {object} ParsedNameSpan
|
|
77
|
+
* @property {string} visible - Text from the actual parsed children.
|
|
78
|
+
* @property {ReadonlyArray<readonly [string, string]>} attrs - Retained permitted attributes.
|
|
79
|
+
* @property {number} line - Original 1-based file line of the opening bracket.
|
|
80
|
+
* @property {number} column - Original 1-based UTF-16 file column of the bracket.
|
|
81
|
+
* @property {Readonly<Record<string, {key: {line: number, column: number}}>>} [attributePositions] - Actual attribute-key sites.
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One invalid supplied name kind.
|
|
86
|
+
* @typedef {object} NameKindFinding
|
|
87
|
+
* @property {string} [file] - Authored source file.
|
|
88
|
+
* @property {number} [line] - Actual attribute-key line when supplied by the parser.
|
|
89
|
+
* @property {number} [column] - Actual attribute-key UTF-16 column when known.
|
|
90
|
+
* @property {"error"} severity - Invalid supplied values fail validation.
|
|
91
|
+
* @property {string} message - The invalid value and the declaration it must follow.
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A marked name whose supplied kinds disagree.
|
|
96
|
+
* @typedef {object} NameConflict
|
|
97
|
+
* @property {string} name - Normalized visible label.
|
|
98
|
+
* @property {Array<{type: string, subType: string|null, uses: NameUse[]}>} kinds - Every supplied typed use; untyped uses are excluded.
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The current package's unwritten names and incompatible supplied facts.
|
|
103
|
+
* @typedef {object} NamesReport
|
|
104
|
+
* @property {string} package - Owning content package.
|
|
105
|
+
* @property {UnwrittenNameGroup[]} unwritten - Unresolved labels grouped by supplied kind.
|
|
106
|
+
* @property {NameConflict[]} conflicts - Labels with incompatible supplied kinds.
|
|
107
|
+
*/
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Normalize a whole visible label without folding its case.
|
|
111
|
+
* @param {string} text - Visible name or declared alias.
|
|
112
|
+
* @returns {string} NFC text with straight apostrophes and single spaces.
|
|
113
|
+
*/
|
|
114
|
+
export function normalizeName(text) {
|
|
115
|
+
return text.normalize("NFC").replaceAll("’", "'").replace(/\s+/g, " ").trim();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whole-label possessives and regular plurals; other forms are declared aliases.
|
|
120
|
+
* @param {string} text - Full name or declared alias.
|
|
121
|
+
* @returns {string[]} Supported whole-label forms.
|
|
122
|
+
*/
|
|
123
|
+
export function nameForms(text) {
|
|
124
|
+
const value = normalizeName(text);
|
|
125
|
+
const last = [...value.matchAll(WORD)].at(-1);
|
|
126
|
+
if (!last) return [value];
|
|
127
|
+
const word = last[0];
|
|
128
|
+
const plural =
|
|
129
|
+
/[^aeiou]y$/i.test(word) ? word.slice(0, -1) + "ies"
|
|
130
|
+
: /(?:s|x|z|ch|sh)$/i.test(word) ? word + "es"
|
|
131
|
+
: word + "s";
|
|
132
|
+
return [...new Set([word, word + "'s", plural, plural + "'s", plural + "'"])].map(
|
|
133
|
+
(ending) => value.slice(0, last.index) + ending + value.slice(last.index + word.length),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Stable record identity independent of a shared display name. */
|
|
138
|
+
function identity(record) {
|
|
139
|
+
return record.address?.canonical ? encodeAddresses(record.address.canonical) : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Prepare full names, declared aliases and link identities from captured records.
|
|
144
|
+
* @param {object} options - The invocation's existing corpus and link index.
|
|
145
|
+
* @param {readonly object[]} options.records - Captured local records.
|
|
146
|
+
* @param {ReturnType<typeof import("./content-links.mjs").buildLinkIndex>} options.index - Captured address space.
|
|
147
|
+
* @returns {NameCatalog} Pure whole-label and Address lookups.
|
|
148
|
+
*/
|
|
149
|
+
export function createNameCatalog({ records, index }) {
|
|
150
|
+
const names = new Map(),
|
|
151
|
+
byFile = new Map(),
|
|
152
|
+
entries = new Map();
|
|
153
|
+
const addName = (key, full, aliases = []) => {
|
|
154
|
+
const entry = entries.get(key) ?? { key, full, indexed: true };
|
|
155
|
+
entries.set(key, entry);
|
|
156
|
+
for (const text of [full, ...aliases]) {
|
|
157
|
+
if (typeof text !== "string" || !text.trim()) continue;
|
|
158
|
+
for (const spelling of nameForms(text)) {
|
|
159
|
+
if (!names.has(spelling)) names.set(spelling, new Map());
|
|
160
|
+
const targets = names.get(spelling);
|
|
161
|
+
targets.set(key, {
|
|
162
|
+
...entry,
|
|
163
|
+
fullUse:
|
|
164
|
+
targets.get(key)?.fullUse || normalizeName(text) === normalizeName(full),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return entry;
|
|
169
|
+
};
|
|
170
|
+
for (const record of records.filter(isNoteRecord)) {
|
|
171
|
+
const canonical = identity(record);
|
|
172
|
+
const parts = readCanonicalKey(canonical);
|
|
173
|
+
const full = record.name?.full;
|
|
174
|
+
if (!parts || parts.type === FOLDER_TYPE || typeof full !== "string") continue;
|
|
175
|
+
const page = canonicalKey(parts.package, "note", parts.type, parts.shortcode);
|
|
176
|
+
const entry = addName(index.resolve(page) ? page : canonical, full, record.name.aliases);
|
|
177
|
+
entries.set(canonical, entry);
|
|
178
|
+
if (index.resolve(page)) entries.set(page, entry);
|
|
179
|
+
if (record.file?.path) byFile.set(record.file.path, entry);
|
|
180
|
+
}
|
|
181
|
+
for (const [canonical, hit] of index.foreign?.index ?? []) {
|
|
182
|
+
const parts = readCanonicalKey(canonical);
|
|
183
|
+
if (
|
|
184
|
+
!parts ||
|
|
185
|
+
parts.system === "none" ||
|
|
186
|
+
parts.type === FOLDER_TYPE ||
|
|
187
|
+
typeof hit.name !== "string"
|
|
188
|
+
)
|
|
189
|
+
continue;
|
|
190
|
+
const page = canonicalKey(parts.package, "note", parts.type, parts.shortcode);
|
|
191
|
+
const key = index.foreign.index.has(page) ? page : canonical;
|
|
192
|
+
entries.set(canonical, addName(key, hit.name, hit.aliases));
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
recognize(label, preferred) {
|
|
196
|
+
const candidates = [...(names.get(normalizeName(label))?.values() ?? [])];
|
|
197
|
+
const target =
|
|
198
|
+
candidates.find((entry) => entry.key === preferred?.key) ??
|
|
199
|
+
(candidates.length === 1 ? candidates[0] : undefined);
|
|
200
|
+
return (
|
|
201
|
+
target ??
|
|
202
|
+
(candidates.length ?
|
|
203
|
+
{
|
|
204
|
+
key: "name:" + normalizeName(label),
|
|
205
|
+
full: label,
|
|
206
|
+
indexed: true,
|
|
207
|
+
fullUse: true,
|
|
208
|
+
candidates,
|
|
209
|
+
}
|
|
210
|
+
: undefined)
|
|
211
|
+
);
|
|
212
|
+
},
|
|
213
|
+
resolve(target) {
|
|
214
|
+
const qualified = readQualifier(
|
|
215
|
+
target,
|
|
216
|
+
index.types,
|
|
217
|
+
index.packages,
|
|
218
|
+
index.noIndexPackages,
|
|
219
|
+
);
|
|
220
|
+
if (!qualified || qualified.reason) return undefined;
|
|
221
|
+
const canonical = expandAddress(qualified, {
|
|
222
|
+
package: index.contentPackage,
|
|
223
|
+
system: "note",
|
|
224
|
+
});
|
|
225
|
+
const hit = index.resolve(target) ?? index.manifestHit(target);
|
|
226
|
+
if (!hit) return undefined;
|
|
227
|
+
const entry = entries.get(canonical) ?? byFile.get(hit.rel);
|
|
228
|
+
return entry ? { ...entry, destination: canonical } : undefined;
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Compare captured sites without inventing an absent source position. */
|
|
234
|
+
function compareUses(a, b) {
|
|
235
|
+
return (
|
|
236
|
+
compare(a.file ?? "\uffff", b.file ?? "\uffff") ||
|
|
237
|
+
compare(a.line, b.line) ||
|
|
238
|
+
compare(a.column, b.column)
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Preserve the captured site and derive only its real NOTE owner. */
|
|
243
|
+
function nameUse(record, occurrence) {
|
|
244
|
+
const parts = readCanonicalKey(identity(record));
|
|
245
|
+
return {
|
|
246
|
+
owner: parts ? canonicalKey(parts.package, "note", parts.type, parts.shortcode) : null,
|
|
247
|
+
file: record.file?.path,
|
|
248
|
+
line: occurrence.line,
|
|
249
|
+
column: occurrence.column,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The first real reported site, without a package-level fallback. */
|
|
254
|
+
function firstNameUse(uses) {
|
|
255
|
+
return uses
|
|
256
|
+
.filter((use) => use.file && Number.isFinite(use.line) && Number.isFinite(use.column))
|
|
257
|
+
.sort(compareUses)[0];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Group unwritten marked names from captured record fields alone.
|
|
262
|
+
* @param {object} options - Current package records and prepared known names.
|
|
263
|
+
* @param {readonly object[]} options.records - Authored records carrying `names` entries.
|
|
264
|
+
* @param {NameCatalog} options.catalog - Invocation-owned indexed name lookup.
|
|
265
|
+
* @returns {{groups: UnwrittenNameGroup[], firstUse: NameUse|undefined}} Every use and the earliest located reported occurrence.
|
|
266
|
+
*/
|
|
267
|
+
export function collectUnwrittenNames({ records, catalog }) {
|
|
268
|
+
const kinds = new Map();
|
|
269
|
+
for (const record of records.filter(isNoteRecord)) {
|
|
270
|
+
for (const occurrence of record.names ?? []) {
|
|
271
|
+
if (catalog.recognize(occurrence.name)) continue;
|
|
272
|
+
const type = occurrence.type,
|
|
273
|
+
subType = occurrence.subType,
|
|
274
|
+
kind = JSON.stringify([type, subType]);
|
|
275
|
+
if (!kinds.has(kind)) kinds.set(kind, { type, subType, names: new Map() });
|
|
276
|
+
const names = kinds.get(kind).names,
|
|
277
|
+
name = normalizeName(occurrence.name);
|
|
278
|
+
if (!names.has(name)) names.set(name, []);
|
|
279
|
+
names.get(name).push(nameUse(record, occurrence));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const groups = [...kinds.values()]
|
|
283
|
+
.sort(
|
|
284
|
+
(a, b) =>
|
|
285
|
+
compare(a.type ?? "", b.type ?? "") || compare(a.subType ?? "", b.subType ?? ""),
|
|
286
|
+
)
|
|
287
|
+
.map(({ type, subType, names }) => ({
|
|
288
|
+
type,
|
|
289
|
+
subType,
|
|
290
|
+
names: [...names]
|
|
291
|
+
.sort(([a], [b]) => compare(a, b))
|
|
292
|
+
.map(([name, uses]) => ({ name, uses: uses.sort(compareUses) })),
|
|
293
|
+
}));
|
|
294
|
+
const firstUse = firstNameUse(
|
|
295
|
+
groups.flatMap((group) => group.names.flatMap((name) => name.uses)),
|
|
296
|
+
);
|
|
297
|
+
return { groups, firstUse };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Read one supplied kind against actual note declarations. */
|
|
301
|
+
function readNameKind(value, vocabulary, types) {
|
|
302
|
+
const parts = value.split("/");
|
|
303
|
+
const prefix = `data-type ${JSON.stringify(value)}`;
|
|
304
|
+
if (parts.length > 2 || parts.some((part) => !isAddressSegment(part)))
|
|
305
|
+
return `${prefix} must be a note type or type/subType using lowercase letters and digits`;
|
|
306
|
+
const [type, subType] = parts;
|
|
307
|
+
if (!Object.hasOwn(vocabulary, type) && !types.has(type))
|
|
308
|
+
return `${prefix} does not name a declared note type`;
|
|
309
|
+
if (subType !== undefined) {
|
|
310
|
+
const entry = vocabulary[type];
|
|
311
|
+
if (!entry || !Object.hasOwn(entry, "subTypes"))
|
|
312
|
+
return `${prefix} supplies a subtype for ${type}, which declares no subtypes`;
|
|
313
|
+
if (entry.subTypes !== null && !entry.subTypes.includes(subType))
|
|
314
|
+
return `${prefix} does not name a subtype declared by ${type} (${entry.subTypes.join(", ")})`;
|
|
315
|
+
}
|
|
316
|
+
return { type, subType: subType ?? null };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Analyze actual parsed name spans using one supplied-kind validation route.
|
|
321
|
+
* @param {readonly ParsedNameSpan[]} spans - Parsed spans at original file coordinates.
|
|
322
|
+
* @param {object} [options] - Existing note declarations and source context.
|
|
323
|
+
* @param {string} [options.file] - Authored source file.
|
|
324
|
+
* @param {Readonly<Record<string, {subTypes?: readonly string[]|null}>>} [options.vocabulary] - Actual note vocabulary.
|
|
325
|
+
* @param {Iterable<string>} [options.types] - Additional bare types from configured declarations.
|
|
326
|
+
* @returns {{names: MarkedName[], findings: NameKindFinding[]}} Valid occurrence fields and every invalid supplied attribute.
|
|
327
|
+
*/
|
|
328
|
+
export function analyzeNameSpans(spans, { file, vocabulary = NOTE_VOCABULARY, types = [] } = {}) {
|
|
329
|
+
const names = [],
|
|
330
|
+
findings = [],
|
|
331
|
+
declared = new Set(types);
|
|
332
|
+
for (const span of spans) {
|
|
333
|
+
if (
|
|
334
|
+
!span.attrs.some(
|
|
335
|
+
([key, value]) => key === "class" && value.split(/\s+/).includes("name"),
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
continue;
|
|
339
|
+
const attribute = span.attrs.find(([key]) => key === "data-type");
|
|
340
|
+
const kind =
|
|
341
|
+
attribute ?
|
|
342
|
+
readNameKind(attribute[1], vocabulary, declared)
|
|
343
|
+
: { type: null, subType: null };
|
|
344
|
+
if (typeof kind === "string") {
|
|
345
|
+
const site = span.attributePositions?.["data-type"]?.key;
|
|
346
|
+
findings.push({
|
|
347
|
+
...(file === undefined ? {} : { file }),
|
|
348
|
+
...(Number.isFinite(site?.line) ? { line: site.line } : {}),
|
|
349
|
+
...(Number.isFinite(site?.column) ? { column: site.column } : {}),
|
|
350
|
+
severity: "error",
|
|
351
|
+
message: kind,
|
|
352
|
+
});
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
names.push({
|
|
356
|
+
name: span.visible,
|
|
357
|
+
type: kind.type,
|
|
358
|
+
subType: kind.subType,
|
|
359
|
+
line: span.line,
|
|
360
|
+
column: span.column,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return { names, findings };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Build one package report from captured explicit names and the prepared catalog.
|
|
368
|
+
* Untyped facts and matching parent/subtype refinements are compatible.
|
|
369
|
+
* @param {object} options - Existing package corpus and lookup.
|
|
370
|
+
* @param {string} options.package - Owning content package.
|
|
371
|
+
* @param {readonly object[]} options.records - Captured current-package records.
|
|
372
|
+
* @param {NameCatalog} options.catalog - Invocation-owned indexed name lookup.
|
|
373
|
+
* @returns {NamesReport} Deterministic structured groups without diagnostics or I/O.
|
|
374
|
+
*/
|
|
375
|
+
export function createNamesReport({ package: contentPackage, records, catalog }) {
|
|
376
|
+
const names = new Map();
|
|
377
|
+
for (const record of records.filter(isNoteRecord))
|
|
378
|
+
for (const occurrence of record.names ?? []) {
|
|
379
|
+
if (occurrence.type === null) continue;
|
|
380
|
+
const name = normalizeName(occurrence.name);
|
|
381
|
+
if (!names.has(name)) names.set(name, new Map());
|
|
382
|
+
const kinds = names.get(name),
|
|
383
|
+
key = JSON.stringify([occurrence.type, occurrence.subType]);
|
|
384
|
+
if (!kinds.has(key))
|
|
385
|
+
kinds.set(key, { type: occurrence.type, subType: occurrence.subType, uses: [] });
|
|
386
|
+
kinds.get(key).uses.push(nameUse(record, occurrence));
|
|
387
|
+
}
|
|
388
|
+
const conflicts = [];
|
|
389
|
+
for (const [name, kinds] of [...names].sort(([a], [b]) => compare(a, b))) {
|
|
390
|
+
const parents = new Map();
|
|
391
|
+
for (const kind of kinds.values()) {
|
|
392
|
+
if (!parents.has(kind.type)) parents.set(kind.type, new Set());
|
|
393
|
+
if (kind.subType !== null) parents.get(kind.type).add(kind.subType);
|
|
394
|
+
}
|
|
395
|
+
if (parents.size < 2 && ![...parents.values()].some((values) => values.size > 1)) continue;
|
|
396
|
+
conflicts.push({
|
|
397
|
+
name,
|
|
398
|
+
kinds: [...kinds.values()]
|
|
399
|
+
.sort(
|
|
400
|
+
(a, b) => compare(a.type, b.type) || compare(a.subType ?? "", b.subType ?? ""),
|
|
401
|
+
)
|
|
402
|
+
.map((kind) => ({ ...kind, uses: kind.uses.sort(compareUses) })),
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
package: contentPackage,
|
|
407
|
+
unwritten: collectUnwrittenNames({ records, catalog }).groups,
|
|
408
|
+
conflicts,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Format one readable advisory containing every reported source use.
|
|
414
|
+
* @param {NamesReport} report - Structured package report.
|
|
415
|
+
* @param {object} [options] - Source-path context for the caller's diagnostics.
|
|
416
|
+
* @param {string} [options.contentBase] - Content root for stored relative paths.
|
|
417
|
+
* @returns {{file?: string, line?: number, column?: number, severity: "warning", message: string}|undefined} One advisory, or none when both groups are empty.
|
|
418
|
+
*/
|
|
419
|
+
export function namesReportFinding(report, { contentBase } = {}) {
|
|
420
|
+
if (!report.unwritten.length && !report.conflicts.length) return undefined;
|
|
421
|
+
const located = (use) => ({
|
|
422
|
+
...use,
|
|
423
|
+
file: contentBase && use.file ? path.resolve(contentBase, use.file) : use.file,
|
|
424
|
+
});
|
|
425
|
+
const kindLabel = ({ type, subType }) =>
|
|
426
|
+
type ? type + (subType ? `/${subType}` : "") : "untyped";
|
|
427
|
+
const usesText = (uses) =>
|
|
428
|
+
uses.map((use) => formatLocator(located(use)) || "source location unavailable").join("; ");
|
|
429
|
+
const count = new Set(
|
|
430
|
+
report.unwritten.flatMap((group) => group.names.map((entry) => entry.name)),
|
|
431
|
+
).size;
|
|
432
|
+
const summary = [];
|
|
433
|
+
if (count) summary.push(`${count} unwritten name${count === 1 ? "" : "s"}`);
|
|
434
|
+
if (report.conflicts.length)
|
|
435
|
+
summary.push(
|
|
436
|
+
`${report.conflicts.length} conflicting name${report.conflicts.length === 1 ? "" : "s"}`,
|
|
437
|
+
);
|
|
438
|
+
const lines = [`names: ${summary.join("; ")}.`];
|
|
439
|
+
for (const group of report.unwritten) {
|
|
440
|
+
lines.push(` Unwritten ${kindLabel(group)}:`);
|
|
441
|
+
for (const entry of group.names) lines.push(` ${entry.name} — ${usesText(entry.uses)}`);
|
|
442
|
+
}
|
|
443
|
+
for (const conflict of report.conflicts) {
|
|
444
|
+
lines.push(` Incompatible ${conflict.name}:`);
|
|
445
|
+
for (const kind of conflict.kinds)
|
|
446
|
+
lines.push(` ${kindLabel(kind)} — ${usesText(kind.uses)}`);
|
|
447
|
+
}
|
|
448
|
+
const firstUse = firstNameUse([
|
|
449
|
+
...report.unwritten.flatMap((group) => group.names.flatMap((entry) => entry.uses)),
|
|
450
|
+
...report.conflicts.flatMap((conflict) => conflict.kinds.flatMap((kind) => kind.uses)),
|
|
451
|
+
]);
|
|
452
|
+
const at = firstUse ? located(firstUse) : {};
|
|
453
|
+
return {
|
|
454
|
+
...(at.file === undefined ? {} : { file: at.file }),
|
|
455
|
+
...(at.line === undefined ? {} : { line: at.line }),
|
|
456
|
+
...(at.column === undefined ? {} : { column: at.column }),
|
|
457
|
+
severity: "warning",
|
|
458
|
+
message: lines.join("\n"),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
isAddressTuple,
|
|
18
18
|
parseAddress,
|
|
19
19
|
renderAddress,
|
|
20
|
+
canonicalKey,
|
|
20
21
|
acceptsType,
|
|
21
22
|
hasAnchor,
|
|
22
23
|
readCanonicalKey,
|
|
@@ -26,6 +27,7 @@ import { NOTE_VOCABULARY, dataFields } from "./note-vocabulary.mjs";
|
|
|
26
27
|
import { ASSET_TYPE_NAMES } from "./asset-types.mjs";
|
|
27
28
|
import { ART_SLOTS } from "./art-slots.mjs";
|
|
28
29
|
import { SHIPPED_SYSTEMS } from "./subtype-registry.mjs";
|
|
30
|
+
import { NOTE_SYSTEM } from "./systems.mjs";
|
|
29
31
|
import { EVENT_ADDRESS_POSITIONS } from "./note-events.mjs";
|
|
30
32
|
|
|
31
33
|
import { AddressEntries, AddressLink, readPublishedAddresses } from "./address-values.mjs";
|
|
@@ -127,6 +129,8 @@ export const STRUCTURED_ADDRESSES = Object.freeze([
|
|
|
127
129
|
shape: "value",
|
|
128
130
|
type: "skill",
|
|
129
131
|
accepts: ["skill"],
|
|
132
|
+
system: NOTE_SYSTEM,
|
|
133
|
+
noteOnly: true,
|
|
130
134
|
lenient: true,
|
|
131
135
|
})),
|
|
132
136
|
]);
|
|
@@ -155,6 +159,9 @@ export function addressPositions(fm, context = {}) {
|
|
|
155
159
|
...(ASSET_TYPE_NAMES.has(field.ref) || field.ref === "folder" ?
|
|
156
160
|
{ system: "none" }
|
|
157
161
|
: {}),
|
|
162
|
+
...(field.name === "language" && field.ref === "skill" ?
|
|
163
|
+
{ system: NOTE_SYSTEM, noteOnly: true }
|
|
164
|
+
: {}),
|
|
158
165
|
});
|
|
159
166
|
}
|
|
160
167
|
out.push(...STRUCTURED_ADDRESSES.filter((p) => !p.noteType || p.noteType === fm.type));
|
|
@@ -289,8 +296,8 @@ function shortcodeCaseError(problem, path, position, context) {
|
|
|
289
296
|
* Decode declared properties at the note boundary. Raw mappings cannot impersonate tuples.
|
|
290
297
|
*
|
|
291
298
|
* A declared Address is read as written. A shortcode spelled with a capital is
|
|
292
|
-
* refused,
|
|
293
|
-
* throws, so each
|
|
299
|
+
* refused, as is a document Address in a language-note field. Every such value
|
|
300
|
+
* is collected before the decode throws, so each is reported at its own position: the thrown error
|
|
294
301
|
* describes the first, and its `findings` carry all of them.
|
|
295
302
|
*
|
|
296
303
|
* @param {object} fm - Parsed YAML or an index record.
|
|
@@ -299,7 +306,7 @@ function shortcodeCaseError(problem, path, position, context) {
|
|
|
299
306
|
* refused shortcode when it is declared.
|
|
300
307
|
* @returns {object} The same note carrying typed Address properties.
|
|
301
308
|
* @throws {Error} When a declared Address is refused; `keyPath` locates it, and
|
|
302
|
-
* `findings`, where present, lists every refused
|
|
309
|
+
* `findings`, where present, lists every refused value.
|
|
303
310
|
*/
|
|
304
311
|
export function decodeNoteAddresses(fm, context) {
|
|
305
312
|
if (!fm || typeof fm !== "object") return fm;
|
|
@@ -325,7 +332,7 @@ export function decodeNoteAddresses(fm, context) {
|
|
|
325
332
|
*
|
|
326
333
|
* @param {object} fm - The note.
|
|
327
334
|
* @param {object} context - The decode context.
|
|
328
|
-
* @param {Error[]} refused - Collects each refused
|
|
335
|
+
* @param {Error[]} refused - Collects each refused declared value.
|
|
329
336
|
* @returns {void}
|
|
330
337
|
*/
|
|
331
338
|
function decodePositions(fm, context, refused) {
|
|
@@ -386,6 +393,20 @@ function decodePositions(fm, context, refused) {
|
|
|
386
393
|
path,
|
|
387
394
|
`must name a ${position.accepts.join(" or ")} Address, but names ${tuple.type}`,
|
|
388
395
|
);
|
|
396
|
+
if (position.noteOnly && tuple.system !== NOTE_SYSTEM) {
|
|
397
|
+
const error = Object.assign(
|
|
398
|
+
new Error(
|
|
399
|
+
`\`${path.join(".")}\` names a language note; write ` +
|
|
400
|
+
`\`${canonicalKey(tuple.package, NOTE_SYSTEM, tuple.type, tuple.shortcode)}\` ` +
|
|
401
|
+
`instead of the \`${tuple.system}\` document Address`,
|
|
402
|
+
),
|
|
403
|
+
{ keyPath: path, addressKey: position.shape === "keys" },
|
|
404
|
+
);
|
|
405
|
+
// A lenient position may retain malformed input for its own
|
|
406
|
+
// check, but cannot absorb a refused language-note identity.
|
|
407
|
+
refused.push(error);
|
|
408
|
+
return value;
|
|
409
|
+
}
|
|
389
410
|
return anchored ?
|
|
390
411
|
new AddressLink(
|
|
391
412
|
tuple,
|
package/engine/note-claims.mjs
CHANGED
|
@@ -105,6 +105,7 @@ import { HOMEPAGE_TYPE } from "./homepage.mjs";
|
|
|
105
105
|
import { FOLDER_TYPE } from "./folder-notes.mjs";
|
|
106
106
|
import { BUNDLE_TYPE } from "./bundle-notes.mjs";
|
|
107
107
|
import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
|
|
108
|
+
import { hasLexicalEntries } from "./lexical-sections.mjs";
|
|
108
109
|
|
|
109
110
|
/**
|
|
110
111
|
* Note types that compile into **no compendium document, by design**.
|
|
@@ -769,6 +770,7 @@ export function unclaimedNoteFindings(
|
|
|
769
770
|
// claiming this note", because the answer there is yes.
|
|
770
771
|
const produces = documentClassesFor(type, resolved, {
|
|
771
772
|
hasProse: () => {
|
|
773
|
+
if (hasLexicalEntries(fm)) return true;
|
|
772
774
|
const source = parsed?.get(absPath);
|
|
773
775
|
return source ? Boolean(source.body) : noteHasProse(absPath);
|
|
774
776
|
},
|
package/engine/note-state.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
/**
|
|
15
15
|
* The three states of a note, and the one observable fact they rest on.
|
|
16
16
|
*
|
|
17
|
-
* A note whose body is empty is a **stub**: frontmatter only — identity and
|
|
17
|
+
* A note whose body is empty and has no automatic lexical content is a **stub**: frontmatter only — identity and
|
|
18
18
|
* statistics — reaching the content index and queryable there, generating no
|
|
19
19
|
* page, no address and no link target. A note with a body and the declared
|
|
20
20
|
* `draft` tag is a **draft**: it publishes, compiles and resolves like any
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
41
|
import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
|
|
42
|
+
import { hasLexicalEntries } from "./lexical-sections.mjs";
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
45
|
* The three states, in the order they read as a ladder.
|
|
@@ -97,7 +98,8 @@ export function isStubbableType(type, vocabulary = NOTE_VOCABULARY) {
|
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
/**
|
|
100
|
-
* Whether a note is a stub: an empty body on a type an empty body suppresses
|
|
101
|
+
* Whether a note is a stub: an empty body on a type an empty body suppresses,
|
|
102
|
+
* without an automatic dictionary or glossary supplied by its frontmatter.
|
|
101
103
|
*
|
|
102
104
|
* The one place the two halves are put together, so the index, the lint and the
|
|
103
105
|
* guard cannot answer it differently.
|
|
@@ -109,7 +111,11 @@ export function isStubbableType(type, vocabulary = NOTE_VOCABULARY) {
|
|
|
109
111
|
* @returns {boolean} Whether the note is a stub.
|
|
110
112
|
*/
|
|
111
113
|
export function isStubNote(frontmatter, body, vocabulary = NOTE_VOCABULARY) {
|
|
112
|
-
return
|
|
114
|
+
return (
|
|
115
|
+
isEmptyBody(body) &&
|
|
116
|
+
!hasLexicalEntries(frontmatter) &&
|
|
117
|
+
isStubbableType(frontmatter?.type, vocabulary)
|
|
118
|
+
);
|
|
113
119
|
}
|
|
114
120
|
|
|
115
121
|
/**
|
|
@@ -600,7 +600,7 @@ const WORD_DATA_FIELDS = Object.freeze([
|
|
|
600
600
|
fields: WORD_FIELDS,
|
|
601
601
|
}),
|
|
602
602
|
describe:
|
|
603
|
-
"The language
|
|
603
|
+
"The words this language states, one map each, in authored order; its automatic dictionary sorts them alphabetically. A language skill " +
|
|
604
604
|
"note only. See [a language's words](format-details.md#a-languages-words).",
|
|
605
605
|
}),
|
|
606
606
|
Object.freeze({
|
|
@@ -671,9 +671,10 @@ const GLOSSARY_FIELD = Object.freeze({
|
|
|
671
671
|
fields: GLOSSARY_ENTRY_FIELDS,
|
|
672
672
|
}),
|
|
673
673
|
describe:
|
|
674
|
-
"Its own glossary
|
|
675
|
-
"
|
|
676
|
-
"
|
|
674
|
+
"Its own glossary entries: common vocabulary, dictionary words with a contextual " +
|
|
675
|
+
"meaning, and phrases, compounds or terms of art. A named dictionary word supplies " +
|
|
676
|
+
"its senses and class unless `definition` supplies the sense. Common marks belong " +
|
|
677
|
+
"to this owner. Every entry resolves to a language. " +
|
|
677
678
|
"Empty when unstated. See [glossary terms](format-details.md#glossary-terms).",
|
|
678
679
|
});
|
|
679
680
|
|
|
@@ -1820,8 +1821,8 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
1820
1821
|
...LANGUAGE_FIELD,
|
|
1821
1822
|
describe:
|
|
1822
1823
|
"The trade, temple or order tongue the affiliation keeps, as a language " +
|
|
1823
|
-
"skill note.
|
|
1824
|
-
"
|
|
1824
|
+
"skill note. A glossary entry that states no `language` is in it. The " +
|
|
1825
|
+
"affiliation chooses its own common vocabulary and context meanings.",
|
|
1825
1826
|
},
|
|
1826
1827
|
GLOSSARY_FIELD,
|
|
1827
1828
|
{
|
package/engine/path-charset.mjs
CHANGED
|
@@ -78,13 +78,36 @@ function pathMessage(characters) {
|
|
|
78
78
|
* @param {string} opts.assetsBase - The package's asset directory, whose
|
|
79
79
|
* picture roots are walked.
|
|
80
80
|
* @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore.
|
|
81
|
+
* @param {ReturnType<import("./content-sources.mjs").captureContentSources>} [opts.inventory] - Captured physical paths in root walk order.
|
|
81
82
|
* @returns {{findings: Array<{file: string, severity: "error", message: string}>,
|
|
82
83
|
* files: number}} The findings, and how many paths were checked.
|
|
83
84
|
*/
|
|
84
|
-
export function lintPathCharset({ contentBase, assetsBase, skipDirectories = [] }) {
|
|
85
|
+
export function lintPathCharset({ contentBase, assetsBase, skipDirectories = [], inventory }) {
|
|
85
86
|
const skip = new Set(skipDirectories);
|
|
86
87
|
const findings = [];
|
|
87
88
|
let files = 0;
|
|
89
|
+
if (inventory) {
|
|
90
|
+
const roots = new Set(
|
|
91
|
+
[
|
|
92
|
+
contentBase,
|
|
93
|
+
...DEPLOYED_ASSET_TYPES.map((entry) => path.join(assetsBase, entry.root)),
|
|
94
|
+
].map((root) => path.resolve(root)),
|
|
95
|
+
);
|
|
96
|
+
for (const entry of inventory.paths) {
|
|
97
|
+
if (!roots.has(entry.root)) continue;
|
|
98
|
+
const parts = path.relative(entry.root, entry.file).split(path.sep);
|
|
99
|
+
if (parts.some((name) => name.startsWith(".") || skip.has(name))) continue;
|
|
100
|
+
files++;
|
|
101
|
+
const bad = offendingPathCharacters(entry.name);
|
|
102
|
+
if (bad.length)
|
|
103
|
+
findings.push({
|
|
104
|
+
file: path.relative(process.cwd(), entry.file),
|
|
105
|
+
severity: "error",
|
|
106
|
+
message: pathMessage(bad),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return { findings, files };
|
|
110
|
+
}
|
|
88
111
|
|
|
89
112
|
/** @param {string} dir - Directory to descend into. */
|
|
90
113
|
const walk = (dir) => {
|