@heroiclands/package-build 25.1.3 → 25.2.1

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 (87) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/assets/styles/content-base.css +0 -9
  3. package/bin/content-commands.mjs +22 -16
  4. package/bin/package-build.mjs +24 -25
  5. package/content-config.mjs +7 -34
  6. package/docs/api.md +38 -21
  7. package/docs/authoring/first-note.md +3 -3
  8. package/docs/authoring/frontmatter.md +30 -2
  9. package/docs/authoring/links-and-markup.md +92 -31
  10. package/docs/commands.md +103 -59
  11. package/docs/configuration.md +10 -45
  12. package/docs/content-format.md +8 -6
  13. package/docs/diagnostics.md +75 -0
  14. package/docs/guides/book.md +3 -3
  15. package/docs/guides/module-project.md +1 -1
  16. package/docs/guides/reference-pages.md +7 -3
  17. package/docs/project-setup.md +3 -0
  18. package/docs/reference/format-details.md +299 -185
  19. package/docs/reference/note-types.md +16 -17
  20. package/docs/source-preview.md +2 -1
  21. package/engine/changelog-group.mjs +84 -173
  22. package/engine/changelog-labels.mjs +73 -0
  23. package/engine/changelog-lint.mjs +168 -44
  24. package/engine/content-expand.mjs +58 -38
  25. package/engine/content-format.yaml +0 -2
  26. package/engine/content-index.mjs +49 -23
  27. package/engine/content-links.mjs +4 -0
  28. package/engine/content-lint.mjs +2 -3
  29. package/engine/content-render.mjs +6 -2
  30. package/engine/culture-parents.mjs +1 -1
  31. package/engine/culture-reach.mjs +10 -1
  32. package/engine/diagnostics.mjs +3 -2
  33. package/engine/dictionary-rows.mjs +3 -1
  34. package/engine/first-use.mjs +301 -87
  35. package/engine/frontmatter-lint.mjs +40 -14
  36. package/engine/glossary-entries.mjs +5 -5
  37. package/engine/glossary-language.mjs +21 -44
  38. package/engine/homepage.mjs +0 -4
  39. package/engine/index-records.mjs +3 -1
  40. package/engine/infobox.mjs +6 -3
  41. package/engine/journals.mjs +14 -14
  42. package/engine/language-names.mjs +3 -2
  43. package/engine/language-rows.mjs +6 -7
  44. package/engine/lexical-sections.mjs +3 -1
  45. package/engine/literature-notes.mjs +4 -76
  46. package/engine/metadata-index.mjs +15 -8
  47. package/engine/name-forms.mjs +99 -0
  48. package/engine/name-refs.mjs +143 -0
  49. package/engine/name-rows.mjs +11 -1
  50. package/engine/names.mjs +27 -22
  51. package/engine/note-addresses.mjs +0 -3
  52. package/engine/note-frontmatter.mjs +3 -5
  53. package/engine/note-language.mjs +348 -0
  54. package/engine/note-vocabulary.mjs +13 -11
  55. package/engine/pdf-index.mjs +3 -2
  56. package/engine/pdf-render.mjs +2 -4
  57. package/engine/preview-source.mjs +15 -8
  58. package/engine/settlement-tables.mjs +4 -3
  59. package/engine/site-build.mjs +2 -7
  60. package/engine/site-search.mjs +3 -5
  61. package/engine/site-theme.mjs +1 -1
  62. package/engine/sql-tables.mjs +29 -14
  63. package/engine/wikilink-syntax.mjs +2 -0
  64. package/engine/word-citations.mjs +3 -3
  65. package/engine/word-rows.mjs +3 -1
  66. package/engine/word-terms.mjs +2 -2
  67. package/package.json +1 -1
  68. package/site-theme/static/css/style.css +0 -4
  69. package/types/engine/changelog-group.d.mts +6 -10
  70. package/types/engine/changelog-labels.d.mts +10 -0
  71. package/types/engine/changelog-lint.d.mts +11 -6
  72. package/types/engine/content-index.d.mts +16 -16
  73. package/types/engine/diagnostics.d.mts +1 -1
  74. package/types/engine/first-use.d.mts +97 -1
  75. package/types/engine/frontmatter-lint.d.mts +9 -2
  76. package/types/engine/glossary-language.d.mts +13 -6
  77. package/types/engine/homepage.d.mts +0 -3
  78. package/types/engine/journals.d.mts +3 -3
  79. package/types/engine/literature-notes.d.mts +2 -27
  80. package/types/engine/name-forms.d.mts +61 -0
  81. package/types/engine/name-refs.d.mts +51 -0
  82. package/types/engine/name-rows.d.mts +4 -0
  83. package/types/engine/names.d.mts +12 -92
  84. package/types/engine/note-frontmatter.d.mts +1 -4
  85. package/types/engine/note-language.d.mts +47 -0
  86. package/types/engine/settlement-tables.d.mts +1 -1
  87. package/types/engine/site-build.d.mts +2 -6
