@heroiclands/package-build 25.1.3 → 25.2.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 +29 -0
- package/assets/styles/content-base.css +0 -9
- package/bin/content-commands.mjs +22 -16
- package/bin/package-build.mjs +24 -25
- package/content-config.mjs +7 -34
- package/docs/api.md +38 -21
- package/docs/authoring/first-note.md +3 -3
- package/docs/authoring/frontmatter.md +30 -2
- package/docs/authoring/links-and-markup.md +92 -31
- package/docs/commands.md +103 -59
- package/docs/configuration.md +10 -45
- package/docs/content-format.md +7 -5
- package/docs/diagnostics.md +75 -0
- package/docs/guides/book.md +3 -3
- package/docs/guides/module-project.md +1 -1
- package/docs/guides/reference-pages.md +7 -3
- package/docs/project-setup.md +3 -0
- package/docs/reference/format-details.md +299 -185
- package/docs/reference/note-types.md +16 -17
- package/engine/changelog-group.mjs +84 -173
- package/engine/changelog-labels.mjs +73 -0
- package/engine/changelog-lint.mjs +168 -44
- package/engine/content-expand.mjs +58 -38
- package/engine/content-format.yaml +0 -2
- package/engine/content-index.mjs +49 -23
- package/engine/content-links.mjs +4 -0
- package/engine/content-lint.mjs +2 -3
- package/engine/content-render.mjs +6 -2
- package/engine/culture-parents.mjs +1 -1
- package/engine/culture-reach.mjs +10 -1
- package/engine/diagnostics.mjs +3 -2
- package/engine/dictionary-rows.mjs +3 -1
- package/engine/first-use.mjs +301 -87
- package/engine/frontmatter-lint.mjs +40 -14
- package/engine/glossary-entries.mjs +5 -5
- package/engine/glossary-language.mjs +21 -44
- package/engine/homepage.mjs +0 -4
- package/engine/index-records.mjs +3 -1
- package/engine/infobox.mjs +6 -3
- package/engine/language-names.mjs +3 -2
- package/engine/language-rows.mjs +6 -7
- package/engine/lexical-sections.mjs +3 -1
- package/engine/literature-notes.mjs +4 -76
- package/engine/metadata-index.mjs +15 -8
- package/engine/name-forms.mjs +99 -0
- package/engine/name-refs.mjs +143 -0
- package/engine/name-rows.mjs +11 -1
- package/engine/names.mjs +27 -22
- package/engine/note-addresses.mjs +0 -3
- package/engine/note-frontmatter.mjs +3 -5
- package/engine/note-language.mjs +348 -0
- package/engine/note-vocabulary.mjs +13 -11
- package/engine/pdf-index.mjs +3 -2
- package/engine/pdf-render.mjs +2 -4
- package/engine/settlement-tables.mjs +4 -3
- package/engine/site-build.mjs +2 -7
- package/engine/site-search.mjs +3 -5
- package/engine/site-theme.mjs +1 -1
- package/engine/sql-tables.mjs +29 -14
- package/engine/wikilink-syntax.mjs +2 -0
- package/engine/word-citations.mjs +3 -3
- package/engine/word-rows.mjs +3 -1
- package/engine/word-terms.mjs +2 -2
- package/package.json +1 -1
- package/site-theme/static/css/style.css +0 -4
- package/types/engine/changelog-group.d.mts +6 -10
- package/types/engine/changelog-labels.d.mts +10 -0
- package/types/engine/changelog-lint.d.mts +11 -6
- package/types/engine/content-index.d.mts +16 -16
- package/types/engine/diagnostics.d.mts +1 -1
- package/types/engine/first-use.d.mts +97 -1
- package/types/engine/frontmatter-lint.d.mts +9 -2
- package/types/engine/glossary-language.d.mts +13 -6
- package/types/engine/homepage.d.mts +0 -3
- package/types/engine/literature-notes.d.mts +2 -27
- package/types/engine/name-forms.d.mts +61 -0
- package/types/engine/name-refs.d.mts +51 -0
- package/types/engine/name-rows.d.mts +4 -0
- package/types/engine/names.d.mts +12 -92
- package/types/engine/note-frontmatter.d.mts +1 -4
- package/types/engine/note-language.d.mts +47 -0
- package/types/engine/settlement-tables.d.mts +1 -1
- package/types/engine/site-build.d.mts +2 -6
package/engine/culture-reach.mjs
CHANGED
|
@@ -38,6 +38,7 @@ import { isAddressTuple, parseAddress, renderAddress } from "./address.mjs";
|
|
|
38
38
|
import { AddressLink } from "./address-values.mjs";
|
|
39
39
|
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
40
40
|
import { glossaryLanguage } from "./glossary-language.mjs";
|
|
41
|
+
import { languageNotesByCode } from "./note-language.mjs";
|
|
41
42
|
import { parseWordClass, WORD_CLASS_BASES } from "./word-classes.mjs";
|
|
42
43
|
import { glossSenses, LANGUAGE_SUBTYPE } from "./word-terms.mjs";
|
|
43
44
|
|
|
@@ -159,13 +160,21 @@ function languageWords(notes, index) {
|
|
|
159
160
|
* @returns {import("./glossary-language.mjs").GlossaryLanguageReader}
|
|
160
161
|
*/
|
|
161
162
|
function readerOf(index, note) {
|
|
163
|
+
let notes = languageNotes.get(index);
|
|
164
|
+
if (!notes) {
|
|
165
|
+
notes = languageNotesByCode(index.notes ?? [], (each) => ownKey(each, index));
|
|
166
|
+
languageNotes.set(index, notes);
|
|
167
|
+
}
|
|
162
168
|
return {
|
|
163
169
|
keyOf: (value, type) => addressKeyOf(value, index, type),
|
|
164
|
-
|
|
170
|
+
noteOf: (code) => notes.get(code),
|
|
165
171
|
self: ownKey(note, index),
|
|
166
172
|
};
|
|
167
173
|
}
|
|
168
174
|
|
|
175
|
+
/** Each link index's language notes by code, read once. */
|
|
176
|
+
const languageNotes = new WeakMap();
|
|
177
|
+
|
|
169
178
|
/**
|
|
170
179
|
* The language a glossary entry on a note resolves to.
|
|
171
180
|
*
|
package/engine/diagnostics.mjs
CHANGED
|
@@ -160,7 +160,8 @@ export function positionInBody(body, offset, { bodyLine = 1, bodyColumn = 1, lin
|
|
|
160
160
|
const upTo = String(body ?? "").slice(0, Math.max(0, offset));
|
|
161
161
|
const nl = upTo.lastIndexOf("\n");
|
|
162
162
|
const scannedLine = upTo.length === 0 ? 0 : upTo.split("\n").length - 1;
|
|
163
|
-
|
|
163
|
+
// A first-use mark the expansion wrote is no authored character.
|
|
164
|
+
const column = upTo.slice(nl + 1).replace(/[\uFDD0\uFDD1]/g, "").length + 1;
|
|
164
165
|
|
|
165
166
|
const mapped = lineMap?.[scannedLine];
|
|
166
167
|
const sourceLine = mapped ? mapped.line : scannedLine;
|
|
@@ -201,7 +202,7 @@ export function positionInBody(body, offset, { bodyLine = 1, bodyColumn = 1, lin
|
|
|
201
202
|
* @param {string} raw - The file's full contents, frontmatter included.
|
|
202
203
|
* @param {string} key - The top-level frontmatter key.
|
|
203
204
|
* @param {string} [value] - When given, prefer the occurrence whose line also
|
|
204
|
-
* carries this text. A list-valued key (`
|
|
205
|
+
* carries this text. A list-valued key (`tags`) is reported at the entry
|
|
205
206
|
* that is wrong, not at the key that introduces it.
|
|
206
207
|
* @param {object} [options] - Options.
|
|
207
208
|
* @param {boolean} [options.topLevel=false] - Require the key at column 1, so
|
|
@@ -53,6 +53,7 @@ export { pluralOf, pluralByRule } from "./word-inflections.mjs";
|
|
|
53
53
|
import { createHash } from "node:crypto";
|
|
54
54
|
import { parseAddress, renderAddress } from "./address.mjs";
|
|
55
55
|
import { glossaryLanguage } from "./glossary-language.mjs";
|
|
56
|
+
import { languageNotesByCode } from "./note-language.mjs";
|
|
56
57
|
import { isNoteRecord } from "./index-records.mjs";
|
|
57
58
|
import { parseWordClass, WORD_CLASS_BASES } from "./word-classes.mjs";
|
|
58
59
|
import { citationLookup, isLanguageRecord, lineageChain, pieceGlosses } from "./word-rows.mjs";
|
|
@@ -229,9 +230,10 @@ export function dictionaryAndTermRows(records, all = records) {
|
|
|
229
230
|
const ref = (value, pkg, type) => refKey(value, pkg, type, types);
|
|
230
231
|
/** An Address a row may cite: not a note of this package the audience does not see. */
|
|
231
232
|
const citable = (key) => visible.has(key) || !byKey.has(key);
|
|
233
|
+
const languageNotes = languageNotesByCode(byKey.values(), noteKey);
|
|
232
234
|
const reader = {
|
|
233
235
|
keyOf: (value) => (typeof value === "string" && value ? value : undefined),
|
|
234
|
-
|
|
236
|
+
noteOf: (code) => languageNotes.get(code),
|
|
235
237
|
};
|
|
236
238
|
|
|
237
239
|
// ── Languages ────────────────────────────────────────────────────────────
|
package/engine/first-use.mjs
CHANGED
|
@@ -11,16 +11,60 @@
|
|
|
11
11
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* **A name's first use.** Each name a page marks is keyed, and the first use
|
|
16
|
+
* of each key in the page's prose is bold when the page renders. Bold an author
|
|
17
|
+
* writes is the author's choice.
|
|
18
|
+
*
|
|
19
|
+
* The key of a use is, in order:
|
|
20
|
+
*
|
|
21
|
+
* 1. a name wikilink's address (`[[being-tomr|Tom]]{.name}`);
|
|
22
|
+
* 2. a `.name` span's `ref`: an address (`ref=being-tomr`) or a page-local key
|
|
23
|
+
* (`ref="#jane"`);
|
|
24
|
+
* 3. otherwise the span's label, case folded, with its `lang` (`en` when it
|
|
25
|
+
* states none).
|
|
26
|
+
*
|
|
27
|
+
* Only body prose counts: paragraphs, list items, quotations and footnotes.
|
|
28
|
+
* Headings, tables, captions, the title line of a named block, raw HTML, code,
|
|
29
|
+
* verse and the sections generated from frontmatter neither take the bold nor
|
|
30
|
+
* count as a use, and neither does a name inside a run that states `lang`,
|
|
31
|
+
* `font` or `enc`. A name already inside authored strong text counts as its
|
|
32
|
+
* key's first use and takes no second bold.
|
|
33
|
+
*
|
|
34
|
+
* {@link boldFirstUses} runs once, in the expansion every target shares, and
|
|
35
|
+
* marks each first use with {@link FIRST_USE_OPEN} and {@link FIRST_USE_CLOSE};
|
|
36
|
+
* {@link firstUsePlugin} turns the marks into strong text in every renderer.
|
|
37
|
+
* {@link createFirstUseChecker} is the lint over the same uses.
|
|
38
|
+
*
|
|
39
|
+
* @module
|
|
40
|
+
*/
|
|
41
|
+
|
|
14
42
|
import { unified } from "unified";
|
|
15
43
|
import remarkParse from "remark-parse";
|
|
16
44
|
import remarkFrontmatter from "remark-frontmatter";
|
|
17
45
|
import remarkGfm from "remark-gfm";
|
|
18
46
|
import MarkdownIt from "markdown-it";
|
|
47
|
+
import { readQualifier } from "./address.mjs";
|
|
48
|
+
import { scanBlocks } from "./content-blocks.mjs";
|
|
49
|
+
import { scanFigures } from "./content-figures.mjs";
|
|
50
|
+
import { RUN_KEYS } from "./content-scripts.mjs";
|
|
19
51
|
import { scanSpans, spanMarkdownPlugin } from "./content-spans.mjs";
|
|
20
52
|
import { inlineText, rebaseNameSpans } from "./name-markup.mjs";
|
|
21
|
-
import {
|
|
53
|
+
import { isLocalRef, readNameRef } from "./name-refs.mjs";
|
|
54
|
+
import { createNameCatalog, normalizeName as normalize } from "./names.mjs";
|
|
22
55
|
import { WIKILINK, parseWikilink } from "./wikilink-syntax.mjs";
|
|
23
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Opens a first use in expanded markdown: a Unicode noncharacter, which no
|
|
59
|
+
* authored text, font or encoding uses.
|
|
60
|
+
*/
|
|
61
|
+
export const FIRST_USE_OPEN = "\uFDD0";
|
|
62
|
+
|
|
63
|
+
/** Closes a first use in expanded markdown. */
|
|
64
|
+
export const FIRST_USE_CLOSE = "\uFDD1";
|
|
65
|
+
|
|
66
|
+
const MARKS = /[\uFDD0\uFDD1]/g;
|
|
67
|
+
|
|
24
68
|
const parser = unified().use(remarkParse).use(remarkFrontmatter).use(remarkGfm);
|
|
25
69
|
const labelParser = new MarkdownIt({ html: true }).use(spanMarkdownPlugin);
|
|
26
70
|
const linkRule = labelParser.inline.ruler.getRules("").find((rule) => rule.name === "link");
|
|
@@ -81,7 +125,6 @@ function readLabel(text, env = {}) {
|
|
|
81
125
|
}
|
|
82
126
|
return { text: inlineText(tokens), links };
|
|
83
127
|
}
|
|
84
|
-
const labelText = (text, env) => readLabel(text, env).text;
|
|
85
128
|
|
|
86
129
|
/** The kind a span's `data-type` supplies; an invalid value is refused by the span check. */
|
|
87
130
|
function spanKind(span) {
|
|
@@ -122,11 +165,16 @@ const overlaps = (values, value) => {
|
|
|
122
165
|
return range && range.end > value.start;
|
|
123
166
|
};
|
|
124
167
|
|
|
125
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* Explicit inline occurrences, authored strong text and structural exclusions
|
|
170
|
+
* at original source offsets.
|
|
171
|
+
*/
|
|
126
172
|
function prose(source, capturedNames) {
|
|
127
173
|
const links = [],
|
|
174
|
+
paragraphs = [],
|
|
128
175
|
regions = [],
|
|
129
176
|
excluded = [],
|
|
177
|
+
strong = [],
|
|
130
178
|
html = [];
|
|
131
179
|
const masked = source
|
|
132
180
|
.replace(/!\[\[[^\]\n]+\]\]|\{\{[^}\n]+\}\}/g, (text, start) => {
|
|
@@ -137,7 +185,12 @@ function prose(source, capturedNames) {
|
|
|
137
185
|
let escapes = 0;
|
|
138
186
|
while (source[start - escapes - 1] === "\\") escapes++;
|
|
139
187
|
if (escapes % 2) return text;
|
|
140
|
-
links.push({
|
|
188
|
+
links.push({
|
|
189
|
+
...parseWikilink(inner, text),
|
|
190
|
+
start,
|
|
191
|
+
end: start + text.length,
|
|
192
|
+
wiki: true,
|
|
193
|
+
});
|
|
141
194
|
return "x".repeat(text.length);
|
|
142
195
|
});
|
|
143
196
|
const tree = parser.parse(masked);
|
|
@@ -151,6 +204,7 @@ function prose(source, capturedNames) {
|
|
|
151
204
|
else if (["strong", "emphasis", "delete", "link", "linkReference"].includes(node.type)) {
|
|
152
205
|
if (["link", "linkReference"].includes(node.type) && source[start] === "[")
|
|
153
206
|
links.push({ start, end, labelled: true });
|
|
207
|
+
if (node.type === "strong") strong.push({ start, end });
|
|
154
208
|
for (const child of node.children ?? []) inline(child);
|
|
155
209
|
}
|
|
156
210
|
};
|
|
@@ -162,7 +216,9 @@ function prose(source, capturedNames) {
|
|
|
162
216
|
};
|
|
163
217
|
else if (node.type === "html") html.push(node);
|
|
164
218
|
else if (node.type === "paragraph") {
|
|
165
|
-
|
|
219
|
+
const range = { start: node.position.start.offset, end: node.position.end.offset };
|
|
220
|
+
regions.push(range);
|
|
221
|
+
paragraphs.push(range);
|
|
166
222
|
for (const child of node.children ?? []) inline(child);
|
|
167
223
|
} else if (
|
|
168
224
|
["root", "blockquote", "list", "listItem", "footnoteDefinition"].includes(node.type)
|
|
@@ -214,7 +270,8 @@ function prose(source, capturedNames) {
|
|
|
214
270
|
for (const entry of stack)
|
|
215
271
|
if (entry.excluded) excluded.push({ start: entry.start, end: source.length });
|
|
216
272
|
const allowed = ranges(regions),
|
|
217
|
-
literal = ranges(excluded)
|
|
273
|
+
literal = ranges(excluded),
|
|
274
|
+
prosaic = ranges(paragraphs);
|
|
218
275
|
const eligible = (value) => contains(allowed, value) && !overlaps(literal, value);
|
|
219
276
|
const actualLinks = links.filter(eligible).flatMap((link) => {
|
|
220
277
|
if (link.wiki) return [link];
|
|
@@ -230,12 +287,229 @@ function prose(source, capturedNames) {
|
|
|
230
287
|
const linked = ranges(authoredLinks);
|
|
231
288
|
const marked = (capturedNames ?? scanSpans(source).names)
|
|
232
289
|
.filter((span) => eligible(span) && !contains(linked, span))
|
|
233
|
-
.map((span) => ({
|
|
234
|
-
|
|
290
|
+
.map((span) => ({
|
|
291
|
+
...span,
|
|
292
|
+
label: span.visible,
|
|
293
|
+
nameKind: spanKind(span),
|
|
294
|
+
ref: readNameRef(span),
|
|
295
|
+
}));
|
|
296
|
+
return {
|
|
297
|
+
marked,
|
|
298
|
+
links: authoredLinks,
|
|
299
|
+
strong,
|
|
300
|
+
inParagraph: (value) => contains(prosaic, value),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Whether a wikilink marks a name: it carries the `.name` class. */
|
|
305
|
+
const isNameLink = (link) => Boolean(link.wiki && link.attributes?.classes?.includes("name"));
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The addresses a page's names are read against: the tree's types and
|
|
309
|
+
* packages, and the package an address that names none belongs to.
|
|
310
|
+
*
|
|
311
|
+
* @typedef {object} NameVocabulary
|
|
312
|
+
* @property {Set<string>} [types] - Every type an address may name.
|
|
313
|
+
* @property {Set<string>} [packages] - Every package an address may name.
|
|
314
|
+
* @property {Set<string>} [noIndexPackages] - Packages with no content index.
|
|
315
|
+
* @property {string} [contentPackage] - The package the page belongs to.
|
|
316
|
+
*/
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The key of a note address: its package, type and shortcode, so every
|
|
320
|
+
* spelling of one note, and each of its systems' documents, is one key. An
|
|
321
|
+
* address the vocabulary cannot read is keyed as written.
|
|
322
|
+
*/
|
|
323
|
+
function addressKey(address, { types, packages, noIndexPackages, contentPackage } = {}) {
|
|
324
|
+
const read =
|
|
325
|
+
types ? readQualifier(address, types, packages ?? new Set(), noIndexPackages) : null;
|
|
326
|
+
if (!read || read.reason) return `note:${address}`;
|
|
327
|
+
return `note:${read.package ?? contentPackage ?? ""}-${read.type}-${read.shortcode}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The key a span's use is counted under. */
|
|
331
|
+
function spanKey(span, vocabulary) {
|
|
332
|
+
if (span.ref)
|
|
333
|
+
return isLocalRef(span.ref) ? `local:${span.ref}` : addressKey(span.ref, vocabulary);
|
|
334
|
+
const lang = span.attrs?.find(([key]) => key === "lang")?.[1] || "en";
|
|
335
|
+
return `label:${lang}:${normalize(span.label)}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The key a name wikilink's use is counted under: its address. */
|
|
339
|
+
const linkKey = (link, vocabulary) =>
|
|
340
|
+
link.target ? addressKey(link.target, vocabulary) : `anchor:#${link.anchor}`;
|
|
341
|
+
|
|
342
|
+
/** The 0-based line of each offset, by a sorted list of line starts. */
|
|
343
|
+
function lineOf(starts, offset) {
|
|
344
|
+
let lo = 0,
|
|
345
|
+
hi = starts.length;
|
|
346
|
+
while (lo + 1 < hi) {
|
|
347
|
+
const mid = (lo + hi) >>> 1;
|
|
348
|
+
if (starts[mid] <= offset) lo = mid;
|
|
349
|
+
else hi = mid;
|
|
350
|
+
}
|
|
351
|
+
return lo;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* A use of a name in a page's prose.
|
|
356
|
+
*
|
|
357
|
+
* @typedef {object} NameUse
|
|
358
|
+
* @property {number} start - Inclusive UTF-16 offset of the use.
|
|
359
|
+
* @property {number} end - Exclusive UTF-16 offset after it, attributes included.
|
|
360
|
+
* @property {string} key - What the use is counted under.
|
|
361
|
+
* @property {boolean} emphasised - It sits inside authored strong text.
|
|
362
|
+
*/
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Every use of a name in a body's prose, in source order.
|
|
366
|
+
*
|
|
367
|
+
* @param {string} markdown - A body, as authored or expanded.
|
|
368
|
+
* @param {NameVocabulary} [vocabulary] - What its addresses are read against.
|
|
369
|
+
* @returns {NameUse[]} The uses that count toward a first use.
|
|
370
|
+
*/
|
|
371
|
+
export function nameUses(markdown, vocabulary) {
|
|
372
|
+
const source = String(markdown ?? "");
|
|
373
|
+
// Every use carries the class, so a body without it is read no further.
|
|
374
|
+
if (!source.includes(".name")) return [];
|
|
375
|
+
const spans = scanSpans(source);
|
|
376
|
+
const { marked, links, strong, inParagraph } = prose(source, spans.names);
|
|
377
|
+
const starts = [0];
|
|
378
|
+
for (let i = 0; i < source.length; i++) if (source[i] === "\n") starts.push(i + 1);
|
|
379
|
+
// A caption and a named block's opening and closing lines are not prose.
|
|
380
|
+
const skipped = new Set();
|
|
381
|
+
for (const figure of scanFigures(source).figures)
|
|
382
|
+
for (let line = figure.captionStart; line < figure.captionEnd; line++) skipped.add(line);
|
|
383
|
+
const blocks = scanBlocks(source).blocks;
|
|
384
|
+
for (const one of blocks) skipped.add(one.start).add(one.end);
|
|
385
|
+
// A name inside a run that states a language, font or encoding is that
|
|
386
|
+
// run's text, never the page's prose.
|
|
387
|
+
const hasRunKey = (keys) => RUN_KEYS.some((key) => Object.hasOwn(keys, key));
|
|
388
|
+
const runLines = blocks
|
|
389
|
+
.filter((one) => hasRunKey(one.attributes ?? {}))
|
|
390
|
+
.map((one) => ({ start: one.start, end: one.end }));
|
|
391
|
+
const runSpans = spans.spans.filter((span) => hasRunKey(Object.fromEntries(span.attrs)));
|
|
392
|
+
const inRun = (use) =>
|
|
393
|
+
runSpans.some(
|
|
394
|
+
(span) =>
|
|
395
|
+
span.start <= use.start &&
|
|
396
|
+
span.end >= use.end &&
|
|
397
|
+
(span.start !== use.start || span.end !== use.end),
|
|
398
|
+
) ||
|
|
399
|
+
runLines.some(
|
|
400
|
+
(range) =>
|
|
401
|
+
lineOf(starts, use.start) > range.start && lineOf(starts, use.start) < range.end,
|
|
402
|
+
);
|
|
403
|
+
const emphasised = (use) =>
|
|
404
|
+
strong.some((range) => range.start <= use.start && range.end >= use.end);
|
|
405
|
+
return [
|
|
406
|
+
...links.filter(isNameLink).map((link) => ({ ...link, key: linkKey(link, vocabulary) })),
|
|
407
|
+
...marked.map((span) => ({ ...span, key: spanKey(span, vocabulary) })),
|
|
408
|
+
]
|
|
409
|
+
.filter((use) => inParagraph(use) && !skipped.has(lineOf(starts, use.start)) && !inRun(use))
|
|
410
|
+
.sort((a, b) => a.start - b.start)
|
|
411
|
+
.map((use) => ({
|
|
412
|
+
start: use.start,
|
|
413
|
+
end: use.end,
|
|
414
|
+
key: use.key,
|
|
415
|
+
emphasised: emphasised(use),
|
|
416
|
+
}));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Mark the first use of each name in a body's prose as bold.
|
|
421
|
+
*
|
|
422
|
+
* Each first use is wrapped in {@link FIRST_USE_OPEN} and
|
|
423
|
+
* {@link FIRST_USE_CLOSE}, which {@link firstUsePlugin} renders as strong
|
|
424
|
+
* text. A first use already inside authored strong text is left as it is.
|
|
425
|
+
*
|
|
426
|
+
* @param {string} markdown - A body's prose, with its generated sections split
|
|
427
|
+
* off and its wikilinks not yet resolved.
|
|
428
|
+
* @param {NameVocabulary} [vocabulary] - What its addresses are read against.
|
|
429
|
+
* @returns {string} The same markdown with each first use marked.
|
|
430
|
+
*/
|
|
431
|
+
export function boldFirstUses(markdown, vocabulary) {
|
|
432
|
+
const source = String(markdown ?? "");
|
|
433
|
+
const seen = new Set(),
|
|
434
|
+
firsts = [];
|
|
435
|
+
for (const use of nameUses(source, vocabulary)) {
|
|
436
|
+
if (seen.has(use.key)) continue;
|
|
437
|
+
seen.add(use.key);
|
|
438
|
+
if (!use.emphasised) firsts.push(use);
|
|
439
|
+
}
|
|
440
|
+
let out = source;
|
|
441
|
+
for (const use of firsts.reverse())
|
|
442
|
+
out =
|
|
443
|
+
out.slice(0, use.start) +
|
|
444
|
+
FIRST_USE_OPEN +
|
|
445
|
+
out.slice(use.start, use.end) +
|
|
446
|
+
FIRST_USE_CLOSE +
|
|
447
|
+
out.slice(use.end);
|
|
448
|
+
return out;
|
|
235
449
|
}
|
|
236
450
|
|
|
237
451
|
/**
|
|
238
|
-
*
|
|
452
|
+
* Render the first-use marks {@link boldFirstUses} wrote as strong text.
|
|
453
|
+
*
|
|
454
|
+
* A mark in inline text opens or closes a `strong`; a mark anywhere else, as in
|
|
455
|
+
* raw HTML or code, is dropped, so no mark reaches a reader.
|
|
456
|
+
*
|
|
457
|
+
* @param {import("markdown-it")} md - Markdown parser to extend.
|
|
458
|
+
*/
|
|
459
|
+
export function firstUsePlugin(md) {
|
|
460
|
+
md.core.ruler.push("first_use", (state) => {
|
|
461
|
+
const Token = state.Token;
|
|
462
|
+
const strip = (token) => {
|
|
463
|
+
if (token.content?.includes(FIRST_USE_OPEN) || token.content?.includes(FIRST_USE_CLOSE))
|
|
464
|
+
token.content = token.content.replace(MARKS, "");
|
|
465
|
+
};
|
|
466
|
+
for (const token of state.tokens) {
|
|
467
|
+
if (token.type !== "inline") {
|
|
468
|
+
strip(token);
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (!token.children) continue;
|
|
472
|
+
const children = [];
|
|
473
|
+
for (const child of token.children) {
|
|
474
|
+
if (child.type !== "text" || !MARKS.test(child.content)) {
|
|
475
|
+
MARKS.lastIndex = 0;
|
|
476
|
+
strip(child);
|
|
477
|
+
children.push(child);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
MARKS.lastIndex = 0;
|
|
481
|
+
for (const part of child.content.split(/([])/)) {
|
|
482
|
+
if (part === FIRST_USE_OPEN || part === FIRST_USE_CLOSE) {
|
|
483
|
+
const open = part === FIRST_USE_OPEN;
|
|
484
|
+
const mark = new Token(
|
|
485
|
+
open ? "strong_open" : "strong_close",
|
|
486
|
+
"strong",
|
|
487
|
+
open ? 1 : -1,
|
|
488
|
+
);
|
|
489
|
+
mark.level = child.level;
|
|
490
|
+
children.push(mark);
|
|
491
|
+
} else if (part) {
|
|
492
|
+
const text = new Token("text", "", 0);
|
|
493
|
+
text.content = part;
|
|
494
|
+
text.level = child.level;
|
|
495
|
+
children.push(text);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
token.children = children;
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Prepare indexed names once, then check each note's marked names without
|
|
506
|
+
* reading files.
|
|
507
|
+
*
|
|
508
|
+
* One rule, a warning at the occurrence:
|
|
509
|
+
*
|
|
510
|
+
* - `unlinked-name`: a `.name` span with no `ref` names an indexed note, which
|
|
511
|
+
* a link or a `ref` ties to its note.
|
|
512
|
+
*
|
|
239
513
|
* @param {{records: readonly object[], index: object,
|
|
240
514
|
* catalog?: import("./names.mjs").NameCatalog,
|
|
241
515
|
* parsed?: Map<string, {raw: string, body: string, bodyLine?: number, bodyColumn?: number}>,
|
|
@@ -250,7 +524,7 @@ export function createFirstUseChecker({
|
|
|
250
524
|
syntax,
|
|
251
525
|
catalog = createNameCatalog({ records, index }),
|
|
252
526
|
}) {
|
|
253
|
-
const { recognize
|
|
527
|
+
const { recognize } = catalog;
|
|
254
528
|
return ({ file, raw }) => {
|
|
255
529
|
const authored = parsed?.get(file);
|
|
256
530
|
const capturedNames =
|
|
@@ -260,41 +534,7 @@ export function createFirstUseChecker({
|
|
|
260
534
|
{ raw, bodyLine: authored.bodyLine, bodyColumn: authored.bodyColumn },
|
|
261
535
|
)
|
|
262
536
|
: undefined;
|
|
263
|
-
const { marked
|
|
264
|
-
const events = [
|
|
265
|
-
...marked.map((span) => ({ ...span, kind: "marked" })),
|
|
266
|
-
...links.map((link) => {
|
|
267
|
-
const target = link.wiki ? resolve(link.target) : undefined;
|
|
268
|
-
return {
|
|
269
|
-
...link,
|
|
270
|
-
kind: "link",
|
|
271
|
-
preferred: target,
|
|
272
|
-
label:
|
|
273
|
-
link.labelled ?
|
|
274
|
-
link.wiki ?
|
|
275
|
-
labelText(link.display || target?.full || "")
|
|
276
|
-
: link.display
|
|
277
|
-
: undefined,
|
|
278
|
-
};
|
|
279
|
-
}),
|
|
280
|
-
].filter((event) => typeof event.label === "string" && normalize(event.label));
|
|
281
|
-
const unknown = new Map();
|
|
282
|
-
for (const event of [...events].sort(
|
|
283
|
-
(a, b) => a.label.length - b.label.length || a.label.localeCompare(b.label),
|
|
284
|
-
)) {
|
|
285
|
-
if (
|
|
286
|
-
recognized(event.label, event.preferred, event.nameKind) ||
|
|
287
|
-
unknown.has(normalize(event.label))
|
|
288
|
-
)
|
|
289
|
-
continue;
|
|
290
|
-
const entry = {
|
|
291
|
-
key: "name:" + normalize(event.label),
|
|
292
|
-
full: event.label,
|
|
293
|
-
fullUse: true,
|
|
294
|
-
};
|
|
295
|
-
for (const spelling of forms(event.label))
|
|
296
|
-
if (!unknown.has(spelling)) unknown.set(spelling, entry);
|
|
297
|
-
}
|
|
537
|
+
const { marked } = prose(raw, capturedNames);
|
|
298
538
|
const findings = new Map();
|
|
299
539
|
const add = (offset, rule, message) => {
|
|
300
540
|
if (!findings.has(offset)) findings.set(offset, { rules: [], messages: [] });
|
|
@@ -304,56 +544,30 @@ export function createFirstUseChecker({
|
|
|
304
544
|
finding.messages.push(message);
|
|
305
545
|
}
|
|
306
546
|
};
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
"unlinked-name",
|
|
321
|
-
`“${event.label}” names “${entry.full}”; use a link to “${destinations}”`,
|
|
322
|
-
);
|
|
323
|
-
}
|
|
324
|
-
if (used.has(entry.key))
|
|
325
|
-
add(
|
|
326
|
-
event.start,
|
|
327
|
-
"repeated-name",
|
|
328
|
-
`“${entry.full}” is linked or marked more than once`,
|
|
329
|
-
);
|
|
330
|
-
// A resolved wikilink identifies its note, so its label may be any natural form.
|
|
331
|
-
const introduces = entry.fullUse || event.preferred !== undefined;
|
|
332
|
-
if (!introduces && !fullUsed.has(entry.key))
|
|
333
|
-
add(
|
|
334
|
-
event.start,
|
|
335
|
-
"short-before-full",
|
|
336
|
-
`“${event.label}” appears before the full name “${entry.full}”`,
|
|
337
|
-
);
|
|
338
|
-
used.add(entry.key);
|
|
339
|
-
if (introduces) fullUsed.add(entry.key);
|
|
547
|
+
for (const span of marked) {
|
|
548
|
+
if (span.ref || !normalize(span.label)) continue;
|
|
549
|
+
const entry = recognize(span.label, undefined, span.nameKind);
|
|
550
|
+
if (!entry?.indexed) continue;
|
|
551
|
+
const destinations = (entry.candidates ?? [entry])
|
|
552
|
+
.map((target) => target.key)
|
|
553
|
+
.sort()
|
|
554
|
+
.join(" or ");
|
|
555
|
+
add(
|
|
556
|
+
span.start,
|
|
557
|
+
"unlinked-name",
|
|
558
|
+
`“${span.label}” names “${entry.full}”; use a link to “${destinations}”, or tie it with ref=`,
|
|
559
|
+
);
|
|
340
560
|
}
|
|
341
561
|
const lineStarts = [0];
|
|
342
562
|
for (let i = 0; i < raw.length; i++) if (raw[i] === "\n") lineStarts.push(i + 1);
|
|
343
563
|
return [...findings]
|
|
344
564
|
.sort(([a], [b]) => a - b)
|
|
345
565
|
.map(([offset, finding]) => {
|
|
346
|
-
|
|
347
|
-
hi = lineStarts.length;
|
|
348
|
-
while (lo + 1 < hi) {
|
|
349
|
-
const mid = (lo + hi) >>> 1;
|
|
350
|
-
if (lineStarts[mid] <= offset) lo = mid;
|
|
351
|
-
else hi = mid;
|
|
352
|
-
}
|
|
566
|
+
const line = lineOf(lineStarts, offset);
|
|
353
567
|
return {
|
|
354
568
|
file,
|
|
355
|
-
line:
|
|
356
|
-
column: offset - lineStarts[
|
|
569
|
+
line: line + 1,
|
|
570
|
+
column: offset - lineStarts[line] + 1,
|
|
357
571
|
severity: "warning",
|
|
358
572
|
rules: finding.rules,
|
|
359
573
|
message: `first-use/${finding.rules.join("+")}: ${finding.messages.join("; ")}`,
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
import { isAddressTuple } from "./address.mjs";
|
|
56
56
|
import { listed, nearest } from "./near-miss.mjs";
|
|
57
57
|
import { checkDataKeys } from "./data-keys.mjs";
|
|
58
|
+
import { FALLBACK_LANGUAGE, normalizeName } from "./name-forms.mjs";
|
|
58
59
|
import {
|
|
59
60
|
authoredNoteKeys,
|
|
60
61
|
CHARACTER_NAME_KEYS,
|
|
@@ -1165,6 +1166,9 @@ function checkEmbeddedShortcodes(note, blockName) {
|
|
|
1165
1166
|
* configuration through {@link systemBlocksFor}, and this module states no
|
|
1166
1167
|
* system name of its own. See {@link DEFAULT_SYSTEM_BLOCKS} for the fallback.
|
|
1167
1168
|
* @param {object} [opts.addressContext] - Builder package and Address type vocabulary.
|
|
1169
|
+
* @param {Iterable<string>} [opts.languages] - The language codes the
|
|
1170
|
+
* configuration's `languages:` declares. A `name` key that is one of them, or
|
|
1171
|
+
* `en`, is a list of forms; absent, only `en` is.
|
|
1168
1172
|
* @param {readonly string[]} [opts.packs] - The pack names this package
|
|
1169
1173
|
* declares, for a `data:` field whose map is keyed by pack. Supplied by the
|
|
1170
1174
|
* caller like `schemas` and `vocabulary`, and absent it no claim is made
|
|
@@ -1181,6 +1185,7 @@ export function lintNote(
|
|
|
1181
1185
|
packs,
|
|
1182
1186
|
emittedArt,
|
|
1183
1187
|
addressContext,
|
|
1188
|
+
languages,
|
|
1184
1189
|
systems = DEFAULT_SYSTEM_BLOCKS,
|
|
1185
1190
|
},
|
|
1186
1191
|
) {
|
|
@@ -1254,15 +1259,17 @@ export function lintNote(
|
|
|
1254
1259
|
if (type === "being" && fm.subType === "character") {
|
|
1255
1260
|
for (const key of CHARACTER_NAME_KEYS) allowed.add(key);
|
|
1256
1261
|
}
|
|
1262
|
+
const codes = new Set([FALLBACK_LANGUAGE, ...(languages ?? [])]);
|
|
1257
1263
|
for (const key of Object.keys(name)) {
|
|
1258
|
-
if (
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1264
|
+
if (allowed.has(key) || codes.has(key)) continue;
|
|
1265
|
+
findings.push({
|
|
1266
|
+
file: note.file,
|
|
1267
|
+
...nameAt([key]),
|
|
1268
|
+
severity: "error",
|
|
1269
|
+
message:
|
|
1270
|
+
`\`name.${key}\` is not valid for this note: a name lists its forms ` +
|
|
1271
|
+
`under a language code \`languages:\` declares, or \`${FALLBACK_LANGUAGE}\``,
|
|
1272
|
+
});
|
|
1266
1273
|
}
|
|
1267
1274
|
if (typeof name.full !== "string" || !name.full.trim()) {
|
|
1268
1275
|
findings.push({
|
|
@@ -1272,18 +1279,34 @@ export function lintNote(
|
|
|
1272
1279
|
message: "`name.full` must be a nonempty string",
|
|
1273
1280
|
});
|
|
1274
1281
|
}
|
|
1275
|
-
|
|
1282
|
+
for (const code of Object.keys(name)) {
|
|
1283
|
+
if (!codes.has(code) || allowed.has(code)) continue;
|
|
1284
|
+
const forms = name[code];
|
|
1276
1285
|
if (
|
|
1277
|
-
!Array.isArray(
|
|
1278
|
-
|
|
1286
|
+
!Array.isArray(forms) ||
|
|
1287
|
+
forms.some((form) => typeof form !== "string" || !form.trim())
|
|
1279
1288
|
) {
|
|
1280
1289
|
findings.push({
|
|
1281
1290
|
file: note.file,
|
|
1282
|
-
...nameAt([
|
|
1291
|
+
...nameAt([code]),
|
|
1283
1292
|
severity: "error",
|
|
1284
|
-
message:
|
|
1293
|
+
message: `\`name.${code}\` must be a list of nonempty strings`,
|
|
1285
1294
|
});
|
|
1295
|
+
continue;
|
|
1286
1296
|
}
|
|
1297
|
+
if (code !== FALLBACK_LANGUAGE || typeof name.full !== "string") continue;
|
|
1298
|
+
const full = normalizeName(name.full);
|
|
1299
|
+
forms.forEach((form, i) => {
|
|
1300
|
+
if (normalizeName(form) !== full) return;
|
|
1301
|
+
findings.push({
|
|
1302
|
+
file: note.file,
|
|
1303
|
+
...positionOfFrontmatterPath(raw(), ["name", code, i]),
|
|
1304
|
+
severity: "error",
|
|
1305
|
+
message:
|
|
1306
|
+
`\`name.${code}\` repeats \`name.full\` (${form}); \`full\` is ` +
|
|
1307
|
+
"already an English form, so leave it out",
|
|
1308
|
+
});
|
|
1309
|
+
});
|
|
1287
1310
|
}
|
|
1288
1311
|
if (
|
|
1289
1312
|
Object.hasOwn(name, "sort") &&
|
|
@@ -1738,6 +1761,8 @@ export function lintNote(
|
|
|
1738
1761
|
* @param {Readonly<Record<string, SystemBlockSpec>>} [opts.systems]
|
|
1739
1762
|
* The system blocks to check; see {@link lintNote} and
|
|
1740
1763
|
* {@link systemBlocksFor}.
|
|
1764
|
+
* @param {Iterable<string>} [opts.languages] - The configured language codes,
|
|
1765
|
+
* which a note's `name` lists its forms under; see {@link lintNote}.
|
|
1741
1766
|
* @param {readonly string[]} [opts.packs] - The declared pack names; see
|
|
1742
1767
|
* {@link lintNote}.
|
|
1743
1768
|
* @param {(type: string) => {document: string|null, art: readonly string[]}|null} [opts.emittedArt]
|
|
@@ -1747,7 +1772,7 @@ export function lintNote(
|
|
|
1747
1772
|
*/
|
|
1748
1773
|
export function lintFrontmatter(
|
|
1749
1774
|
index,
|
|
1750
|
-
{ schemas, vocabulary, packs, emittedArt, references = true, systems, currentDate },
|
|
1775
|
+
{ schemas, vocabulary, packs, emittedArt, references = true, systems, currentDate, languages },
|
|
1751
1776
|
) {
|
|
1752
1777
|
const findings = [];
|
|
1753
1778
|
const notes = [...index.notes].sort((a, b) =>
|
|
@@ -1763,6 +1788,7 @@ export function lintFrontmatter(
|
|
|
1763
1788
|
currentDate,
|
|
1764
1789
|
packs,
|
|
1765
1790
|
emittedArt,
|
|
1791
|
+
languages,
|
|
1766
1792
|
index: references ? index : undefined,
|
|
1767
1793
|
addressContext: {
|
|
1768
1794
|
package: index.contentPackage,
|