@heroiclands/package-build 23.9.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 (85) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +2 -0
  3. package/bin/content-commands.mjs +197 -68
  4. package/docs/api.md +336 -68
  5. package/docs/authoring/links-and-markup.md +114 -47
  6. package/docs/commands.md +92 -3
  7. package/docs/content-format.md +6 -1
  8. package/docs/reference/format-details.md +129 -14
  9. package/docs/reference/note-types.md +4 -4
  10. package/engine/anchors.mjs +8 -6
  11. package/engine/base-compiler.mjs +6 -4
  12. package/engine/changelog-group.mjs +58 -34
  13. package/engine/changelog-lint.mjs +6 -2
  14. package/engine/compendiums.mjs +15 -1
  15. package/engine/compile-corpus.mjs +14 -1
  16. package/engine/content-charset.mjs +13 -1
  17. package/engine/content-html.mjs +18 -4
  18. package/engine/content-icons.mjs +15 -1
  19. package/engine/content-images.mjs +28 -9
  20. package/engine/content-index.mjs +102 -23
  21. package/engine/content-links.mjs +27 -12
  22. package/engine/content-session.mjs +247 -0
  23. package/engine/content-sources.mjs +110 -0
  24. package/engine/content-spans.mjs +87 -8
  25. package/engine/content-tables.mjs +1 -0
  26. package/engine/content-tasklists.mjs +20 -4
  27. package/engine/diagnostics.mjs +17 -6
  28. package/engine/extension-attributes.mjs +28 -5
  29. package/engine/first-use.mjs +371 -0
  30. package/engine/foundry-entries.mjs +12 -9
  31. package/engine/generate.mjs +13 -4
  32. package/engine/helpers.mjs +43 -14
  33. package/engine/index-records.mjs +1 -0
  34. package/engine/index.mjs +3 -0
  35. package/engine/journals.mjs +9 -5
  36. package/engine/language-data.mjs +2 -2
  37. package/engine/language-rows.mjs +4 -4
  38. package/engine/language-terms.mjs +4 -4
  39. package/engine/lexical-sections.mjs +5 -5
  40. package/engine/markup-validation.mjs +7 -2
  41. package/engine/metadata-index.mjs +1 -0
  42. package/engine/name-markup.mjs +114 -0
  43. package/engine/name-rows.mjs +70 -0
  44. package/engine/names.mjs +460 -0
  45. package/engine/note-addresses.mjs +25 -4
  46. package/engine/path-charset.mjs +24 -1
  47. package/engine/pdf-render.mjs +15 -1
  48. package/engine/site-index.mjs +5 -1
  49. package/engine/sql-tables.mjs +76 -15
  50. package/engine/stub-lint.mjs +4 -3
  51. package/engine/yaml-lint.mjs +3 -5
  52. package/hugo-theme/static/css/style.css +4 -0
  53. package/package.json +1 -1
  54. package/types/engine/anchors.d.mts +4 -2
  55. package/types/engine/compendiums.d.mts +5 -1
  56. package/types/engine/compile-corpus.d.mts +5 -2
  57. package/types/engine/content-charset.d.mts +5 -1
  58. package/types/engine/content-html.d.mts +5 -1
  59. package/types/engine/content-icons.d.mts +5 -1
  60. package/types/engine/content-images.d.mts +5 -1
  61. package/types/engine/content-index.d.mts +39 -19
  62. package/types/engine/content-links.d.mts +22 -8
  63. package/types/engine/content-session.d.mts +108 -0
  64. package/types/engine/content-sources.d.mts +32 -0
  65. package/types/engine/content-spans.d.mts +3 -5
  66. package/types/engine/content-tasklists.d.mts +5 -1
  67. package/types/engine/diagnostics.d.mts +10 -0
  68. package/types/engine/extension-attributes.d.mts +16 -2
  69. package/types/engine/first-use.d.mts +31 -0
  70. package/types/engine/foundry-entries.d.mts +11 -4
  71. package/types/engine/generate.d.mts +5 -1
  72. package/types/engine/helpers.d.mts +30 -7
  73. package/types/engine/index.d.mts +1 -0
  74. package/types/engine/journals.d.mts +5 -2
  75. package/types/engine/lexical-sections.d.mts +3 -2
  76. package/types/engine/markup-validation.d.mts +2 -1
  77. package/types/engine/name-markup.d.mts +176 -0
  78. package/types/engine/name-rows.d.mts +35 -0
  79. package/types/engine/names.d.mts +356 -0
  80. package/types/engine/note-addresses.d.mts +3 -3
  81. package/types/engine/path-charset.d.mts +16 -1
  82. package/types/engine/site-build.d.mts +2 -2
  83. package/types/engine/site-index.d.mts +3 -1
  84. package/types/engine/sql-tables.d.mts +7 -2
  85. package/types/engine/stub-lint.d.mts +3 -1
