@heroiclands/package-build 23.9.0 → 23.11.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 +33 -0
- package/README.md +2 -0
- package/bin/content-commands.mjs +197 -68
- package/docs/api.md +365 -70
- package/docs/authoring/first-note.md +10 -0
- package/docs/authoring/frontmatter.md +1 -1
- package/docs/authoring/links-and-markup.md +186 -49
- package/docs/commands.md +92 -3
- package/docs/content-format.md +10 -3
- package/docs/design.md +18 -0
- package/docs/guides/book.md +7 -0
- package/docs/guides/site.md +7 -0
- package/docs/reference/format-details.md +186 -15
- package/docs/reference/note-types.md +4 -4
- package/engine/anchors.mjs +43 -9
- 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 +14 -1
- package/engine/content-charset.mjs +13 -1
- 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 +102 -23
- package/engine/content-links.mjs +68 -30
- package/engine/content-session.mjs +247 -0
- package/engine/content-sources.mjs +110 -0
- package/engine/content-spans.mjs +118 -14
- package/engine/content-tables.mjs +1 -0
- package/engine/content-tasklists.mjs +20 -4
- package/engine/diagnostics.mjs +17 -6
- package/engine/extension-attributes.mjs +28 -5
- package/engine/first-use.mjs +371 -0
- package/engine/foundry-entries.mjs +12 -9
- package/engine/generate.mjs +13 -4
- package/engine/helpers.mjs +47 -16
- package/engine/index-records.mjs +1 -0
- package/engine/index.mjs +3 -0
- package/engine/journals.mjs +9 -5
- package/engine/language-data.mjs +2 -2
- package/engine/language-rows.mjs +4 -4
- package/engine/language-terms.mjs +4 -4
- package/engine/lexical-sections.mjs +5 -5
- 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/path-charset.mjs +24 -1
- package/engine/pdf-render.mjs +15 -1
- package/engine/site-index.mjs +25 -2
- package/engine/sql-tables.mjs +76 -15
- package/engine/stub-lint.mjs +4 -3
- package/engine/web-wikilinks.mjs +33 -12
- package/engine/wikilink-syntax.mjs +65 -8
- package/engine/wikilinks.mjs +29 -11
- package/engine/yaml-lint.mjs +3 -5
- package/hugo-theme/layouts/partials/footer.html +2 -0
- package/hugo-theme/static/css/style.css +20 -0
- package/hugo-theme/static/js/inline-spoilers.js +23 -0
- package/package.json +1 -1
- package/types/engine/anchors.d.mts +6 -3
- 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 +39 -19
- 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 +8 -6
- package/types/engine/content-tasklists.d.mts +5 -1
- package/types/engine/diagnostics.d.mts +10 -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 +11 -4
- package/types/engine/generate.d.mts +5 -1
- package/types/engine/helpers.d.mts +30 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/journals.d.mts +5 -2
- package/types/engine/lexical-sections.d.mts +3 -2
- 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/path-charset.d.mts +16 -1
- package/types/engine/site-build.d.mts +2 -2
- package/types/engine/site-index.d.mts +3 -1
- package/types/engine/sql-tables.d.mts +7 -2
- package/types/engine/stub-lint.d.mts +3 -1
- package/types/engine/wikilink-syntax.d.mts +40 -3
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/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) => {
|
package/engine/pdf-render.mjs
CHANGED
|
@@ -1210,7 +1210,15 @@ function renderInline(token, ctx) {
|
|
|
1210
1210
|
out.push(inlineRaw(child.content));
|
|
1211
1211
|
break;
|
|
1212
1212
|
case "span_open": {
|
|
1213
|
-
|
|
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
|
+
' if classes.contains("secret") { box(fill: rgb("#f2eefb"), inset: (x: 3pt, y: 1pt), text(fill: rgb("#5c4b8a"))[#strong[Secret:] #styled]) } else { 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" +
|
package/engine/site-index.mjs
CHANGED
|
@@ -70,7 +70,7 @@ import { contentPackage } from "./content-package.mjs";
|
|
|
70
70
|
import { reckoningContext } from "./reckoning-markers.mjs";
|
|
71
71
|
import { presentAmongRecords } from "./being-age.mjs";
|
|
72
72
|
// The declared tag vocabulary, which is where `draft` is stated.
|
|
73
|
-
import { isDraftNote } from "./note-vocabulary.mjs";
|
|
73
|
+
import { isGmNote, isDraftNote } from "./note-vocabulary.mjs";
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
76
|
* One page the site will publish, as the index needs to see it.
|
|
@@ -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();
|
|
@@ -217,6 +219,24 @@ export function buildSiteIndex(
|
|
|
217
219
|
// a stale vendored manifest and a shadowed local page.
|
|
218
220
|
const { ambiguous } = mergeForeign(index, foreignIndex);
|
|
219
221
|
|
|
222
|
+
// Private notes have no public page, but still resolve references safely.
|
|
223
|
+
for (const record of records) {
|
|
224
|
+
if (!isGmNote(record) || !record.address || !record.shortcode) continue;
|
|
225
|
+
contentTypes.add(record.type);
|
|
226
|
+
const value = {
|
|
227
|
+
name: record.name?.full ?? record.name,
|
|
228
|
+
gm: true,
|
|
229
|
+
noteAnchors: record.anchors ?? [],
|
|
230
|
+
};
|
|
231
|
+
index.set(`${record.type}/${record.shortcode}`.toLowerCase(), value);
|
|
232
|
+
const pkg = record.package ?? ownPackage;
|
|
233
|
+
index.set(
|
|
234
|
+
canonicalKey(pkg, ownDocumentSystem(record.type), record.type, record.shortcode),
|
|
235
|
+
value,
|
|
236
|
+
);
|
|
237
|
+
index.set(canonicalKey(pkg, "note", record.type, record.shortcode), value);
|
|
238
|
+
}
|
|
239
|
+
|
|
220
240
|
for (const e of entries) {
|
|
221
241
|
// Only a content note is addressable; anything else is carried
|
|
222
242
|
// through and takes no part in type-scoped indexing.
|
|
@@ -231,17 +251,20 @@ export function buildSiteIndex(
|
|
|
231
251
|
// `subType` is: a being states a rank as a number, and what that
|
|
232
252
|
// number is called is declared on the body.
|
|
233
253
|
const standings = standingsDigest(e.fm);
|
|
254
|
+
const noteAnchors = collectAnchors(e.body ?? "", 1, undefined, syntax);
|
|
234
255
|
const value = {
|
|
235
256
|
url: e.url,
|
|
236
257
|
name: e.name,
|
|
237
258
|
draft: isDraftNote(e.fm),
|
|
259
|
+
gm: isGmNote(e.fm),
|
|
260
|
+
noteAnchors,
|
|
238
261
|
...(e.fm.subType ? { subType: e.fm.subType } : {}),
|
|
239
262
|
...(standings ? { standings } : {}),
|
|
240
263
|
// The `{#anchor}` slugs this page declares, so a `#section`
|
|
241
264
|
// written against it can be checked rather than joined onto the
|
|
242
265
|
// URL unverified. A Set even when empty: a page that declares no
|
|
243
266
|
// anchor still has a definite answer for one an author names.
|
|
244
|
-
anchors: new Set(
|
|
267
|
+
anchors: new Set(noteAnchors.map((anchor) => anchor.slug)),
|
|
245
268
|
// The note's event ids, which a wikilink may not name.
|
|
246
269
|
eventAnchors: new Set(eventAnchors(e.fm).map((anchor) => anchor.slug)),
|
|
247
270
|
};
|