@@ -0,0 +1,143 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * The `ref=` attribute of a `.name` span: the note a spelling belongs to, or a
16
+ * page-local key for a subject with no note. Shape findings need no catalog;
17
+ * resolution findings take the prepared name catalog.
18
+ * @module
19
+ */
20
+
21
+ import { rebaseNameSpans } from "./name-markup.mjs";
22
+ import { scanSpans } from "./content-spans.mjs";
23
+
24
+ /** A page-local key: `#` then a word of letters, digits, `_` and `-`. */
25
+ const LOCAL_KEY = /^#[\p{L}\p{N}][\p{L}\p{N}_-]*$/u;
26
+
27
+ const isName = (span) =>
28
+ span.attrs.some(([key, value]) => key === "class" && value.split(/\s+/).includes("name"));
29
+
30
+ /**
31
+ * Whether a `ref` value is a page-local key rather than a note address.
32
+ * @param {string} ref - The attribute value as written.
33
+ * @returns {boolean} True for a value beginning with `#`.
34
+ */
35
+ export const isLocalRef = (ref) => ref.startsWith("#");
36
+
37
+ /**
38
+ * The `ref` a span states.
39
+ * @param {{attrs: ReadonlyArray<readonly [string, string]>}} span - A parsed span.
40
+ * @returns {string|null} The value as written, or null when the span has none.
41
+ */
42
+ export function readNameRef(span) {
43
+ return span.attrs.find(([key]) => key === "ref")?.[1] ?? null;
44
+ }
45
+
46
+ /** A finding at a named attribute part, with the position left out when unknown. */
47
+ function at(span, key, part, file, message) {
48
+ const site = span.attributePositions?.[key]?.[part];
49
+ return {
50
+ ...(file === undefined ? {} : { file }),
51
+ ...(Number.isFinite(site?.line) ? { line: site.line } : {}),
52
+ ...(Number.isFinite(site?.column) ? { column: site.column } : {}),
53
+ severity: "error",
54
+ message,
55
+ };
56
+ }
57
+
58
+ /**
59
+ * The `ref=` findings that depend on the span alone.
60
+ * @param {ReadonlyArray<import("./names.mjs").ParsedNameSpan>} spans - Every parsed span, at file coordinates.
61
+ * @param {{file?: string}} [options] - Authored source file.
62
+ * @returns {Array<{file?: string, line?: number, column?: number, severity: "error", message: string}>} One error per defect.
63
+ */
64
+ export function nameRefFindings(spans, { file } = {}) {
65
+ const findings = [];
66
+ for (const span of spans) {
67
+ const ref = readNameRef(span);
68
+ if (ref === null) continue;
69
+ if (!isName(span)) {
70
+ findings.push(
71
+ at(
72
+ span,
73
+ "ref",
74
+ "key",
75
+ file,
76
+ "ref= belongs on a .name span; a wikilink never takes ref",
77
+ ),
78
+ );
79
+ continue;
80
+ }
81
+ if (span.attrs.some(([key]) => key === "data-type"))
82
+ findings.push(
83
+ at(
84
+ span,
85
+ "data-type",
86
+ "key",
87
+ file,
88
+ "data-type is for a name with no note; the ref states the type",
89
+ ),
90
+ );
91
+ if (isLocalRef(ref) && !LOCAL_KEY.test(ref))
92
+ findings.push(
93
+ at(
94
+ span,
95
+ "ref",
96
+ "value",
97
+ file,
98
+ `ref ${JSON.stringify(ref)} must be a note address or a page-local key such as #jane`,
99
+ ),
100
+ );
101
+ }
102
+ return findings;
103
+ }
104
+
105
+ /**
106
+ * Create the check that every note `ref=` of a note names a note.
107
+ * @param {object} options - The lint's prepared inputs.
108
+ * @param {import("./names.mjs").NameCatalog} options.catalog - Resolves an address to its note.
109
+ * @param {Map<string, {raw: string, body: string, bodyLine?: number, bodyColumn?: number}>} [options.parsed] - Captured authored bodies.
110
+ * @param {import("./content-session.mjs").ContentSyntax} [options.syntax] - Exact-text scanner projections.
111
+ * @returns {(note: {file: string, raw: string}) => Array<{file: string, line?: number, column?: number, severity: "error", message: string}>} One error per unresolved address.
112
+ */
113
+ export function createNameRefChecker({ catalog, parsed, syntax }) {
114
+ return ({ file, raw }) => {
115
+ const authored = parsed?.get(file);
116
+ const spans =
117
+ authored?.raw === raw && Number.isInteger(authored.bodyLine) ?
118
+ rebaseNameSpans(
119
+ (syntax ? syntax(authored.body).spans : scanSpans(authored.body)).names,
120
+ {
121
+ raw,
122
+ bodyLine: authored.bodyLine,
123
+ bodyColumn: authored.bodyColumn,
124
+ },
125
+ )
126
+ : scanSpans(raw).names;
127
+ const findings = [];
128
+ for (const span of spans) {
129
+ const ref = readNameRef(span);
130
+ if (ref === null || isLocalRef(ref) || catalog.resolve(ref)) continue;
131
+ findings.push(
132
+ at(
133
+ span,
134
+ "ref",
135
+ "value",
136
+ file,
137
+ `ref ${JSON.stringify(ref)} names no note in this package or its declared dependencies`,
138
+ ),
139
+ );
140
+ }
141
+ return findings;
142
+ };
143
+ }
@@ -22,7 +22,15 @@ import { encodeAddresses } from "./address-values.mjs";
22
22
  import { isNoteRecord } from "./index-records.mjs";
