@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +2 -0
  3. package/bin/content-commands.mjs +207 -70
  4. package/docs/api.md +646 -78
  5. package/docs/authoring/first-note.md +1 -1
  6. package/docs/authoring/links-and-markup.md +253 -102
  7. package/docs/commands.md +111 -13
  8. package/docs/content-format.md +7 -2
  9. package/docs/diagnostics.md +1 -1
  10. package/docs/guides/packs.md +2 -1
  11. package/docs/guides/reference-pages.md +86 -63
  12. package/docs/reference/format-details.md +398 -137
  13. package/docs/reference/note-types.md +188 -174
  14. package/engine/anchors.mjs +15 -26
  15. package/engine/base-compiler.mjs +6 -4
  16. package/engine/changelog-group.mjs +58 -34
  17. package/engine/changelog-lint.mjs +6 -2
  18. package/engine/compendiums.mjs +15 -1
  19. package/engine/compile-corpus.mjs +17 -3
  20. package/engine/content-charset.mjs +13 -1
  21. package/engine/content-format.yaml +1 -0
  22. package/engine/content-html.mjs +18 -4
  23. package/engine/content-icons.mjs +15 -1
  24. package/engine/content-images.mjs +28 -9
  25. package/engine/content-index.mjs +496 -16
  26. package/engine/content-links.mjs +70 -27
  27. package/engine/content-session.mjs +247 -0
  28. package/engine/content-sources.mjs +110 -0
  29. package/engine/content-spans.mjs +87 -8
  30. package/engine/content-tables.mjs +9 -4
  31. package/engine/content-tasklists.mjs +20 -4
  32. package/engine/culture-reach.mjs +61 -34
  33. package/engine/data-keys.mjs +3 -1
  34. package/engine/diagnostics.mjs +17 -6
  35. package/engine/dictionary-layout.mjs +151 -19
  36. package/engine/dictionary-rows.mjs +129 -79
  37. package/engine/extension-attributes.mjs +28 -5
  38. package/engine/first-use.mjs +371 -0
  39. package/engine/foundry-entries.mjs +29 -8
  40. package/engine/generate.mjs +13 -4
  41. package/engine/glossary-entries.mjs +11 -11
  42. package/engine/heading-attributes.mjs +21 -28
  43. package/engine/helpers.mjs +54 -19
  44. package/engine/index-records.mjs +1 -0
  45. package/engine/index.mjs +3 -0
  46. package/engine/journals.mjs +13 -6
  47. package/engine/language-data.mjs +55 -2
  48. package/engine/language-rows.mjs +4 -4
  49. package/engine/language-terms.mjs +90 -4
  50. package/engine/language-words.mjs +2 -2
  51. package/engine/lexical-sections.mjs +247 -0
  52. package/engine/markup-validation.mjs +7 -2
  53. package/engine/metadata-index.mjs +1 -0
  54. package/engine/name-markup.mjs +114 -0
  55. package/engine/name-rows.mjs +70 -0
  56. package/engine/names.mjs +460 -0
  57. package/engine/note-addresses.mjs +25 -4
  58. package/engine/note-claims.mjs +2 -0
  59. package/engine/note-state.mjs +9 -3
  60. package/engine/note-vocabulary.mjs +7 -6
  61. package/engine/path-charset.mjs +24 -1
  62. package/engine/pdf-build.mjs +9 -9
  63. package/engine/pdf-render.mjs +15 -1
  64. package/engine/site-build.mjs +16 -2
  65. package/engine/site-index.mjs +5 -1
  66. package/engine/sql-tables.mjs +324 -44
  67. package/engine/stub-lint.mjs +10 -5
  68. package/engine/web-wikilinks.mjs +8 -6
  69. package/engine/word-rows.mjs +20 -12
  70. package/engine/word-terms.mjs +15 -6
  71. package/engine/yaml-lint.mjs +3 -5
  72. package/hugo-theme/static/css/style.css +4 -0
  73. package/package.json +1 -1
  74. package/types/engine/anchors.d.mts +4 -2
  75. package/types/engine/compendiums.d.mts +5 -1
  76. package/types/engine/compile-corpus.d.mts +5 -2
  77. package/types/engine/content-charset.d.mts +5 -1
  78. package/types/engine/content-html.d.mts +5 -1
  79. package/types/engine/content-icons.d.mts +5 -1
  80. package/types/engine/content-images.d.mts +5 -1
  81. package/types/engine/content-index.d.mts +102 -18
  82. package/types/engine/content-links.d.mts +22 -8
  83. package/types/engine/content-session.d.mts +108 -0
  84. package/types/engine/content-sources.d.mts +32 -0
  85. package/types/engine/content-spans.d.mts +3 -5
  86. package/types/engine/content-tables.d.mts +2 -1
  87. package/types/engine/content-tasklists.d.mts +5 -1
  88. package/types/engine/culture-reach.d.mts +22 -2
  89. package/types/engine/data-keys.d.mts +7 -0
  90. package/types/engine/diagnostics.d.mts +10 -0
  91. package/types/engine/dictionary-layout.d.mts +35 -4
  92. package/types/engine/dictionary-rows.d.mts +9 -0
  93. package/types/engine/extension-attributes.d.mts +16 -2
  94. package/types/engine/first-use.d.mts +31 -0
  95. package/types/engine/foundry-entries.d.mts +14 -5
  96. package/types/engine/generate.d.mts +5 -1
  97. package/types/engine/heading-attributes.d.mts +2 -2
  98. package/types/engine/helpers.d.mts +33 -7
  99. package/types/engine/index.d.mts +1 -0
  100. package/types/engine/journals.d.mts +5 -2
  101. package/types/engine/language-terms.d.mts +7 -0
  102. package/types/engine/lexical-sections.d.mts +96 -0
  103. package/types/engine/markup-validation.d.mts +2 -1
  104. package/types/engine/name-markup.d.mts +176 -0
  105. package/types/engine/name-rows.d.mts +35 -0
  106. package/types/engine/names.d.mts +356 -0
  107. package/types/engine/note-addresses.d.mts +3 -3
  108. package/types/engine/note-state.d.mts +2 -1
  109. package/types/engine/path-charset.d.mts +16 -1
  110. package/types/engine/site-build.d.mts +6 -3
  111. package/types/engine/site-index.d.mts +3 -1
  112. package/types/engine/sql-tables.d.mts +39 -4
  113. package/types/engine/stub-lint.d.mts +3 -1
  114. package/types/engine/word-rows.d.mts +9 -7
@@ -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, and every such value in the note is collected before the decode
293
- * throws, so each reference is reported at its own position: the thrown error
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 shortcode.
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 shortcode.
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,
@@ -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
  },
@@ -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 isEmptyBody(body) && isStubbableType(frontmatter?.type, vocabulary);
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's words, one map each, in the order they print. A language skill " +
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 terms: euphemisms, idioms, terms of art and titles that are not " +
675
- "ranks. An entry with a `definition` creates a term or replaces its senses; one " +
676
- "without marks a term the note already has. Every entry resolves to a language. " +
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. It reaches the affiliation's culture, and its words enter that " +
1824
- "culture's glossary. A glossary entry that states no `language` is in it.",
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
  {
@@ -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) => {