@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
@@ -607,73 +607,196 @@ function checkCodeLikeTokens(text, maskedRegions) {
607
607
  }
608
608
 
609
609
  /**
610
- * A block's bold label absent from the declared `changelog.labels`
611
- * vocabulary — the drift `**Character data**` beside `**Characters**`
612
- * produces, invisible until something reads the declared list against what a
613
- * changeset actually wrote. A warning, not an error: an undeclared label
614
- * still ships and still groups (`changelog group` files it last), so nothing
615
- * here blocks a release — it only says the vocabulary and the prose have
616
- * drifted apart.
617
- *
618
- * @param {Array<{startLine: number, label: string|null}>} blocks - From
619
- * {@link topLevelBlocks}.
620
- * @param {readonly string[]|null|undefined} labels - `changelog.labels`, or
621
- * `null`/`undefined` when the repository declares none, in which case
622
- * nothing is checked — there is no vocabulary for a label to drift from.
610
+ * The label every repository may use without declaring it, for the changes
611
+ * that fit no declared label. `changelog group` writes it last.
612
+ */
613
+ export const MISCELLANEOUS = "Miscellaneous";
614
+
615
+ /** A label line that holds the label and nothing else. */
616
+ const LABEL_ONLY_RE = /^\*\*([^*]+)\*\*[ \t]*$/;
617
+
618
+ /**
619
+ * Hold a changeset body, or a release section's body, to the house shape:
620
+ * one or more blocks, each a `**Label**` line on its own followed by bullets
621
+ * and nothing else.
622
+ *
623
+ * Findings: a `lead-paragraph` (prose before any label), `unlabelled` (a
624
+ * bullet before any label), `stray-prose` (prose after a label opens, between
625
+ * or after its bullets), `label-line` (text sharing the label's line),
626
+ * `empty-block` (a label with no bullet) and `unknown-label` (a label neither
627
+ * declared in the changelog `labels` nor {@link MISCELLANEOUS}). Nested lists,
628
+ * headings and bullet length are separate rules. Code regions are skipped; a
629
+ * fenced block has its own finding.
630
+ *
631
+ * @param {string} text - Section text.
632
+ * @param {Set<number>} codeLines - Lines inside a code region.
633
+ * @param {Set<number>} scaffoldLines - Generated lines, never authored.
634
+ * @param {readonly string[]|null|undefined} labels - the changelog `labels`, or
635
+ * `null`/`undefined` when the repository declares none.
623
636
  * @returns {RelativeFinding[]}
624
637
  */
