@heroiclands/package-build 23.7.1 → 23.9.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 (96) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +32 -0
  3. package/bin/content-commands.mjs +32 -10
  4. package/ci/check-banners.mjs +37 -19
  5. package/container.mjs +48 -10
  6. package/docs/api.md +402 -58
  7. package/docs/authoring/first-note.md +1 -1
  8. package/docs/authoring/frontmatter.md +30 -1
  9. package/docs/authoring/links-and-markup.md +154 -56
  10. package/docs/commands.md +31 -12
  11. package/docs/content-format.md +25 -2
  12. package/docs/design.md +31 -0
  13. package/docs/diagnostics.md +1 -1
  14. package/docs/guides/e2e.md +37 -1
  15. package/docs/guides/packs.md +2 -1
  16. package/docs/guides/reference-pages.md +86 -63
  17. package/docs/project-setup.md +30 -15
  18. package/docs/reference/format-details.md +313 -132
  19. package/docs/reference/note-types.md +191 -174
  20. package/e2e.mjs +89 -2
  21. package/engine/anchors.mjs +7 -20
  22. package/engine/base-compiler.mjs +11 -18
  23. package/engine/compile-corpus.mjs +5 -3
  24. package/engine/content-alerts.mjs +4 -2
  25. package/engine/content-blocks.mjs +18 -36
  26. package/engine/content-format.yaml +1 -0
  27. package/engine/content-index.mjs +447 -33
  28. package/engine/content-links.mjs +43 -15
  29. package/engine/content-poetry.mjs +9 -0
  30. package/engine/content-tables.mjs +8 -4
  31. package/engine/culture-reach.mjs +61 -34
  32. package/engine/data-keys.mjs +3 -1
  33. package/engine/diagnostics.mjs +24 -11
  34. package/engine/dictionary-layout.mjs +151 -19
  35. package/engine/dictionary-rows.mjs +129 -79
  36. package/engine/foundry-entries.mjs +21 -3
  37. package/engine/frontmatter-lint.mjs +1 -1
  38. package/engine/generate.mjs +12 -22
  39. package/engine/glossary-entries.mjs +11 -11
  40. package/engine/heading-attributes.mjs +21 -28
  41. package/engine/helpers.mjs +20 -12
  42. package/engine/infobox.mjs +3 -0
  43. package/engine/journals.mjs +15 -2
  44. package/engine/language-data.mjs +53 -0
  45. package/engine/language-terms.mjs +86 -0
  46. package/engine/language-words.mjs +2 -2
  47. package/engine/lexical-sections.mjs +247 -0
  48. package/engine/markdownlint-run.mjs +12 -2
  49. package/engine/markup-validation.mjs +34 -0
  50. package/engine/note-addresses.mjs +1 -0
  51. package/engine/note-claims.mjs +21 -10
  52. package/engine/note-package.mjs +3 -5
  53. package/engine/note-state.mjs +9 -3
  54. package/engine/note-vocabulary.mjs +36 -7
  55. package/engine/pdf-build.mjs +17 -10
  56. package/engine/prose-lint.mjs +12 -6
  57. package/engine/related-pages.mjs +22 -3
  58. package/engine/see-also.mjs +150 -0
  59. package/engine/site-build.mjs +21 -3
  60. package/engine/site-preview.mjs +4 -2
  61. package/engine/sql-tables.mjs +287 -53
  62. package/engine/standings.mjs +14 -12
  63. package/engine/stub-lint.mjs +6 -2
  64. package/engine/web-wikilinks.mjs +8 -6
  65. package/engine/word-rows.mjs +20 -12
  66. package/engine/word-terms.mjs +15 -6
  67. package/hugo-theme/layouts/partials/related.html +25 -8
  68. package/package.json +2 -1
  69. package/types/container.d.mts +4 -3
  70. package/types/e2e.d.mts +28 -0
  71. package/types/engine/base-compiler.d.mts +2 -2
  72. package/types/engine/compile-corpus.d.mts +2 -1
  73. package/types/engine/content-index.d.mts +85 -8
  74. package/types/engine/content-tables.d.mts +2 -1
  75. package/types/engine/culture-reach.d.mts +22 -2
  76. package/types/engine/data-keys.d.mts +7 -0
  77. package/types/engine/diagnostics.d.mts +7 -6
  78. package/types/engine/dictionary-layout.d.mts +35 -4
  79. package/types/engine/dictionary-rows.d.mts +9 -0
  80. package/types/engine/foundry-entries.d.mts +3 -1
  81. package/types/engine/heading-attributes.d.mts +2 -2
  82. package/types/engine/helpers.d.mts +11 -5
  83. package/types/engine/language-terms.d.mts +7 -0
  84. package/types/engine/lexical-sections.d.mts +95 -0
  85. package/types/engine/markdownlint-run.d.mts +3 -1
  86. package/types/engine/markup-validation.d.mts +14 -0
  87. package/types/engine/note-addresses.d.mts +10 -0
  88. package/types/engine/note-claims.d.mts +7 -1
  89. package/types/engine/note-package.d.mts +3 -1
  90. package/types/engine/note-state.d.mts +2 -1
  91. package/types/engine/note-vocabulary.d.mts +1 -0
  92. package/types/engine/related-pages.d.mts +10 -1
  93. package/types/engine/see-also.d.mts +55 -0
  94. package/types/engine/site-build.d.mts +4 -1
  95. package/types/engine/sql-tables.d.mts +40 -6
  96. package/types/engine/word-rows.d.mts +9 -7