23
23
 
24
24
  /** Every column of the `names` relation, in order. */
25
- export const NAME_COLUMNS = Object.freeze(["owner", "name", "type", "subType", "line", "column"]);
25
+ export const NAME_COLUMNS = Object.freeze([
26
+ "owner",
27
+ "name",
28
+ "type",
29
+ "subType",
30
+ "ref",
31
+ "line",
32
+ "column",
33
+ ]);
26
34
 
27
35
  /**
28
36
  * @typedef {object} NameRow
@@ -30,6 +38,7 @@ export const NAME_COLUMNS = Object.freeze(["owner", "name", "type", "subType", "
30
38
  * @property {string} name - Rendered visible text of the occurrence.
31
39
  * @property {string|null} type - Supplied note type, or null.
32
40
  * @property {string|null} subType - Supplied subtype, or null.
41
+ * @property {string|null} ref - The `ref=` as written, a note address or a page-local `#key`, or null.
33
42
  * @property {number} line - Original 1-based source line at the opening bracket.
34
43
  * @property {number} column - Original 1-based UTF-16 source column at that bracket.
35
44
  */
@@ -61,6 +70,7 @@ export function namesRows(records) {
61
70
  name: entry.name,
62
71
  type: entry.type ?? null,
63
72
  subType: entry.subType ?? null,
73
+ ref: entry.ref ?? null,
64
74
  line: entry.line,
65
75
  column: entry.column,
66
76
  });
package/engine/names.mjs CHANGED
@@ -19,7 +19,9 @@ import { formatLocator } from "./diagnostics.mjs";
19
19
  import { FOLDER_TYPE } from "./folder-notes.mjs";
20
20
  import { isNoteRecord } from "./index-records.mjs";
21
21
  import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
22
+ import { readNameRef } from "./name-refs.mjs";
22
23
  import { WORD } from "./word-pattern.mjs";
24
+ import { displayName, nameFormsOf, normalizeName } from "./name-forms.mjs";
23
25
 
24
26
  const compare = (a, b) =>
25
27
  a < b ? -1
@@ -32,7 +34,7 @@ const compare = (a, b) =>
32
34
  * @property {string} key - Canonical page identity, or the ambiguous label identity.
33
35
  * @property {string} full - Declared full name.
34
36
  * @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.
37
+ * @property {boolean} [fullUse] - The label is a form of the full name rather than a listed form.
36
38
  * @property {string} [type] - The note's type, absent on an ambiguous label identity.
37
39
  * @property {string|null} [subType] - The note's subtype, or null.
38
40
  * @property {NameMatch[]} [candidates] - Indexed identities sharing the label.
@@ -40,7 +42,7 @@ const compare = (a, b) =>
40
42
  */
41
43
 