625
- function checkUnknownLabels(blocks, labels) {
626
- if (!labels) return [];
638
+ function checkShape(text, codeLines, scaffoldLines, labels) {
639
+ const lines = text.split("\n");
640
+ const declared = labels ?? [];
627
641
  /** @type {RelativeFinding[]} */
628
642
  const findings = [];
629
- for (const block of blocks) {
630
- if (block.label === null || labels.includes(block.label)) continue;
643
+ /** @type {{line: number, label: string, bullets: number}|null} */
644
+ let open = null;
645
+ let reportedLead = false;
646
+ const closeBlock = () => {
647
+ if (open && open.bullets === 0)
648
+ findings.push({
649
+ line: open.line,
650
+ column: 1,
651
+ severity: "error",
652
+ message:
653
+ `changelog-check/empty-block the block labelled "${open.label}" has no ` +
654
+ "bullets — a block is a label line, then bullets",
655
+ });
656
+ open = null;
657
+ };
658
+
659
+ for (let i = 0; i < lines.length; i++) {
660
+ const lineNo = i + 1;
661
+ const raw = lines[i];
662
+ if (codeLines.has(lineNo) || scaffoldLines.has(lineNo)) continue;
663
+ if (raw.trim() === "") continue;
664
+ const atColumnZero = !/^[ \t]/.test(raw);
665
+ if (!atColumnZero) continue; // a bullet's continuation, or a nested item
666
+ if (/^#{1,6}\s/.test(raw)) {
667
+ closeBlock();
668
+ continue; // reported by the heading rule
669
+ }
670
+
671
+ const label = LABEL_LINE_RE.exec(raw)?.[1];
672
+ if (label !== undefined) {
673
+ closeBlock();
674
+ open = { line: lineNo, label, bullets: 0 };
675
+ if (!LABEL_ONLY_RE.test(raw))
676
+ findings.push({
677
+ line: lineNo,
678
+ column: 1,
679
+ severity: "error",
680
+ message:
681
+ "changelog-check/label-line the label stands on a line of its own, " +
682
+ "`**Label**`, with the bullets beneath it",
683
+ });
684
+ if (label !== MISCELLANEOUS && !declared.includes(label))
685
+ findings.push({
686
+ line: lineNo,
687
+ column: 3, // right after the opening `**`
688
+ severity: "error",
689
+ message:
690
+ `changelog-check/unknown-label "${label}" is not a declared label — ` +
691
+ (declared.length > 0 ?
692
+ `declared: ${declared.join(", ")}; ${MISCELLANEOUS} is always available`
693
+ : `declare \`labels\` in the changelog options of .changeset/config.json, ` +
694
+ `or use ${MISCELLANEOUS}`),
695
+ });
696
+ continue;
697
+ }
698
+
699
+ if (TOP_BULLET_RE.test(raw)) {
700
+ if (open) open.bullets++;
701
+ else
702
+ findings.push({
703
+ line: lineNo,
704
+ column: 1,
705
+ severity: "error",
706
+ message:
707
+ "changelog-check/unlabelled a bullet belongs under a `**Label**` line — " +
708
+ `use a declared label, or ${MISCELLANEOUS}`,
709
+ });
710
+ continue;
711
+ }
712
+
713
+ if (!open) {
714
+ if (!reportedLead)
715
+ findings.push({
716
+ line: lineNo,
717
+ column: 1,
718
+ severity: "error",
719
+ message:
720
+ "changelog-check/lead-paragraph a changeset is labelled blocks of " +
721
+ "bullets; a paragraph before the first `**Label**` has no place",
722
+ });
723
+ reportedLead = true;
724
+ continue;
725
+ }
631
726
  findings.push({
632
- line: block.startLine,
633
- column: 3, // right after the opening `**`
634
- severity: "warning",
727
+ line: lineNo,
728
+ column: 1,
729
+ severity: "error",
635
730
  message:
636
- `changelog-check/unknown-label "${block.label}" is not declared in ` +
637
- "`changelog.labels` (declared: " +
638
- `${labels.join(", ")}) — add it there, or correct the label`,
731
+ "changelog-check/stray-prose only bullets sit under a label — move this " +
732
+ "into a bullet, or drop it",
639
733
  });
640
734
  }
735
+ closeBlock();
641
736
  return findings;
642
737
  }
643
738
 
644
739
  /**
645
740
  * The 1-based lines a caller drops from the heading rule because they are the
646
- * generated scaffold, never authored prose: the section's own `## <version>`
647
- * opening and any `### <Bump> Changes` line.
741
+ * generated scaffold, never authored prose: a release section's own
742
+ * `## <version>` opening. Only a release has one.
648
743
  *
649
744
  * @param {string} text - Section text.
745
+ * @param {boolean} release - Whether `text` is a release section.
650
746
  * @returns {Set<number>}
651
747
  */
652
- function scaffoldLineSet(text) {
653
- const lines = text.split("\n");
748
+ function scaffoldLineSet(text, release) {
654
749
  const set = new Set();
655
- if (RELEASE_HEADING_RE.test(lines[0] ?? "")) set.add(1);
656
- for (let i = 0; i < lines.length; i++) {
657
- if (CHANGES_HEADING_RE.test(lines[i])) set.add(i + 1);
658
- }
750
+ if (release && RELEASE_HEADING_RE.test(text.split("\n")[0] ?? "")) set.add(1);
659
751
  return set;
660
752
  }
661
753
 
754
+ /**
755
+ * A `### <Bump> Changes` heading in a release section — the scaffold
756
+ * `changelog group` removes, so one that remains means group has not run.
757
+ *
758
+ * @param {string} text - Release section text.
759
+ * @param {Set<number>} codeLines
760
+ * @returns {RelativeFinding[]}
761
+ */
762
+ function checkChangesHeadings(text, codeLines) {
763
+ /** @type {RelativeFinding[]} */
764
+ const findings = [];
765
+ text.split("\n").forEach((line, i) => {
766
+ if (codeLines.has(i + 1) || !CHANGES_HEADING_RE.test(line)) return;
767
+ findings.push({
768
+ line: i + 1,
769
+ column: 1,
770
+ severity: "error",
771
+ message:
772
+ "changelog-check/changes-heading a `### … Changes` heading survives in the " +
773
+ "release — run `package-build changelog group`, which removes it",
774
+ });
775
+ });
776
+ return findings;
777
+ }
778
+
662
779
  /**
663
780
  * Run every rule over one section of release prose.
664
781
  *
665
782
  * @param {string} text - The section, already isolated by the caller.
666
783
  * @param {object} [opts]
667
- * @param {readonly string[]|null} [opts.labels] - `changelog.labels`, for
668
- * {@link checkUnknownLabels}. `null`/absent checks nothing.
784
+ * @param {readonly string[]|null} [opts.labels] - the changelog `labels`, for
785
+ * {@link checkShape}.
786
+ * @param {boolean} [opts.release] - `text` is a release section, whose
787
+ * `## <version>` line is scaffold and whose `### … Changes` headings are
788
+ * findings.
669
789
  * @returns {RelativeFinding[]} Findings with line numbers relative to `text`.
670
790
  */
671
- function lintSection(text, { labels = null } = {}) {
791
+ function lintSection(text, { labels = null, release = false } = {}) {
672
792
  const codeLines = codeLineSet(text);
673
793
  const maskedRegions = codeRegions(text, { spans: true });
674
- const scaffoldLines = scaffoldLineSet(text);
794
+ const scaffoldLines = scaffoldLineSet(text, release);
795
+ if (release)
796
+ text.split("\n").forEach((line, i) => {
797
+ if (CHANGES_HEADING_RE.test(line)) scaffoldLines.add(i + 1);
798
+ });
675
799
  const bullets = topLevelBullets(text, codeLines);
676
- const blocks = topLevelBlocks(text, codeLines, scaffoldLines);
677
800
 
678
801
  return [
679
802
  ...checkCommitHash(text, codeLines, scaffoldLines),
@@ -687,7 +810,8 @@ function lintSection(text, { labels = null } = {}) {
687
810
  ...checkTooManyBullets(bullets),
688
811
  ...checkTooManyLines(text),
689
812
  ...checkCodeLikeTokens(text, maskedRegions),
690
- ...checkUnknownLabels(blocks, labels),
813
+ ...checkShape(text, codeLines, scaffoldLines, labels),
814
+ ...(release ? checkChangesHeadings(text, codeLines) : []),
691
815
  ].sort((a, b) => a.line - b.line || (a.column ?? 0) - (b.column ?? 0));
692
816
  }
693
817
 
@@ -738,9 +862,9 @@ function extractReleaseSection(text) {
738
862
  *
739
863
  * @param {string} text - The file's full contents, frontmatter included.
740
864
  * @param {object} [opts]
741
- * @param {readonly string[]|null} [opts.labels] - `changelog.labels`, in
742
- * display order, or `null`/absent when the repository declares none —
743
- * {@link checkUnknownLabels} checks nothing in that case.
865
+ * @param {readonly string[]|null} [opts.labels] - the changelog `labels`, in
866
+ * display order, or `null`/absent when the repository declares none, in
867
+ * which case only {@link MISCELLANEOUS} is accepted.
744
868
  * @returns {{findings: Array<{line: number, column?: number,
745
869
  * severity: "error"|"warning", message: string}>}}
746
870
  */
@@ -758,9 +882,9 @@ export function lintChangesetText(text, { labels = null } = {}) {
758
882
  *
759
883
  * @param {string} text - The changelog's full contents.
760
884
  * @param {object} [opts]
761
- * @param {readonly string[]|null} [opts.labels] - `changelog.labels`, in
762
- * display order, or `null`/absent when the repository declares none —
763
- * {@link checkUnknownLabels} checks nothing in that case.
885
+ * @param {readonly string[]|null} [opts.labels] - the changelog `labels`, in
886
+ * display order, or `null`/absent when the repository declares none, in
887
+ * which case only {@link MISCELLANEOUS} is accepted.
764
888
  * @returns {{findings: Array<{line?: number, column?: number,
765
889
  * severity: "error"|"warning", message: string}>}}
766
890
  */
@@ -776,7 +900,7 @@ export function lintReleaseText(text, { labels = null } = {}) {
776
900
  ],
777
901
  };
778
902
  }
779
- const findings = lintSection(section.body, { labels }).map((f) => ({
903
+ const findings = lintSection(section.body, { labels, release: true }).map((f) => ({
780
904
  ...f,
781
905
  line: f.line + section.startLine - 1,
782
906
  }));
@@ -26,7 +26,10 @@
26
26
  * `[[#id]]` link read the label.
27
27
  * 3. **Expressions.** `{{…}}` is evaluated — `sql`, `ref`, dates and the
28
28
  * helpers — see {@link module:engine/markdown-expressions.renderMarkdownExpressions}.
29
- * 4. **Wikilinks.** Each `[[…]]` outside code is resolved against the index by
29
+ * 4. **First uses.** The first use of each name in the author's prose is
30
+ * marked bold — see {@link module:engine/first-use.boldFirstUses}. The
31
+ * generated sections take no mark.
32
+ * 5. **Wikilinks.** Each `[[…]]` outside code is resolved against the index by
30
33
  * {@link module:engine/wikilinks.resolveWikilinks}, and written in the
31
34
  * target's form.
32
35
  *
@@ -55,7 +58,8 @@ import { collectAnchors, eventAnchors } from "./anchors.mjs";
55
58
  import { protectCode } from "./code-fences.mjs";
56
59
  import { scanFigures } from "./content-figures.mjs";
57
60
  import { expandContentTables } from "./content-tables.mjs";
58
- import { markGenerated, splitGenerated } from "./generated-sections.mjs";
61
+ import { boldFirstUses } from "./first-use.mjs";
62
+ import { GENERATED_BOUNDARY, markGenerated, splitGenerated } from "./generated-sections.mjs";
59
63
  import { convertNoteWikilinks } from "./helpers.mjs";
60
64
  import { renderMarkdownExpressions } from "./markdown-expressions.mjs";
61
65
  import { reckoningContext } from "./reckoning-markers.mjs";
@@ -239,35 +243,32 @@ export function expandContent(
239
243
  ]),
240
244
  );
241
245
 
242
- const expressions = renderMarkdownExpressions(
243
- web ? markGenerated(tables.markdown, generatedFrom) : tables.markdown,
244
- {
245
- fm,
246
- dates: web ? index.dateContext : reckoningContext(index),
247
- file,
248
- bodyLine,
249
- sqlResults: prepared.inline,
250
- events,
251
- figures:
252
- web ?
253
- {
254
- get: (id) => figuresById.get(id),
255
- // A cross-note `ref` resolves the written address
256
- // exactly as a wikilink does, against every page's
257
- // figures scanned before any page's expressions render.
258
- note: (address) =>
259
- resolveCrossNoteFigures(address, {
260
- contentTypes: index.contentTypes,
261
- packages: index.packages,
262
- noIndexPackages: index.noIndexPackages,
263
- contentPackage: index.contentPackage,
264
- siteIndexMap: index.index,
265
- figuresByAddress,
266
- }),
267
- }
268
- : { get: (id) => figuresById.get(id), link: foundryRefLink },
269
- },
270
- );
246
+ const expressions = renderMarkdownExpressions(markGenerated(tables.markdown, generatedFrom), {
247
+ fm,
248
+ dates: web ? index.dateContext : reckoningContext(index),
249
+ file,
250
+ bodyLine,
251
+ sqlResults: prepared.inline,
252
+ events,
253
+ figures:
254
+ web ?
255
+ {
256
+ get: (id) => figuresById.get(id),
257
+ // A cross-note `ref` resolves the written address
258
+ // exactly as a wikilink does, against every page's
259
+ // figures scanned before any page's expressions render.
260
+ note: (address) =>
261
+ resolveCrossNoteFigures(address, {
262
+ contentTypes: index.contentTypes,
263
+ packages: index.packages,
264
+ noIndexPackages: index.noIndexPackages,
265
+ contentPackage: index.contentPackage,
266
+ siteIndexMap: index.index,
267
+ figuresByAddress,
268
+ }),
269
+ }
270
+ : { get: (id) => figuresById.get(id), link: foundryRefLink },
271
+ });
271
272
 
272
273
  const findings = {
273
274
  tables: tables.errors,
@@ -276,11 +277,24 @@ export function expandContent(
276
277
  links: [],
277
278
  };
278
279
 
280
+ // The first use of each name is counted in the author's prose alone.
281
+ const sections = splitGenerated(expressions.markdown);
282
+ const named = {
283
+ authored: boldFirstUses(sections.authored, {
284
+ types: web ? index.contentTypes : index.types,
285
+ packages: index.packages,
286
+ noIndexPackages: index.noIndexPackages,
287
+ contentPackage: index.contentPackage,
288
+ }),
289
+ generated: sections.generated,
290
+ };
291
+
279
292
  if (!web) {
293
+ const marked = named.generated ? `${named.authored}\n${named.generated}` : named.authored;
280
294
  let converted;
281
295
  let linkError;
282
296
  try {
283
- converted = convertNoteWikilinks(expressions.markdown, {
297
+ converted = convertNoteWikilinks(marked, {
284
298
  type: fm.type,
285
299
  id: fm.id,
286
300
  file,
@@ -295,7 +309,7 @@ export function expandContent(
295
309
  } catch (error) {
296
310
  linkError = error;
297
311
  }
298
- const body = converted?.markdown ?? expressions.markdown;
312
+ const body = converted?.markdown ?? marked;
299
313
  return {
300
314
  markdown: body,
301
315
  authored: body,
@@ -338,7 +352,14 @@ export function expandContent(
338
352
  // The book resolves the body whole, so a link's occurrence counts across
339
353
  // the author's text and the generated sections it prints after it.
340
354
  if (book) {
341
- const split = splitGenerated(protectCode(expressions.markdown, resolve));
355
+ const split = splitGenerated(
356
+ protectCode(
357
+ named.generated ?
358
+ `${named.authored}\n${GENERATED_BOUNDARY}\n${named.generated}`
359
+ : named.authored,
360
+ resolve,
361
+ ),
362
+ );
342
363
  return {
343
364
  markdown: split.generated ? `${split.authored}\n${split.generated}` : split.authored,
344
365
  authored: split.authored,
@@ -356,12 +377,11 @@ export function expandContent(
356
377
  // The author's text and the generated sections are resolved apart, so a
357
378
  // caller can tell the links the author wrote from the ones a section
358
379
  // derives.
359
- const split = splitGenerated(expressions.markdown);
360
- const authored = protectCode(split.authored, resolve);
380
+ const authored = protectCode(named.authored, resolve);
361
381
  const authoredLinks = resolved.length;
362
- const generated = split.generated ? protectCode(split.generated, resolve) : "";
382
+ const generated = named.generated ? protectCode(named.generated, resolve) : "";
363
383
  return {
364
- markdown: split.generated ? `${authored}\n${generated}` : authored,
384
+ markdown: named.generated ? `${authored}\n${generated}` : authored,
365
385
  authored,
366
386
  generated,
367
387
  lineMap,
@@ -55,7 +55,6 @@ types:
55
55
  - lore
56
56
  - parents
57
57
  - culture
58
- - language
59
58
  - glossary
60
59
  - relations
61
60
  subTypes:
@@ -283,7 +282,6 @@ types:
283
282
  - culture
284
283
  - form
285
284
  - subjects
286
- - language
287
285
  - events
288
286
  - appearanceProfile
289
287
  - glossary
@@ -87,6 +87,8 @@
87
87
 
88
88
  import { isAddressSegment } from "./address-charset.mjs";
89
89
  import { configFileIn, resolveNoteCalendars } from "./note-calendars.mjs";
90
+ import { resolveNoteLanguages } from "./note-language.mjs";
91
+ import { nameFormLists } from "./name-forms.mjs";
90
92
  import { cultureInheritance, reportCultureParents } from "./culture-parents.mjs";
91
93
  import {
92
94
  formatDiagnostic,
@@ -106,6 +108,7 @@ import { AddressLink } from "./address-values.mjs";
106
108
  import { scanSpans } from "./content-spans.mjs";
107
109
  import { rebaseNameSpans } from "./name-markup.mjs";
108
110
  import { analyzeNameSpans } from "./names.mjs";
111
+ import { nameRefFindings } from "./name-refs.mjs";
109
112
  import { fillEncodingRuns, recordRuns } from "./run-rows.mjs";
110
113
  import fs from "node:fs";
111
114
  import path from "node:path";
@@ -249,27 +252,31 @@ export function noteAddress(frontmatter, contentPackage) {
249
252
  }
250
253
 
251
254
  /**
252
- * A note's `name.aliases` reduced to printable 7-bit ASCII, in order.
255
+ * A note's listed name forms reduced to printable 7-bit ASCII, by language.
253
256
  *
254
- * An alias is the name a reader is at least as likely to reach for as the
255
- * canonical one — `Killer Whale` for an orca, `Ice Bear` for a polar bear,
256
- * `Ix'balam` for a jaguar — so anything searching or completing over the index
257
- * has to match them too, and needs the same keyboard-typeable form
258
- * {@link asciiName} gives the primary name.
257
+ * A listed form is a name a reader is at least as likely to reach for as the
258
+ * full one — `Killer Whale` for an orca, a people's own name for the people —
259
+ * so anything searching or completing over the index has to match them too,
260
+ * and needs the same keyboard-typeable form {@link asciiName} gives the full
261
+ * name.
259
262
  *
260
- * Order is the authored order, so a caller can pair an entry with the alias it
261
- * came from. An alias that is not a non-empty string, or that leaves nothing
262
- * printable behind, is dropped rather than left as a hole — the array is a set
263
- * of names to match, and a null in it is not one.
263
+ * Each language keeps its authored order, so a caller can pair an entry with
264
+ * the form it came from. A form that leaves nothing printable behind is
265
+ * dropped rather than left as a hole, and a language left with no form is
266
+ * left out.
264
267
  *
265
- * @param {unknown} aliases - The note's `name.aliases`; may be absent or null.
266
- * @returns {Array<string>} Possibly empty, never null: a note with no aliases
267
- * has an empty set of them, which is a fact rather than a missing value, and
268
- * a consumer iterating it should not have to check first.
268
+ * @param {unknown} name - The note's `name`; may be absent or null.
269
+ * @returns {Record<string, string[]>} Possibly empty, never null: a note
270
+ * listing no form has an empty map, which a consumer iterating it need not
271
+ * check first.
269
272
  */
270
- export function asciiAliases(aliases) {
271
- if (!Array.isArray(aliases)) return [];
272
- return aliases.map((alias) => asciiName(alias)).filter((alias) => alias !== null);
273
+ export function asciiForms(name) {
274
+ const out = {};
275
+ for (const [code, forms] of nameFormLists(name)) {
276
+ const folded = forms.map((form) => asciiName(form)).filter((form) => form !== null);
277
+ if (folded.length) out[code] = folded;
278
+ }
279
+ return out;
273
280
  }
274
281
 
275
282
  /**
@@ -423,7 +430,7 @@ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage, raw)
423
430
  * Build one index record from a note's frontmatter and its place in the tree.
424
431
  *
425
432
  * The frontmatter as authored, plus what the index derives from it: the package
426
- * it compiles as, its address, ASCII folds of its name and aliases, the anchors
433
+ * it compiles as, its address, ASCII folds of its name and listed forms, the anchors
427
434
  * of its body, its Foundry block, the address of its documentation journal, and
428
435
  * where the file sits within the tree.
429
436
  *
@@ -482,10 +489,20 @@ export function buildIndexRecord({
482
489
  assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage, raw);
483
490
 
484
491
  const source = body ?? "";
485
- const spans = syntax ? syntax(source).spans.names : scanSpans(source).names;
486
- const marked = analyzeNameSpans(
487
- rebaseNameSpans(spans, { raw: raw ?? source, bodyLine, bodyColumn }),
488
- { file: absPath ?? relPath, types: nameTypes },
492
+ const scannedSpans = syntax ? syntax(source).spans : scanSpans(source);
493
+ const origin = { raw: raw ?? source, bodyLine, bodyColumn };
494
+ const marked = analyzeNameSpans(rebaseNameSpans(scannedSpans.names, origin), {
495
+ file: absPath ?? relPath,
496
+ types: nameTypes,
497
+ });
498
+ marked.findings.push(
499
+ ...nameRefFindings(
500
+ rebaseNameSpans(
501
+ scannedSpans.spans.filter((span) => span.attrs.some(([key]) => key === "ref")),
502
+ origin,
503
+ ),
504
+ { file: absPath ?? relPath },
505
+ ),
489
506
  );
490
507
  if (marked.findings.length) {
491
508
  const findings = marked.findings.map(({ line, column, ...finding }) => ({
@@ -548,7 +565,7 @@ export function buildIndexRecord({
548
565
  runs: recordRuns(source, { context: runContext, bodyLine, bodyColumn, syntax }),
549
566
  address: stub ? null : address,
550
567
  nameAscii: asciiName(frontmatter?.name?.full),
551
- aliasesAscii: asciiAliases(frontmatter?.name?.aliases),
568
+ formsAscii: asciiForms(frontmatter?.name),
552
569
  // Each anchor carries the link that reaches it, so a section is
553
570
  // addressable from the index without anyone re-deriving how an
554
571
  // anchor is spelled — its file line, so an editor can jump
@@ -1284,6 +1301,15 @@ export function collectContentIndex(
1284
1301
  problems,
1285
1302
  parsed,
1286
1303
  });
1304
+ // Languages read the culture a note states, as calendars do.
1305
+ resolveNoteLanguages(records, {
1306
+ contentPackage,
1307
+ languages,
1308
+ contentBase,
1309
+ foreignRecord,
1310
+ problems,
1311
+ parsed,
1312
+ });
1287
1313
  resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems, parsed });
1288
1314
  const cultureNotes = records
1289
1315
  .filter((record) => isNoteRecord(record) && record.type === "lore")
@@ -242,6 +242,10 @@ export function buildLinkIndex(
242
242
  // The calendar the index resolved, which the reckoning context built
243
243
  // over these notes hands to every date printed for this note.
244
244
  if (record.resolvedCalendar) note.resolvedCalendar = record.resolvedCalendar;
245
+ // The language the index derived, and the code a language note is the
246
+ // note of, which the glossary and `npc new` read.
247
+ if (typeof record.lang === "string") note.lang = record.lang;
248
+ if (typeof record.languageCode === "string") note.languageCode = record.languageCode;
245
249
  const hierarchyProblems = cultureRecordProblems(record);
246
250
  note.inheritedCultureFields = cultureInheritedFields(record);
247
251
  if (hierarchyProblems)
@@ -42,9 +42,8 @@
42
42
  * **Nothing here writes.** A check reports and an author fixes.
43
43
  *
44
44
  * **There is deliberately no third rule** requiring every note to repeat its
45
- * own `type-shortcode` address in a top-level `aliases:` list. It would serve
46
- * no build reader: the resolver and the checker parse the hyphen
47
- * qualifier themselves.
45
+ * own `type-shortcode` address in its frontmatter. It would serve no build
46
+ * reader: the resolver and the checker parse the hyphen qualifier themselves.
48
47
  *
49
48
  * **What is deliberately absent.** Corpus reachability — "every Rules document
50
49
  * is reachable from the book's root" — is a statement about what one package
@@ -27,12 +27,13 @@
27
27
  * | a heading's id | as authored | as authored, or its text's | as authored | as authored |
28
28
  * | quotes, dashes and ellipses | as written | typographic | as written | as written, set by Typst |
29
29
  * | raw HTML in a note | passed through | passed through | shown as text | shown as text |
30
+ * | a name's first use | bold | bold | bold, with an index | bold |
30
31
  * | the output | HTML | HTML | HTML | the token stream, for the Typst stage |
31
32
  *
32
33
  * A target differs from the others only in that table. The shared plugins are
33
34
  * footnotes, definition lists, dictionary rows, bracket spans, dictionary
34
- * entries, heading attributes, icons and body images; each renderer's `plugins`
35
- * lists what its target installs.
35
+ * entries, heading attributes, icons, body images and first uses; each
36
+ * renderer's `plugins` lists what its target installs.
36
37
  *
37
38
  * ## The stages of a render
38
39
  *
@@ -72,6 +73,7 @@ import { dictionaryListPlugin } from "./definition-list.mjs";
72
73
  import { alertMarkdownPlugin, renderAlerts, scanAlerts } from "./content-alerts.mjs";
73
74
  import { renderBlocks, renderWithheldSections } from "./content-blocks.mjs";
74
75
  import { renderFigureBlocks } from "./content-figures.mjs";
76
+ import { firstUsePlugin } from "./first-use.mjs";
75
77
  import { iconPlugin } from "./content-icons.mjs";
76
78
  import { imagePlugin } from "./content-images.mjs";
77
79
  import { renderRuns } from "./content-scripts.mjs";
@@ -390,6 +392,7 @@ export function createRenderer(target, options = {}) {
390
392
  use("definition-lists", deflistPlugin);
391
393
  use("icons", iconPlugin(options.icons));
392
394
  use("images", imagePlugin());
395
+ use("first-use", firstUsePlugin);
393
396
  } else {
394
397
  use("cartouche-figures", cartoucheFigures);
395
398
  use("definition-lists", deflistPlugin);
@@ -409,6 +412,7 @@ export function createRenderer(target, options = {}) {
409
412
  // A buffer with no index has no registry to draw an icon from.
410
413
  if (!source) use("icons", iconPlugin(options.icons));
411
414
  use("images", imagePlugin(options.images?.resolveSrc, options.images?.lookupAsset));
415
+ use("first-use", firstUsePlugin);
412
416
  footnoteMarkup(md);
413
417
  if (preview)
414
418
  use("preview-source", previewSourcePlugin, { ...options.sourceContext, source });
@@ -15,7 +15,7 @@ import { isDeepStrictEqual } from "node:util";
15
15
  import { canonicalKey, parseAddress, renderAddress } from "./address.mjs";
16
16
  import { positionOfFrontmatterPath, formatDiagnostic } from "./diagnostics.mjs";
17
17
 
18
- const fields = ["language", "appearanceProfile"];
18
+ const fields = ["appearanceProfile"];
19
19
  const payload = (note) => note.fm ?? note;
20
20
  const dataOf = (note) => payload(note).data ?? payload(note);
21
21
  const culture = (note) => payload(note).type === "lore" && payload(note).subType === "culture";