@@ -78,6 +78,8 @@ import { isHomepage } from "./homepage.mjs";
78
78
  import { declaresNoPack } from "./pack-router.mjs";
79
79
  import { assertNoDeclaredPackage } from "./note-package.mjs";
80
80
  import { collectAnchors } from "./anchors.mjs";
81
+ import { scanSpans } from "./content-spans.mjs";
82
+ import { DICTIONARY_ENTRY_CLASS } from "./dictionary-layout.mjs";
81
83
  import { journalPageId, splitPages } from "./journals.mjs";
82
84
  import { routerFor } from "./pack-router.mjs";
83
85
  import { loadPackConfig } from "./pack-config.mjs";
@@ -94,7 +96,9 @@ import { publishesContentPages } from "../content-config.mjs";
94
96
  export const LEAD_ANCHOR = "$lead";
95
97
 
96
98
  /**
97
- * Every page of a note's journal, as `anchorName → whole UUID`.
99
+ * Every anchor of a note's journal, as `anchorName → Foundry target`.
100
+ * A dictionary word's native heading carries its fragment after the
101
+ * containing page's UUID.
98
102
  *
99
103
  * Whole, not a fragment appended to the entry's UUID: nothing owns a page
100
104
  * address, so a complete link restates no fact, and it keeps the page-id hash
@@ -112,9 +116,20 @@ export function anchorsOf(entryUuid, entryId, body, name) {
112
116
  const anchors = {};
113
117
  splitPages(body, name).forEach((page, index) => {
114
118
  const uuid = pageUuid(entryUuid, journalPageId(entryId, page));
119
+ const words = new Set(
120
+ scanSpans(page.markdown)
121
+ .spans.filter((span) =>
122
+ span.attrs.some(
123
+ ([key, value]) =>
124
+ key === "class" && value.split(/\s+/).includes(DICTIONARY_ENTRY_CLASS),
125
+ ),
126
+ )
127
+ .map((span) => span.id),
128
+ );
115
129
  if (index === 0) anchors[LEAD_ANCHOR] = uuid;
116
130
  if (page.anchorSlug) anchors[page.anchorSlug] = uuid;
117
- for (const anchor of collectAnchors(page.markdown)) anchors[anchor.slug] = uuid;
131
+ for (const anchor of collectAnchors(page.markdown))
132
+ anchors[anchor.slug] = words.has(anchor.slug) ? `${uuid}#${anchor.slug}` : uuid;
118
133
  });
119
134
  return anchors;
120
135
  }
@@ -290,7 +305,10 @@ export function entriesForNote(fm, name, address, body, ctx) {
290
305
  id: fm.id,
291
306
  uuid: own,
292
307
  systemUuids: systemUuidsFor(fm.type, fm.id),
293
- anchors: own && fm.type === "doc" ? anchorsOf(own, fm.id, body ?? "", name) : undefined,
308
+ anchors:
309
+ own && packForType(fm.type)?.docType === "JournalEntry" ?
310
+ anchorsOf(own, fm.id, body ?? "", name)
311
+ : undefined,
294
312
  },
295
313
  ];
296
314
  }
@@ -1541,7 +1541,7 @@ export function lintNote(
1541
1541
  for (const field of fields) {
1542
1542
  if (note.inheritedCultureFields?.has(field.name)) continue;
1543
1543
  if (typeof field.check !== "function") continue;
1544
- findings.push(...field.check(note, { index }));
1544
+ findings.push(...field.check(note, { index, addressContext: referenceContext }));
1545
1545
  }
1546
1546
  // A check the type declares of the whole note, with the same index.
1547
1547
  if (typeof entry.check === "function") findings.push(...entry.check(note, { index }));
@@ -38,7 +38,7 @@
38
38
  * The HeroicLands vault is not a build input for SoHL content.
39
39
  */
40
40
 