42
44
  /**
43
- * The prepared name and declared-alias lookup.
45
+ * The prepared lookup of full names and listed forms.
44
46
  * @typedef {object} NameCatalog
45
47
  * @property {(label: string, preferred?: {key: string}, kind?: NameKind) => NameMatch|undefined} recognize - Match a whole visible label, among notes of `kind` when it supplies a type.
46
48
  * @property {(target: string) => NameMatch|undefined} resolve - Resolve an Address target without its anchor to its indexed identity.
@@ -60,6 +62,7 @@ const compare = (a, b) =>
60
62
  * @property {string} name - Rendered visible text.
61
63
  * @property {string|null} type - Supplied valid type, or null.
62
64
  * @property {string|null} subType - Supplied valid subtype, or null.
65
+ * @property {string|null} ref - The `ref=` as written, a note address or a `#key`, or null.
63
66
  * @property {number} line - Original 1-based file line.
64
67
  * @property {number} column - Original 1-based UTF-16 file column.
65
68
  */
@@ -116,19 +119,14 @@ const compare = (a, b) =>
116
119
  * @property {NameConflict[]} conflicts - Labels with incompatible supplied kinds.
117
120
  */
118
121
 
119
- /**
120
- * Normalize a whole visible label without folding its case.
121
- * @param {string} text - Visible name or declared alias.
122
- * @returns {string} NFC text with straight apostrophes and single spaces.
123
- */
124
- export function normalizeName(text) {
125
- return text.normalize("NFC").replaceAll("’", "'").replace(/\s+/g, " ").trim();
126
- }
122
+ // A label's matching key folds case; its display form keeps it.
123
+ export { displayName, normalizeName } from "./name-forms.mjs";
127
124
 
128
125
  /**
129
- * Whole-label possessives and regular plurals; other forms are declared aliases.
130
- * @param {string} text - Full name or declared alias.
131
- * @returns {string[]} Supported whole-label forms.
126
+ * Whole-label possessives and regular plurals, as matching keys; any other
127
+ * form is one a note's name lists.
128
+ * @param {string} text - Full name or a listed form.
129
+ * @returns {string[]} Supported whole-label forms, case folded.
132
130
  */