@@ -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,
@@ -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) => {
@@ -1210,7 +1210,15 @@ function renderInline(token, ctx) {
1210
1210
  out.push(inlineRaw(child.content));
1211
1211
  break;
1212
1212
  case "span_open": {
1213
- out.push("#box[");
1213
+ const classes = (child.attrGet?.("class") ?? "").split(/\s+/).filter(Boolean);
1214
+ const values = classes
1215
+ .map((value) => '"' + escapeTypstString(value) + '"')
1216
+ .join(", ");
1217
+ out.push(
1218
+ classes.length ?
1219
+ "#book-span(classes: (" + values + (classes.length === 1 ? "," : "") + "))["
1220
+ : "#box[",
1221
+ );
1214
1222
  break;
1215
1223
  }
1216
1224
  case "span_close": {
@@ -1618,6 +1626,12 @@ export function bookTypstPreamble() {
1618
1626
  "#let book-page-height = 11in",
1619
1627
  "#let book-text-width = book-page-width - 2 * book-margin",
1620
1628
  "#let book-text-height = book-page-height - 2 * book-margin",
1629
+ // Span classes reach the book intact; name styling sets an absolute
1630
+ // weight so it remains bold inside an ordinary strong phrase.
1631
+ "#let book-span(body, classes: ()) = {\n" +
1632
+ ' let styled = if classes.contains("name") { text(weight: "bold", body) } else { body }\n' +
1633
+ " box(styled)\n" +
1634
+ "}",
1621
1635
  // Use the file's natural measure for auto. Every requested width is
1622
1636
  // bounded by the available measure and by the height of a page.
1623
1637
  "#let book-image(path, requested: auto, caption: none) = layout(size => {\n" +
@@ -174,6 +174,7 @@ function mergeForeign(index, foreignIndex) {
174
174
  * ordinary dead address.
175
175
  * @param {object[]} [options.records] - Content-index records.
176
176
  * @param {Map<string, object>} [options.foreignReferences] - Foreign reference targets.
177
+ * @param {import("./content-session.mjs").ContentSyntax} [options.syntax] - Exact-text scanner projections.
177
178
  * @returns {SiteIndex} The index, and what could not be addressed unambiguously.
178
179
  */
179
180
  export function buildSiteIndex(
@@ -184,6 +185,7 @@ export function buildSiteIndex(
184
185
  package: builderPackage,
185
186
  records = [],
186
187
  foreignReferences = new Map(),
188
+ syntax,
187
189
  } = {},
188
190
  ) {
189
191
  const index = new Map();
@@ -241,7 +243,9 @@ export function buildSiteIndex(
241
243
  // written against it can be checked rather than joined onto the
242
244
  // URL unverified. A Set even when empty: a page that declares no
243
245
  // anchor still has a definite answer for one an author names.
244
- anchors: new Set(collectAnchors(e.body ?? "").map((anchor) => anchor.slug)),
246
+ anchors: new Set(
247
+ collectAnchors(e.body ?? "", 1, undefined, syntax).map((anchor) => anchor.slug),
248
+ ),
245
249
  // The note's event ids, which a wikilink may not name.
246
250
  eventAnchors: new Set(eventAnchors(e.fm).map((anchor) => anchor.slug)),
247
251
  };