@heroiclands/package-build 25.1.2 → 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 (86) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/assets/styles/content-base.css +0 -9
  3. package/bin/content-commands.mjs +22 -16
  4. package/bin/package-build.mjs +24 -25
  5. package/content-config.mjs +7 -34
  6. package/docs/api.md +38 -21
  7. package/docs/authoring/first-note.md +3 -3
  8. package/docs/authoring/frontmatter.md +30 -2
  9. package/docs/authoring/links-and-markup.md +92 -31
  10. package/docs/commands.md +103 -59
  11. package/docs/configuration.md +10 -45
  12. package/docs/content-format.md +7 -5
  13. package/docs/diagnostics.md +75 -0
  14. package/docs/encoding-core.md +11 -0
  15. package/docs/guides/book.md +3 -3
  16. package/docs/guides/module-project.md +1 -1
  17. package/docs/guides/reference-pages.md +7 -3
  18. package/docs/project-setup.md +3 -0
  19. package/docs/reference/format-details.md +329 -187
  20. package/docs/reference/note-types.md +274 -274
  21. package/engine/changelog-group.mjs +84 -173
  22. package/engine/changelog-labels.mjs +73 -0
  23. package/engine/changelog-lint.mjs +168 -44
  24. package/engine/content-expand.mjs +58 -38
  25. package/engine/content-format.yaml +0 -2
  26. package/engine/content-index.mjs +49 -23
  27. package/engine/content-links.mjs +4 -0
  28. package/engine/content-lint.mjs +2 -3
  29. package/engine/content-render.mjs +6 -2
  30. package/engine/culture-parents.mjs +1 -1
  31. package/engine/culture-reach.mjs +10 -1
  32. package/engine/diagnostics.mjs +3 -2
  33. package/engine/dictionary-rows.mjs +3 -1
  34. package/engine/encoding-lexicon.mjs +12 -2
  35. package/engine/first-use.mjs +301 -87
  36. package/engine/frontmatter-lint.mjs +40 -14
  37. package/engine/glossary-entries.mjs +5 -5
  38. package/engine/glossary-language.mjs +21 -44
  39. package/engine/homepage.mjs +0 -4
  40. package/engine/index-records.mjs +3 -1
  41. package/engine/infobox.mjs +6 -3
  42. package/engine/language-names.mjs +70 -10
  43. package/engine/language-rows.mjs +6 -7
  44. package/engine/lexical-sections.mjs +3 -1
  45. package/engine/literature-notes.mjs +4 -76
  46. package/engine/metadata-index.mjs +15 -8
  47. package/engine/name-forms.mjs +99 -0
  48. package/engine/name-refs.mjs +143 -0
  49. package/engine/name-rows.mjs +11 -1
  50. package/engine/names.mjs +27 -22
  51. package/engine/note-addresses.mjs +0 -3
  52. package/engine/note-frontmatter.mjs +3 -5
  53. package/engine/note-language.mjs +348 -0
  54. package/engine/note-vocabulary.mjs +35 -13
  55. package/engine/pdf-index.mjs +3 -2
  56. package/engine/pdf-render.mjs +2 -4
  57. package/engine/settlement-tables.mjs +4 -3
  58. package/engine/site-build.mjs +2 -7
  59. package/engine/site-search.mjs +3 -5
  60. package/engine/site-theme.mjs +1 -1
  61. package/engine/sql-tables.mjs +29 -14
  62. package/engine/wikilink-syntax.mjs +2 -0
  63. package/engine/word-citations.mjs +3 -3
  64. package/engine/word-rows.mjs +3 -1
  65. package/engine/word-terms.mjs +2 -2
  66. package/package.json +1 -1
  67. package/site-theme/static/css/style.css +0 -4
  68. package/types/engine/changelog-group.d.mts +6 -10
  69. package/types/engine/changelog-labels.d.mts +10 -0
  70. package/types/engine/changelog-lint.d.mts +11 -6
  71. package/types/engine/content-index.d.mts +16 -16
  72. package/types/engine/diagnostics.d.mts +1 -1
  73. package/types/engine/first-use.d.mts +97 -1
  74. package/types/engine/frontmatter-lint.d.mts +9 -2
  75. package/types/engine/glossary-language.d.mts +13 -6
  76. package/types/engine/homepage.d.mts +0 -3
  77. package/types/engine/language-names.d.mts +3 -0
  78. package/types/engine/literature-notes.d.mts +2 -27
  79. package/types/engine/name-forms.d.mts +61 -0
  80. package/types/engine/name-refs.d.mts +51 -0
  81. package/types/engine/name-rows.d.mts +4 -0
  82. package/types/engine/names.d.mts +12 -92
  83. package/types/engine/note-frontmatter.d.mts +1 -4
  84. package/types/engine/note-language.d.mts +47 -0
  85. package/types/engine/settlement-tables.d.mts +1 -1
  86. 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 ────────────────────────────────────────────────────────────
@@ -22,6 +22,7 @@ import { isLanguageRecord, lineageChain, noteAddressOf } from "./word-rows.mjs";
22
22
  import { derivedForms, foldWord } from "./word-forms.mjs";
23
23
  import { WRITTEN_ENDINGS, RESERVED_TONGUE } from "./language-terms.mjs";
24
24
  import { LANGUAGE_SUBTYPE, glossText } from "./word-terms.mjs";
25
+ import { ENGLISH_GROUP } from "./language-names.mjs";
25
26
  import { writingsOf } from "./language-writings.mjs";
26
27
  import { encodeText, validateSource, EncodingError } from "./encoding.mjs";
27
28
  import { encodeWithLanguage } from "./encoding-language.mjs";
@@ -642,7 +643,10 @@ export function checkEncodingLexicon(note, ctx) {
642
643
  ...new Map(
643
644
  [...(ctx.encodings?.values() ?? [])].map((encoding) => [encoding.name, encoding]),
644
645
  ).values(),
645
- ].filter((encoding) => aliases.has(encoding.language));
646
+ ]
647
+ .filter((encoding) => aliases.has(encoding.language))
648
+ // A codes-only script accepts no letter, so the roman forms are not written in it.
649
+ .filter((encoding) => [...encoding.accepted].some((char) => /\p{L}/u.test(char)));
646
650
  if (!encodings.length) return [];
647
651
  const forms = [],
648
652
  data = note.fm.data ?? {};
@@ -653,6 +657,7 @@ export function checkEncodingLexicon(note, ctx) {
653
657
  add(word?.form, ["words", i, "form"]);
654
658
  listOf(word?.variants).forEach((form, j) => add(form, ["words", i, "variants", j]));
655
659
  });
660
+ // `en` holds English names, which no encoding of the tongue sees.
656
661
  const names = (value, keys) => {
657
662
  if (Array.isArray(value))
658
663
  value.forEach((item, i) =>
@@ -660,7 +665,12 @@ export function checkEncodingLexicon(note, ctx) {
660
665
  );
661
666
  else if (isMap(value))
662
667
  for (const [key, item] of Object.entries(value))
663
- if (key !== "pattern" && key !== "metadata") names(item, [...keys, key]);
668
+ if (
669
+ key !== "pattern" &&
670
+ key !== "metadata" &&
671
+ !(keys.length === 1 && key === ENGLISH_GROUP)
672
+ )
673
+ names(item, [...keys, key]);
664
674
  };
665
675
  names(data.names, ["names"]);
666
676
  listOf(data.elements).forEach((element, i) => {
@@ -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("; ")}`,