@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.
Files changed (83) hide show
  1. package/CHANGELOG.md +29 -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 +7 -5
  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/engine/changelog-group.mjs +84 -173
  21. package/engine/changelog-labels.mjs +73 -0
  22. package/engine/changelog-lint.mjs +168 -44
  23. package/engine/content-expand.mjs +58 -38
  24. package/engine/content-format.yaml +0 -2
  25. package/engine/content-index.mjs +49 -23
  26. package/engine/content-links.mjs +4 -0
  27. package/engine/content-lint.mjs +2 -3
  28. package/engine/content-render.mjs +6 -2
  29. package/engine/culture-parents.mjs +1 -1
  30. package/engine/culture-reach.mjs +10 -1
  31. package/engine/diagnostics.mjs +3 -2
  32. package/engine/dictionary-rows.mjs +3 -1
  33. package/engine/first-use.mjs +301 -87
  34. package/engine/frontmatter-lint.mjs +40 -14
  35. package/engine/glossary-entries.mjs +5 -5
  36. package/engine/glossary-language.mjs +21 -44
  37. package/engine/homepage.mjs +0 -4
  38. package/engine/index-records.mjs +3 -1
  39. package/engine/infobox.mjs +6 -3
  40. package/engine/language-names.mjs +3 -2
  41. package/engine/language-rows.mjs +6 -7
  42. package/engine/lexical-sections.mjs +3 -1
  43. package/engine/literature-notes.mjs +4 -76
  44. package/engine/metadata-index.mjs +15 -8
  45. package/engine/name-forms.mjs +99 -0
  46. package/engine/name-refs.mjs +143 -0
  47. package/engine/name-rows.mjs +11 -1
  48. package/engine/names.mjs +27 -22
  49. package/engine/note-addresses.mjs +0 -3
  50. package/engine/note-frontmatter.mjs +3 -5
  51. package/engine/note-language.mjs +348 -0
  52. package/engine/note-vocabulary.mjs +13 -11
  53. package/engine/pdf-index.mjs +3 -2
  54. package/engine/pdf-render.mjs +2 -4
  55. package/engine/settlement-tables.mjs +4 -3
  56. package/engine/site-build.mjs +2 -7
  57. package/engine/site-search.mjs +3 -5
  58. package/engine/site-theme.mjs +1 -1
  59. package/engine/sql-tables.mjs +29 -14
  60. package/engine/wikilink-syntax.mjs +2 -0
  61. package/engine/word-citations.mjs +3 -3
  62. package/engine/word-rows.mjs +3 -1
  63. package/engine/word-terms.mjs +2 -2
  64. package/package.json +1 -1
  65. package/site-theme/static/css/style.css +0 -4
  66. package/types/engine/changelog-group.d.mts +6 -10
  67. package/types/engine/changelog-labels.d.mts +10 -0
  68. package/types/engine/changelog-lint.d.mts +11 -6
  69. package/types/engine/content-index.d.mts +16 -16
  70. package/types/engine/diagnostics.d.mts +1 -1
  71. package/types/engine/first-use.d.mts +97 -1
  72. package/types/engine/frontmatter-lint.d.mts +9 -2
  73. package/types/engine/glossary-language.d.mts +13 -6
  74. package/types/engine/homepage.d.mts +0 -3
  75. package/types/engine/literature-notes.d.mts +2 -27
  76. package/types/engine/name-forms.d.mts +61 -0
  77. package/types/engine/name-refs.d.mts +51 -0
  78. package/types/engine/name-rows.d.mts +4 -0
  79. package/types/engine/names.d.mts +12 -92
  80. package/types/engine/note-frontmatter.d.mts +1 -4
  81. package/types/engine/note-language.d.mts +47 -0
  82. package/types/engine/settlement-tables.d.mts +1 -1
  83. package/types/engine/site-build.d.mts +2 -6
@@ -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
- lookup: (key) => index.addressHit?.(key),
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
  *
@@ -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
- const column = upTo.length - nl; // 1-based: offset - (nl + 1) + 1
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 (`aliases`) is reported at the entry
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
- lookup: (key) => byKey.get(key),
236
+ noteOf: (code) => languageNotes.get(code),
235
237
  };
236
238
 
237
239
  // ── Languages ────────────────────────────────────────────────────────────
@@ -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 { createNameCatalog, nameForms as forms, normalizeName as normalize } from "./names.mjs";
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
- /** Explicit inline occurrences and structural exclusions at original source offsets. */
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({ ...parseWikilink(inner), start, end: start + text.length, wiki: true });
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
- regions.push({ start: node.position.start.offset, end: node.position.end.offset });
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) => ({ ...span, label: span.visible, nameKind: spanKind(span) }));
234
- return { marked, links: authoredLinks };
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
- * Prepare indexed names once, then check only explicit occurrences without reading files.
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: recognized, resolve } = catalog;
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, links } = prose(raw, capturedNames);
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 used = new Set(),
308
- fullUsed = new Set();
309
- for (const event of events.sort((a, b) => a.start - b.start)) {
310
- const entry =
311
- recognized(event.label, event.preferred, event.nameKind) ??
312
- unknown.get(normalize(event.label));
313
- if (event.kind === "marked" && entry.indexed) {
314
- const destinations = (entry.candidates ?? [entry])
315
- .map((target) => target.key)
316
- .sort()
317
- .join(" or ");
318
- add(
319
- event.start,
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
- let lo = 0,
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: lo + 1,
356
- column: offset - lineStarts[lo] + 1,
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 (!allowed.has(key)) {
1259
- findings.push({
1260
- file: note.file,
1261
- ...nameAt([key]),
1262
- severity: "error",
1263
- message: `\`name.${key}\` is not valid for this note`,
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
- if (Object.hasOwn(name, "aliases")) {
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(name.aliases) ||
1278
- name.aliases.some((alias) => typeof alias !== "string" || !alias.trim())
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(["aliases"]),
1291
+ ...nameAt([code]),
1283
1292
  severity: "error",
1284
- message: "`name.aliases` must be a list of nonempty strings",
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,