133
131
  export function nameForms(text) {
134
132
  const value = normalizeName(text);
@@ -150,7 +148,7 @@ function identity(record) {
150
148
  }
151
149
 
152
150
  /**
153
- * Prepare full names, declared aliases and link identities from captured records.
151
+ * Prepare full names, every listed form and link identities from captured records.
154
152
  * @param {object} options - The invocation's existing corpus and link index.
155
153
  * @param {readonly object[]} options.records - Captured local records.
156
154
  * @param {ReturnType<typeof import("./content-links.mjs").buildLinkIndex>} options.index - Captured address space.
@@ -160,7 +158,7 @@ export function createNameCatalog({ records, index }) {
160
158
  const names = new Map(),
161
159
  byFile = new Map(),
162
160
  entries = new Map();
163
- const addName = (key, full, aliases, type, subType) => {
161
+ const addName = (key, full, forms, type, subType) => {
164
162
  const entry = entries.get(key) ?? {
165
163
  key,
166
164
  full,
@@ -169,7 +167,7 @@ export function createNameCatalog({ records, index }) {
169
167
  subType: typeof subType === "string" && subType ? subType : null,
170
168
  };
171
169
  entries.set(key, entry);
172
- for (const text of [full, ...(aliases ?? [])]) {
170
+ for (const text of [full, ...(forms ?? [])]) {
173
171
  if (typeof text !== "string" || !text.trim()) continue;
174
172
  for (const spelling of nameForms(text)) {
175
173
  if (!names.has(spelling)) names.set(spelling, new Map());
@@ -192,7 +190,7 @@ export function createNameCatalog({ records, index }) {
192
190
  const entry = addName(
193
191
  index.resolve(page) ? page : canonical,
194
192
  full,
195
- record.name.aliases,
193
+ nameFormsOf(record.name),
196
194
  parts.type,
197
195
  record.subType,
198
196
  );
@@ -211,7 +209,10 @@ export function createNameCatalog({ records, index }) {
211
209
  continue;
212
210
  const page = canonicalKey(parts.package, "note", parts.type, parts.shortcode);
213
211
  const key = index.foreign.index.has(page) ? page : canonical;
214
- entries.set(canonical, addName(key, hit.name, hit.aliases, parts.type, hit.subType));
212
+ entries.set(
213
+ canonical,
214
+ addName(key, hit.name, nameFormsOf(hit.forms), parts.type, hit.subType),
215
+ );
215
216
  }
216
217
  return {
217
218
  recognize(label, preferred, kind) {
@@ -302,8 +303,9 @@ export function collectUnwrittenNames({ records, catalog }) {
302
303
  if (!kinds.has(kind)) kinds.set(kind, { type, subType, names: new Map() });
303
304
  const names = kinds.get(kind).names,
304
305
  name = normalizeName(occurrence.name);
305
- if (!names.has(name)) names.set(name, []);
306
- names.get(name).push(nameUse(record, occurrence));
306
+ if (!names.has(name))
307
+ names.set(name, { display: displayName(occurrence.name), uses: [] });
308
+ names.get(name).uses.push(nameUse(record, occurrence));
307
309
  }
308
310
  }
309
311
  const groups = [...kinds.values()]
@@ -316,7 +318,7 @@ export function collectUnwrittenNames({ records, catalog }) {
316
318
  subType,
317
319
  names: [...names]
318
320
  .sort(([a], [b]) => compare(a, b))
319
- .map(([name, uses]) => ({ name, uses: uses.sort(compareUses) })),
321
+ .map(([, { display, uses }]) => ({ name: display, uses: uses.sort(compareUses) })),
320
322
  }));
321
323
  const firstUse = firstNameUse(
322
324
  groups.flatMap((group) => group.names.flatMap((name) => name.uses)),
@@ -383,6 +385,7 @@ export function analyzeNameSpans(spans, { file, vocabulary = NOTE_VOCABULARY, ty
383
385
  name: span.visible,
384
386
  type: kind.type,
385
387
  subType: kind.subType,
388
+ ref: readNameRef(span),
386
389
  line: span.line,
387
390
  column: span.column,
388
391
  });
@@ -401,11 +404,13 @@ export function analyzeNameSpans(spans, { file, vocabulary = NOTE_VOCABULARY, ty
401
404
  */
402
405
  export function createNamesReport({ package: contentPackage, records, catalog }) {
403
406
  const names = new Map();
407
+ const displays = new Map();
404
408
  for (const record of records.filter(isNoteRecord))
405
409
  for (const occurrence of record.names ?? []) {
406
410
  if (occurrence.type === null) continue;
407
411
  const name = normalizeName(occurrence.name);
408
412
  if (!names.has(name)) names.set(name, new Map());
413
+ if (!displays.has(name)) displays.set(name, displayName(occurrence.name));
409
414
  const kinds = names.get(name),
410
415
  key = JSON.stringify([occurrence.type, occurrence.subType]);
411
416
  if (!kinds.has(key))
@@ -421,7 +426,7 @@ export function createNamesReport({ package: contentPackage, records, catalog })
421
426
  }
422
427
  if (parents.size < 2 && ![...parents.values()].some((values) => values.size > 1)) continue;
423
428
  conflicts.push({
424
- name,
429
+ name: displays.get(name),
425
430
  kinds: [...kinds.values()]
426
431
  .sort(
427
432
  (a, b) => compare(a.type, b.type) || compare(a.subType ?? "", b.subType ?? ""),
@@ -159,9 +159,6 @@ export function addressPositions(fm, context = {}) {
159
159
  ...(ASSET_TYPE_NAMES.has(field.ref) || field.ref === "folder" ?
160
160
  { system: "none" }
161
161
  : {}),
162
- ...(field.name === "language" && field.ref === "skill" ?
163
- { system: NOTE_SYSTEM, noteOnly: true }
164
- : {}),
165
162
  });
166
163
  }
167
164
  out.push(...STRUCTURED_ADDRESSES.filter((p) => !p.noteType || p.noteType === fm.type));
@@ -21,11 +21,9 @@ export {
21
21
  NOTE_TOP_LEVEL_KEY_SET,
22
22
  } from "./note-vocabulary.mjs";
23
23
 
24
- /** The name properties every note accepts, in display order. */
25
- export const NOTE_NAME_KEYS = Object.freeze(["full", "aliases", "sort"]);
26
-
27
- /** Additional name components accepted by an individual character. */
28
- export const CHARACTER_NAME_KEYS = Object.freeze(["given", "clan"]);
24
+ // The single-string name keys, beside the language form lists they are told
25
+ // apart from.
26
+ export { CHARACTER_NAME_KEYS, NOTE_NAME_KEYS } from "./name-forms.mjs";
29
27
 
30
28
  /** Return authored keys from an opening YAML frontmatter map. */
31
29
  export function authoredNoteKeys(markdown) {