41
- import { noteAddressContext } from "./note-addresses.mjs";
41
+ import { noteAddressContext, decodeNoteAddresses } from "./note-addresses.mjs";
42
42
  import { GENERATED_DRAWING_DIR } from "./generated-sections.mjs";
43
43
  import fs from "fs";
44
44
  import path from "path";
@@ -54,7 +54,7 @@ import { Hm3Actors } from "../hm3/actors.mjs";
54
54
  import { Macros } from "./macros.mjs";
55
55
  import { Scenes } from "./scenes.mjs";
56
56
  import { Bundles } from "./bundles.mjs";
57
- import { statsForPack, parseMarkdownFile, folderFilename } from "./helpers.mjs";
57
+ import { statsForPack, folderFilename } from "./helpers.mjs";
58
58
  import {
59
59
  buildFolderNoteIndex,
60
60
  collectFolderNotes,
@@ -800,7 +800,10 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
800
800
  // every type a system deliberately does not map, which is exactly the
801
801
  // silence the rule requires. Independent of `only`, since it is a fact about the
802
802
  // configured pack list rather than about which passes this run executes.
803
- const unclaimed = unclaimedNoteFindings(config, undefined, { records: corpus.records });
803
+ const unclaimed = unclaimedNoteFindings(config, undefined, {
804
+ records: corpus.records,
805
+ parsed: corpus.parsed,
806
+ });
804
807
  for (const finding of unclaimed) emitDiagnostic(finding);
805
808
 
806
809
  fs.mkdirSync(config.paths.packJson, { recursive: true });
@@ -831,22 +834,8 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
831
834
  try {
832
835
  folderNotes = buildFolderNoteIndex(
833
836
  collectFolderNotes(
834
- // The corpus this compile derived picks the notes; the
835
- // file supplies their frontmatter, and this is one of the few
836
- // places where that distinction is load-bearing rather than
837
- // incidental.
838
- //
839
- // `collectFolderNotes` treats `fm.id` as an **authored pin**,
840
- // which wins over the id it derives under the folder namespace.
841
- // A record's `id` is not that: the index fills it in for every
842
- // addressable note, so handing records straight over
843
- // would make every folder look pinned. The *value*
844
- // would be right either way — the index derives a folder's id
845
- // under the folder namespace, so the two agree — but `derivedId`
846
- // would not, and it is what tells an author whether a duplicate
847
- // id was two pins or two addresses. The index cannot tell a pin
848
- // from a derivation, so the note is read — and only folder notes
849
- // are, 79 of `sohl`'s 1,685 rather than all of them.
837
+ // Folder pins are authored ids; index-derived ids must not
838
+ // turn an unpinned folder into a pinned one.
850
839
  corpus.records
851
840
  .filter(
852
841
  (record) =>
@@ -856,9 +845,10 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
856
845
  .map((record) => {
857
846
  const absPath = noteFile(contentBase, record);
858
847
  return {
859
- frontmatter: parseMarkdownFile(absPath, {
860
- addressContext: noteAddressContext(config),
861
- }).frontmatter,
848
+ frontmatter: decodeNoteAddresses(
849
+ structuredClone(corpus.parsed.get(absPath).frontmatter),
850
+ noteAddressContext(config),
851
+ ),
862
852
  absPath,
863
853
  };
864
854
  }),
@@ -16,12 +16,12 @@
16
16
  * `lore` note of `subType: culture` or on an `affiliation`, a list of
17
17
  * `{ name, name_sort?, common?, definition?, pronunciation?, language? }`.
18
18
  *
19
- * It holds the terms that belong to the people or body using them rather than
20
- * to a language's grammar: euphemisms, idioms, terms of art, and titles that
21
- * are not ranks. An entry with a `definition` creates a term or replaces every
22
- * sense the term's other sources give it. An entry without one changes no
23
- * sense, and only marks a term the note already has — a word of a language
24
- * that reaches it, or a rank or office — as common, sorted or pronounced.
19
+ * An entry chooses a common word, gives a dictionary word a contextual
20
+ * meaning, or defines a phrase, compound or term of art. An entry naming a
21
+ * word of its resolved language carries that word's senses and class unless
22
+ * its own `definition` supplies the sense. A rank or office can also be named.
23
+ * Common marks, sort keys and pronunciation overrides belong to the entry's
24
+ * own glossary.
25
25
  *
26
26
  * The shape is the inner-key check's; this check owns what an entry means:
27
27
  *
@@ -88,7 +88,7 @@ export function checkGlossary(note, { index } = {}) {
88
88
  if (!Array.isArray(data.glossary)) return [];
89
89
 
90
90
  const findings = [];
91
- const terms = index?.notes ? noteTerms(index, note) : undefined;
91
+ const hasTerms = Boolean(index?.notes);
92
92
  const seen = new Set();
93
93
  data.glossary.forEach((entry, i) => {
94
94
  if (!isMap(entry) || typeof entry.name !== "string" || !entry.name.trim()) return;
@@ -116,7 +116,7 @@ export function checkGlossary(note, { index } = {}) {
116
116
  ),
117
117
  );
118
118
  }
119
- if (!terms) return;
119
+ if (!hasTerms) return;
120
120
  const stated = entry.language !== undefined && entry.language !== null;
121
121
  const named = stated ? languageProblem(index, entry.language) : undefined;
122
122
  if (named) findings.push(at([i, "language"], "error", named));
@@ -133,15 +133,15 @@ export function checkGlossary(note, { index } = {}) {
133
133
  "; state `language` on the entry or `data.language` on the note",
134
134
  ),
135
135
  );
136
- const senses = terms.get(name);
136
+ const senses = noteTerms(index, note, entry).get(name);
137
137
  const defined = typeof entry.definition === "string" && entry.definition.trim() !== "";
138
138
  if (!defined && !senses)
139
139
  findings.push(
140
140
  at(
141
141
  [i, "name"],
142
142
  "error",
143
- `\`${entry.name}\` names nothing: it is no word of a language that reaches ` +
144
- "this note and no rank or office it holds, and the entry gives no " +
143
+ `\`${entry.name}\` names nothing: it is no word of its resolved language ` +
144
+ "and no rank or office that reaches this note, and the entry gives no " +
145
145
  "`definition` to make it a term",
146
146
  ),
147
147
  );
@@ -28,12 +28,26 @@
28
28
  * **A leaf, deliberately.** `anchors.mjs` is shared by the compilers, the index
29
29
  * and the link checker and cannot import any of them; the figure pass is
30
30
  * imported by `anchors.mjs` and so cannot import it back. A module that imports
31
- * only the attribute grammar can be asked by all of them.
31
+ * the attribute grammar and Markdown literal ranges can be asked by all of them.
32
32
  *
33
33
  * @module
34
34
  */
35
35
 
36
36
  import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
37
+ import MarkdownIt from "markdown-it";
38
+
39
+ /** Literal block ranges use the renderer's Markdown grammar. */
40
+ const structureParser = new MarkdownIt({ html: true });
41
+
42
+ /** Body lines occupied by code or raw HTML, including comment examples. */
43
+ function literalHeadingLines(source) {
44
+ const lines = new Set();
45
+ for (const token of structureParser.parse(String(source ?? ""), {})) {
46
+ if (!token.map || !["fence", "code_block", "html_block"].includes(token.type)) continue;
47
+ for (let line = token.map[0]; line < token.map[1]; line++) lines.add(line);
48
+ }
49
+ return lines;
50
+ }
37
51
 
38
52
  /**
39
53
  * A heading line: its hashes and the text after them.
@@ -134,8 +148,8 @@ export function opensPage({ level, id }) {
134
148
  /**
135
149
  * Every heading in a body, with what its attribute block declares.
136
150
  *
137
- * Fenced code is skipped: `#` opens a comment in most of what gets fenced, and
138
- * an example heading in a document is not a heading of it.
151
+ * Code and raw HTML blocks are skipped: an example heading in a document is
152
+ * not a heading of it, including an example in an HTML comment.
139
153
  *
140
154
  * @param {string} source - A note's markdown body.
141
155
  * @param {number} [bodyLine=1] - The 1-based file line the body starts on, so a
@@ -148,18 +162,9 @@ export function scanHeadingAttributes(source, bodyLine = 1) {
148
162
  const lines = String(source ?? "").split("\n");
149
163
  const headings = [];
150
164
  const errors = [];
151
- let codeFence = null;
165
+ const literal = literalHeadingLines(source);
152
166
  for (let i = 0; i < lines.length; i++) {
153
- const fence = /^ {0,3}(`{3,}|~{3,})/.exec(lines[i]);
154
- if (codeFence) {
155
- if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length)
156
- codeFence = null;
157
- continue;
158
- }
159
- if (fence) {
160
- codeFence = fence[1];
161
- continue;
162
- }
167
+ if (literal.has(i)) continue;
163
168
  const heading = HEADING_LINE.exec(lines[i]);
164
169
  if (!heading) continue;
165
170
  const parsed = splitHeadingAttributes(heading[2]);
@@ -267,21 +272,9 @@ export function parseHeadingLine(line) {
267
272
  export function pageOpenings(source, blockLines = new Set()) {
268
273
  const lines = String(source ?? "").split("\n");
269
274
  const openings = new Map();
270
- let codeFence = null;
275
+ const literal = literalHeadingLines(source);
271
276
  for (const [index, line] of lines.entries()) {
272
- // A fence's closer must carry the same character as its opener and
273
- // be at least as long, same as {@link scanHeadingAttributes} tracks
274
- // it below — a longer fence is what lets a fenced example carry a
275
- // shorter fence of its own as literal content.
276
- const fence = /^ {0,3}(`{3,}|~{3,})/.exec(line);
277
- if (codeFence) {
278
- if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length) {
279
- codeFence = null;
280
- }
281
- } else if (fence) {
282
- codeFence = fence[1];
283
- }
284
- if (codeFence !== null || blockLines.has(index)) continue;
277
+ if (literal.has(index) || blockLines.has(index)) continue;
285
278
  const heading = parseHeadingLine(line);
286
279
  if (!heading?.startsPage) continue;
287
280
  openings.set(index, { ...heading, line: index + 1 });
@@ -74,6 +74,7 @@ import { isDraftNote } from "./note-vocabulary.mjs";
74
74
  import { DERIVED_PACKED_TYPES, NEVER_PACKED_TYPES } from "./note-claims.mjs";
75
75
  import { expandContentTables } from "./content-tables.mjs";
76
76
  import { spanMarkdownPlugin } from "./content-spans.mjs";
77
+ import { dictionaryEntryPlugin } from "./dictionary-layout.mjs";
77
78
  import { renderBlocks } from "./content-blocks.mjs";
78
79
  import { headingAttributesPlugin } from "./heading-attributes.mjs";
79
80
  import { renderFigureBlocks, scanFigures } from "./content-figures.mjs";
@@ -108,6 +109,7 @@ export const md = markdownit({ html: true })
108
109
  .use(footnotePlugin)
109
110
  .use(deflistPlugin)
110
111
  .use(spanMarkdownPlugin)
112
+ .use(dictionaryEntryPlugin)
111
113
  // A heading's attribute block, written onto the heading element rather than
112
114
  // typeset into it — the reading the website's renderer does for itself.
113
115
  .use(headingAttributesPlugin)
@@ -233,17 +235,18 @@ export function renderFoundryMarkdown(body, figures, footnoteNumbers, docId, res
233
235
  /**
234
236
  * Parses a markdown file with YAML frontmatter.
235
237
  *
236
- * Returns `{ frontmatter, body, description, bodyLine, bodyColumn }` where
238
+ * Returns `{ frontmatter, body, description, bodyLine, bodyColumn, raw }` where
237
239
  * `body` is the trimmed raw markdown after the frontmatter block, and
238
- * `description` is `body` rendered to HTML. `bodyLine` / `bodyColumn` are the
240
+ * `description` is `body` rendered to HTML. The full source is retained as
241
+ * `raw` for diagnostic locations. `bodyLine` / `bodyColumn` are the
239
242
  * 1-based **file** position of the body's first character, which is what turns
240
243
  * an offset within `body` into a position a diagnostic can name — see
241
244
  * {@link positionInBody}. If the file has no frontmatter block, returns
242
- * `{ frontmatter: null, body: "", description: "" }` with a warn log, and no
245
+ * `{ frontmatter: null, body: "", description: "", raw }` and no
243
246
  * position: there is no body to have one.
244
247
  * @param {string} filePath - Markdown file to read.
245
248
  * @param {{addressContext?: object}} [opts] - Address defaults for decoded fields.
246
- * @returns {{frontmatter: object|null, body: string, description: string,
249
+ * @returns {{frontmatter: object|null, body: string, description: string, raw: string,
247
250
  * bodyLine?: number, bodyColumn?: number, parseError?: {message: string,
248
251
  * line?: number, column?: number}}} Parsed note and body position.
249
252
  */
@@ -251,7 +254,7 @@ export function parseMarkdownFile(filePath, { addressContext } = {}) {
251
254
  const content = fs.readFileSync(filePath, "utf8");
252
255
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
253
256
  if (!fmMatch) {
254
- return { frontmatter: null, body: "", description: "" };
257
+ return { frontmatter: null, body: "", description: "", raw: content };
255
258
  }
256
259
  let frontmatter;
257
260
  try {
@@ -264,6 +267,7 @@ export function parseMarkdownFile(filePath, { addressContext } = {}) {
264
267
  frontmatter: null,
265
268
  body: "",
266
269
  description: "",
270
+ raw: content,
267
271
  parseError: {
268
272
  message: `YAML parse error: ${message}`,
269
273
  ...(Number.isInteger(location?.line) ? { line: location.line + 1 } : {}),
@@ -295,7 +299,7 @@ export function parseMarkdownFile(filePath, { addressContext } = {}) {
295
299
  const before = content.slice(0, bodyStart);
296
300
  const bodyLine = before.split("\n").length;
297
301
  const bodyColumn = bodyStart - before.lastIndexOf("\n");
298
- return { frontmatter, body, description, bodyLine, bodyColumn };
302
+ return { frontmatter, body, description, bodyLine, bodyColumn, raw: content };
299
303
  }
300
304
 
301
305
  /**
@@ -779,12 +783,14 @@ import { collectAnchors } from "./anchors.mjs";
779
783
  * omitted.
780
784
  * @param {readonly object[]} [opts.records] - The corpus, derived once per
781
785
  * compile and handed in. Required: see {@link assertSuppliedCorpus}.
786
+ * @param {Map<string, object[]>} [opts.sqlTables] - Prepared directives,
787
+ * including the anchors and Foundry page maps of their expanded bodies.
782
788
  * @returns {{byShortcode: Map, types: Set}} From `buildWikilinkIndex`.
783
789
  */
784
790
  export function buildContentLinkIndex(
785
791
  contentBase,
786
792
  router = packRouter(),
787
- { config, records } = {},
793
+ { config, records, sqlTables } = {},
788
794
  ) {
789
795
  const docs = [];
790
796
  /** The files this package ships, by canonical address. */
@@ -810,6 +816,7 @@ export function buildContentLinkIndex(
810
816
  // authored fields, and neither may be handed the keys the index derived.
811
817
  const fm = authoredFrontmatter(record);
812
818
  const absPath = noteFile(contentBase, record);
819
+ const prepared = sqlTables?.get(absPath);
813
820
  // The id a note's document is filed under: its authored pin, or the
814
821
  // one derived from its canonical address. Derived by the index
815
822
  // against the configuration this build resolved — never here through
@@ -859,11 +866,12 @@ export function buildContentLinkIndex(
859
866
  // discarded here — the walk yields the body and nothing read it.
860
867
  // Read from the record rather than from a second reading of the
861
868
  // note's headings — the one-anchor-reader rule.
862
- anchors: new Set(
863
- (record.anchors ?? [])
869
+ anchors: new Set([
870
+ ...(record.anchors ?? [])
864
871
  .filter((anchor) => anchor.kind === "prose")
865
872
  .map((anchor) => anchor.slug),
866
- ),
873
+ ...(prepared?.anchors ?? []).map((anchor) => anchor.slug),
874
+ ]),
867
875
  // The note's event ids, which a wikilink may not name: a link to
868
876
  // one is refused with its own message rather than as unknown.
869
877
  eventAnchors: new Set(
@@ -871,8 +879,8 @@ export function buildContentLinkIndex(
871
879
  .filter((anchor) => anchor.kind === "event")
872
880
  .map((anchor) => anchor.slug),
873
881
  ),
874
- anchorUuids: record.foundry?.note?.anchors,
875
- docAnchorUuids: documentation?.foundry?.note?.anchors,
882
+ anchorUuids: prepared?.anchorUuids ?? record.foundry?.note?.anchors,
883
+ docAnchorUuids: prepared?.docAnchorUuids ?? documentation?.foundry?.note?.anchors,
876
884
  });
877
885
  }
878
886
  // Packages this build links *into* but does not publish. Each publishes
@@ -277,6 +277,9 @@ export const GEAR_UNITS = Object.freeze({
277
277
  * unit?: string, group?: string, phrase?: (value: any) => string}>>}
278
278
  */
279
279
  export const NOTE_FIELD_PRESENTATION = Object.freeze({
280
+ seeAlso: Object.freeze({
281
+ withheld: "navigation machinery, rendered in its own reading section",
282
+ }),
280
283
  ...DURATION_LABELS,
281
284
  ...GEAR_UNITS,
282
285
 
@@ -50,6 +50,7 @@
50
50
  */
51
51
 
52
52
  import log from "loglevel";
53
+ import { seeAlsoEntries, seeAlsoMarkdown } from "./see-also.mjs";
53
54
 
54
55
  import path from "node:path";
55
56
 
@@ -72,6 +73,7 @@ import { reckoningContext } from "./reckoning-markers.mjs";
72
73
  import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
73
74
  import { JOURNAL_TYPES } from "./ids.mjs";
74
75
  import { journalHasContent } from "./note-state.mjs";
76
+ import { hasLexicalEntries } from "./lexical-sections.mjs";
75
77
  import { draftNoticeFor } from "./draft-notice.mjs";
76
78
  import { scanFigures } from "./content-figures.mjs";
77
79
  import { separateFootnotes } from "./content-footnotes.mjs";
@@ -759,13 +761,15 @@ export class Journals extends BasePackCompiler {
759
761
  * An entry holding only its infobox is empty by this test. The panel is a
760
762
  * rendering of `data:` that the content index already carries, so a
761
763
  * compendium entry of nothing else tells a reader what the index told them.
764
+ * A frontmatter dictionary or glossary supplies pages of its own, so it
765
+ * qualifies before the shared body expansion inserts it.
762
766
  *
763
767
  * @param {object} fm - The note's frontmatter.
764
768
  * @param {string} body - The note body, frontmatter stripped.
765
769
  * @returns {boolean} True to skip the note.
766
770
  */
767
771
  skipNote(fm, body) {
768
- return !journalHasContent(body);
772
+ return !journalHasContent(body) && !hasLexicalEntries(fm);
769
773
  }
770
774
 
771
775
  /**
@@ -825,7 +829,16 @@ export class Journals extends BasePackCompiler {
825
829
  return buildJournalEntry({
826
830
  id,
827
831
  name,
828
- markdown,
832
+ markdown: [
833
+ markdown,
834
+ seeAlsoMarkdown(
835
+ seeAlsoEntries(fm, (ref) => resolveReference(this.linkIndex, ref)),
836
+ linkToUuid,
837
+ 1,
838
+ ),
839
+ ]
840
+ .filter(Boolean)
841
+ .join("\n\n"),
829
842
  resolveRole: (pathname) => roles.get(pathname),
830
843
  infoboxes: boxes.map((box) => ({
831
844
  id: box.id,
@@ -160,6 +160,59 @@ export function checkLanguageData(note, { index, types } = {}) {
160
160
 
161
161
  const findings = [];
162
162
 
163
+ // Only the shape of a supplied declaration: no word is compared with it.
164
+ const grammar = isMap(data.grammar) ? data.grammar : {};
165
+ listOf(grammar.plural).forEach((row, i) => {
166
+ if (row == null || (Array.isArray(row) && row.length === 0))
167
+ findings.push(
168
+ finding(
169
+ ["data", "grammar", "plural", i],
170
+ "error",
171
+ `\`data.grammar.plural[${i}]\` must be a map with ending and becomes strings`,
172
+ ),
173
+ );
174
+ });
175
+ const verbs = grammar.verbs;
176
+ if (Array.isArray(verbs) && verbs.length === 0)
177
+ findings.push(
178
+ finding(
179
+ ["data", "grammar", "verbs"],
180
+ "error",
181
+ "`data.grammar.verbs` must state `invariant: true` or the six `persons` strings",
182
+ ),
183
+ );
184
+ if (isMap(verbs)) {
185
+ const invariant = Object.hasOwn(verbs, "invariant");
186
+ const persons = Object.hasOwn(verbs, "persons");
187
+ if (invariant === persons)
188
+ findings.push(
189
+ finding(
190
+ ["data", "grammar", "verbs"],
191
+ "error",
192
+ "`data.grammar.verbs` must state exactly one of `invariant: true` or `persons`",
193
+ ),
194
+ );
195
+ else if (invariant && (verbs.invariant == null || verbs.invariant === false))
196
+ findings.push(
197
+ finding(
198
+ ["data", "grammar", "verbs", "invariant"],
199
+ "error",
200
+ "`data.grammar.verbs.invariant` must be true",
201
+ ),
202
+ );
203
+ else if (
204
+ persons &&
205
+ (verbs.persons == null || (Array.isArray(verbs.persons) && verbs.persons.length === 0))
206
+ )
207
+ findings.push(
208
+ finding(
209
+ ["data", "grammar", "verbs", "persons"],
210
+ "error",
211
+ "`data.grammar.verbs.persons` must be a map of all six ending strings",
212
+ ),
213
+ );
214
+ }
215
+
163
216
  /**
164
217
  * The note an Address names, or a finding where it names none.
165
218
  *
@@ -43,6 +43,7 @@ export const LANGUAGE_DATA_KEYS = Object.freeze([
43
43
  "older",
44
44
  "negation",
45
45
  "naming",
46
+ "grammar",
46
47
  ]);
47
48
 
48
49
  /**
@@ -1594,6 +1595,81 @@ function listOfMaps(name, fields, describe) {
1594
1595
  });
1595
1596
  }
1596
1597
 
1598
+ /**
1599
+ * The six present persons of the one optional regular verb pattern, in
1600
+ * presentation order. Empty endings leave the citation form unchanged.
1601
+ *
1602
+ * @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
1603
+ */
1604
+ export const VERB_PERSON_FIELDS = Object.freeze(
1605
+ [
1606
+ ["I", "I"],
1607
+ ["you", "you"],
1608
+ ["heShe", "he/she"],
1609
+ ["we", "we"],
1610
+ ["youAll", "you all"],
1611
+ ["they", "they"],
1612
+ ].map(([name, label]) =>
1613
+ Object.freeze({
1614
+ name,
1615
+ label,
1616
+ kind: "string",
1617
+ required: true,
1618
+ allowEmpty: true,
1619
+ shape: "an ending string, empty for unchanged",
1620
+ describe: `The ending added to the citation form for ${label}.`,
1621
+ }),
1622
+ ),
1623
+ );
1624
+
1625
+ /** The two alternatives of a supplied optional verb pattern. */
1626
+ const VERB_PATTERN_FIELDS = Object.freeze([
1627
+ Object.freeze({
1628
+ name: "invariant",
1629
+ kind: "boolean",
1630
+ shape: "true, as the alternative to persons",
1631
+ describe: "True where verbs keep the citation form for every person; excludes `persons`.",
1632
+ }),
1633
+ Object.freeze({
1634
+ name: "persons",
1635
+ kind: "map",
1636
+ shape: "a map — `{ I, you, heShe, we, youAll, they }` of ending strings",
1637
+ fields: VERB_PERSON_FIELDS,
1638
+ describe: "All six present endings, each added to the citation form; excludes `invariant`.",
1639
+ }),
1640
+ ]);
1641
+
1642
+ /** The optional, display-only noun and verb patterns of one language. */
1643
+ const GRAMMAR_FIELDS = Object.freeze([
1644
+ innerList(
1645
+ "plural",
1646
+ Object.freeze(
1647
+ ["ending", "becomes"].map((name) =>
1648
+ Object.freeze({
1649
+ name,
1650
+ kind: "string",
1651
+ required: true,
1652
+ allowEmpty: true,
1653
+ shape: "a literal ending string, which may be empty",
1654
+ describe:
1655
+ name === "ending" ?
1656
+ "The bare suffix replaced by this rule; empty matches every noun."
1657
+ : "The bare replacement; empty removes the matching suffix.",
1658
+ }),
1659
+ ),
1660
+ ),
1661
+ "Default noun plurals, replacing the first matching literal ending in authored order. " +
1662
+ "A word's explicit plural wins; no word is checked against this rule.",
1663
+ ),
1664
+ Object.freeze({
1665
+ name: "verbs",
1666
+ kind: "map",
1667
+ shape: "a map — `{ invariant: true }` or `{ persons: { I, you, heShe, we, youAll, they } }`",
1668
+ fields: VERB_PATTERN_FIELDS,
1669
+ describe: "One optional regular present pattern, printed once at the dictionary head.",
1670
+ }),
1671
+ ]);
1672
+
1597
1673
  /**
1598
1674
  * The `data` fields this module declares, in the order the vocabulary lists
1599
1675
  * them. {@link module:engine/language-data.checkLanguageData} checks what the
@@ -1602,6 +1678,16 @@ function listOfMaps(name, fields, describe) {
1602
1678
  * @type {readonly object[]}
1603
1679
  */
1604
1680
  export const LANGUAGE_DATA_FIELDS = Object.freeze([
1681
+ Object.freeze({
1682
+ name: "grammar",
1683
+ kind: "map",
1684
+ shape: "a map — `{ plural?, verbs? }`",
1685
+ fields: GRAMMAR_FIELDS,
1686
+ describe:
1687
+ "Optional noun plurals and one verb pattern for dictionary presentation. Only the " +
1688
+ "shape of supplied keys is checked; no word must follow or state a rule. A language " +
1689
+ "skill note only.",
1690
+ }),
1605
1691
  listOfMaps(
1606
1692
  "tongues",
1607
1693
  TONGUE_FIELDS,
@@ -36,7 +36,7 @@
36
36
  * an ancestor's;
37
37
  * - every source language resolves, and names a language skill;
38
38
  * - a pronunciation is a respelling;
39
- * - a `built` piece no word or affix of the language glosses is a warning.
39
+ * - a `built` piece no word, affix or element of the language glosses is a warning.
40
40
  *
41
41
  * {@link checkLanguageFamily} owns `data.family`, the parent language a
42
42
  * daughter reads words from: it is stated only on a language, names a
@@ -285,7 +285,7 @@ export function checkLanguageWords(note, { index } = {}) {
285
285
  finding(
286
286
  [...at("built"), j],
287
287
  "warning",
288
- `the piece \`${piece}\` of \`${word.form}\` matches no word or affix of this ` +
288
+ `the piece \`${piece}\` of \`${word.form}\` matches no word, affix or element of this ` +
289
289
  "language or its ancestors, so it prints with no gloss",
290
290
  ),
291
291
  );