@heroiclands/package-build 23.8.0 → 23.10.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 (114) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +2 -0
  3. package/bin/content-commands.mjs +207 -70
  4. package/docs/api.md +646 -78
  5. package/docs/authoring/first-note.md +1 -1
  6. package/docs/authoring/links-and-markup.md +253 -102
  7. package/docs/commands.md +111 -13
  8. package/docs/content-format.md +7 -2
  9. package/docs/diagnostics.md +1 -1
  10. package/docs/guides/packs.md +2 -1
  11. package/docs/guides/reference-pages.md +86 -63
  12. package/docs/reference/format-details.md +398 -137
  13. package/docs/reference/note-types.md +188 -174
  14. package/engine/anchors.mjs +15 -26
  15. package/engine/base-compiler.mjs +6 -4
  16. package/engine/changelog-group.mjs +58 -34
  17. package/engine/changelog-lint.mjs +6 -2
  18. package/engine/compendiums.mjs +15 -1
  19. package/engine/compile-corpus.mjs +17 -3
  20. package/engine/content-charset.mjs +13 -1
  21. package/engine/content-format.yaml +1 -0
  22. package/engine/content-html.mjs +18 -4
  23. package/engine/content-icons.mjs +15 -1
  24. package/engine/content-images.mjs +28 -9
  25. package/engine/content-index.mjs +496 -16
  26. package/engine/content-links.mjs +70 -27
  27. package/engine/content-session.mjs +247 -0
  28. package/engine/content-sources.mjs +110 -0
  29. package/engine/content-spans.mjs +87 -8
  30. package/engine/content-tables.mjs +9 -4
  31. package/engine/content-tasklists.mjs +20 -4
  32. package/engine/culture-reach.mjs +61 -34
  33. package/engine/data-keys.mjs +3 -1
  34. package/engine/diagnostics.mjs +17 -6
  35. package/engine/dictionary-layout.mjs +151 -19
  36. package/engine/dictionary-rows.mjs +129 -79
  37. package/engine/extension-attributes.mjs +28 -5
  38. package/engine/first-use.mjs +371 -0
  39. package/engine/foundry-entries.mjs +29 -8
  40. package/engine/generate.mjs +13 -4
  41. package/engine/glossary-entries.mjs +11 -11
  42. package/engine/heading-attributes.mjs +21 -28
  43. package/engine/helpers.mjs +54 -19
  44. package/engine/index-records.mjs +1 -0
  45. package/engine/index.mjs +3 -0
  46. package/engine/journals.mjs +13 -6
  47. package/engine/language-data.mjs +55 -2
  48. package/engine/language-rows.mjs +4 -4
  49. package/engine/language-terms.mjs +90 -4
  50. package/engine/language-words.mjs +2 -2
  51. package/engine/lexical-sections.mjs +247 -0
  52. package/engine/markup-validation.mjs +7 -2
  53. package/engine/metadata-index.mjs +1 -0
  54. package/engine/name-markup.mjs +114 -0
  55. package/engine/name-rows.mjs +70 -0
  56. package/engine/names.mjs +460 -0
  57. package/engine/note-addresses.mjs +25 -4
  58. package/engine/note-claims.mjs +2 -0
  59. package/engine/note-state.mjs +9 -3
  60. package/engine/note-vocabulary.mjs +7 -6
  61. package/engine/path-charset.mjs +24 -1
  62. package/engine/pdf-build.mjs +9 -9
  63. package/engine/pdf-render.mjs +15 -1
  64. package/engine/site-build.mjs +16 -2
  65. package/engine/site-index.mjs +5 -1
  66. package/engine/sql-tables.mjs +324 -44
  67. package/engine/stub-lint.mjs +10 -5
  68. package/engine/web-wikilinks.mjs +8 -6
  69. package/engine/word-rows.mjs +20 -12
  70. package/engine/word-terms.mjs +15 -6
  71. package/engine/yaml-lint.mjs +3 -5
  72. package/hugo-theme/static/css/style.css +4 -0
  73. package/package.json +1 -1
  74. package/types/engine/anchors.d.mts +4 -2
  75. package/types/engine/compendiums.d.mts +5 -1
  76. package/types/engine/compile-corpus.d.mts +5 -2
  77. package/types/engine/content-charset.d.mts +5 -1
  78. package/types/engine/content-html.d.mts +5 -1
  79. package/types/engine/content-icons.d.mts +5 -1
  80. package/types/engine/content-images.d.mts +5 -1
  81. package/types/engine/content-index.d.mts +102 -18
  82. package/types/engine/content-links.d.mts +22 -8
  83. package/types/engine/content-session.d.mts +108 -0
  84. package/types/engine/content-sources.d.mts +32 -0
  85. package/types/engine/content-spans.d.mts +3 -5
  86. package/types/engine/content-tables.d.mts +2 -1
  87. package/types/engine/content-tasklists.d.mts +5 -1
  88. package/types/engine/culture-reach.d.mts +22 -2
  89. package/types/engine/data-keys.d.mts +7 -0
  90. package/types/engine/diagnostics.d.mts +10 -0
  91. package/types/engine/dictionary-layout.d.mts +35 -4
  92. package/types/engine/dictionary-rows.d.mts +9 -0
  93. package/types/engine/extension-attributes.d.mts +16 -2
  94. package/types/engine/first-use.d.mts +31 -0
  95. package/types/engine/foundry-entries.d.mts +14 -5
  96. package/types/engine/generate.d.mts +5 -1
  97. package/types/engine/heading-attributes.d.mts +2 -2
  98. package/types/engine/helpers.d.mts +33 -7
  99. package/types/engine/index.d.mts +1 -0
  100. package/types/engine/journals.d.mts +5 -2
  101. package/types/engine/language-terms.d.mts +7 -0
  102. package/types/engine/lexical-sections.d.mts +96 -0
  103. package/types/engine/markup-validation.d.mts +2 -1
  104. package/types/engine/name-markup.d.mts +176 -0
  105. package/types/engine/name-rows.d.mts +35 -0
  106. package/types/engine/names.d.mts +356 -0
  107. package/types/engine/note-addresses.d.mts +3 -3
  108. package/types/engine/note-state.d.mts +2 -1
  109. package/types/engine/path-charset.d.mts +16 -1
  110. package/types/engine/site-build.d.mts +6 -3
  111. package/types/engine/site-index.d.mts +3 -1
  112. package/types/engine/sql-tables.d.mts +39 -4
  113. package/types/engine/stub-lint.d.mts +3 -1
  114. package/types/engine/word-rows.d.mts +9 -7
@@ -22,18 +22,21 @@
22
22
  * surface sets it as it sets a note:
23
23
  *
24
24
  * - **the key**, once, at the head unless the fence sets `key=false`: the abbreviations this rendering uses,
25
- * each with its expansion, then the common mark where an entry carries it.
25
+ * each with its expansion, then a glossary's common mark where an entry carries it.
26
26
  * A fence setting `key=only` prints the key alone;
27
27
  * - **a heading per letter**, from `letter`, at the fence's `section-level`;
28
28
  * - **one paragraph per entry**: the common mark, the bold headword, the
29
- * pronunciation between slashes, the language tag (and for a word its
30
- * class) in italics, a word's etymology in brackets, the senses, and the
29
+ * pronunciation between slashes, the language tag and word class in
30
+ * italics, a dictionary word's field tag and etymology in brackets, the senses, and the
31
31
  * citations as links.
32
32
  *
33
33
  * A glossary sets the rows that share a headword and a language as one entry:
34
34
  * their senses numbered in the order the rows arrive, each tagged with the
35
- * bodies it comes from, a sense several rows give printed once with all of
36
- * them. A word's senses carry no bodies, and a glossary's always do.
35
+ * bodies it comes from, except a sole owner's label on its own page. A sense
36
+ * several rows give is printed once with all its bodies. A dictionary word's
37
+ * senses carry no bodies; a glossary's contextual
38
+ * and governance senses name their owners. A glossary headword naming a
39
+ * dictionary word links to that word's anchored entry on its language page.
37
40
  *
38
41
  * The entries are ordered here rather than by the query: by letter, then by
39
42
  * `name_sort` with its accents taken off and its case folded, then as written.
@@ -43,8 +46,9 @@
43
46
  * @module
44
47
  */
45
48
 
46
- import { parseAddress, renderAddress } from "./address.mjs";
47
- import { dictionaryLetter } from "./dictionary-rows.mjs";
49
+ import { parseAddress, readCanonicalKey, renderAddress, splitAnchor } from "./address.mjs";
50
+ import { dictionaryEntryAnchor, dictionaryLetter } from "./dictionary-rows.mjs";
51
+ import { VERB_PERSON_FIELDS } from "./language-terms.mjs";
48
52
  import {
49
53
  WORD_CLASS_BASES,
50
54
  WORD_CLASS_MODIFIERS,
@@ -61,6 +65,50 @@ export const LAYOUT_REQUIRED_COLUMNS = Object.freeze({
61
65
  /** The mark a common entry opens with. */
62
66
  export const COMMON_MARK = "•";
63
67
 
68
+ /** The word-entry span class whose Foundry rendering is a native TOC heading. */
69
+ export const DICTIONARY_ENTRY_CLASS = "dictionary-entry";
70
+
71
+ /**
72
+ * Render a dictionary entry's marked headword as an inline native heading in
73
+ * Foundry. Its paragraph becomes a div, so the heading has a valid container.
74
+ * The shared Markdown keeps its span and its containing journal page.
75
+ *
76
+ * @param {import("markdown-it")} md - The Foundry Markdown parser.
77
+ */
78
+ export function dictionaryEntryPlugin(md) {
79
+ md.core.ruler.after("inline", "dictionary_entry_headings", (state) => {
80
+ for (let i = 1; i < state.tokens.length - 1; i++) {
81
+ const inline = state.tokens[i];
82
+ const opening = inline.children?.[0];
83
+ if (
84
+ inline.type !== "inline" ||
85
+ state.tokens[i - 1].type !== "paragraph_open" ||
86
+ state.tokens[i + 1].type !== "paragraph_close" ||
87
+ opening?.type !== "span_open" ||
88
+ !opening.attrGet("id") ||
89
+ !String(opening.attrGet("class") ?? "")
90
+ .split(/\s+/)
91
+ .includes(DICTIONARY_ENTRY_CLASS)
92
+ )
93
+ continue;
94
+ let depth = 0;
95
+ const closing = inline.children.find((token) => {
96
+ if (token.type === "span_open") depth++;
97
+ if (token.type === "span_close") return --depth === 0;
98
+ return false;
99
+ });
100
+ if (!closing) continue;
101
+ opening.tag = closing.tag = "h6";
102
+ opening.attrSet(
103
+ "style",
104
+ "display: inline; margin: 0; padding: 0; border: 0; font: inherit; text-transform: none",
105
+ );
106
+ state.tokens[i - 1].tag = state.tokens[i + 1].tag = "div";
107
+ state.tokens[i - 1].attrSet("style", "margin: 0 0 0.5em");
108
+ }
109
+ });
110
+ }
111
+
64
112
  /** What the key says of the common mark. */
65
113
  const COMMON_KEY = "a common word, which a player is expected to know by heart";
66
114
 
@@ -93,10 +141,16 @@ function sentence(text) {
93
141
  * @property {object} [addressContext] - Address resolution context.
94
142
  * @property {(address: string) => string|undefined} [labelOf] - A note's
95
143
  * `name.full`, by its Address.
96
- * @property {boolean|"only"} [showKey=true] - Whether the key is printed;
97
- * `"only"` prints the key and nothing else.
144
+ * @property {boolean|"only"} [showKey=true] - Whether the key and dictionary
145
+ * head tables are printed; `"only"` prints the key and nothing else.
98
146
  * @property {string} [pageLanguage] - The language of the page the fence is
99
147
  * on, which no entry is tagged with.
148
+ * @property {string} [pageOwner] - The containing page's canonical note Address;
149
+ * a sense attributed only to its own row's owner omits that label on this page.
150
+ * @property {boolean} [entryAnchors=true] - Whether dictionary headwords carry
151
+ * their canonical targets; custom SQL views leave those to the automatic list.
152
+ * @property {object} [verbs] - The owning language's stated pattern, including
153
+ * when its automatic dictionary has no eligible word entries.
100
154
  */
101
155
 
102
156
  /**
@@ -114,9 +168,56 @@ export function renderDictionary(result, opts = {}) {
114
168
  result.rows.map((row) => ({ ...row, rows: [row] })),
115
169
  opts,
116
170
  true,
171
+ verbTables(result.rows, opts),
117
172
  );
118
173
  }
119
174
 
175
+ /** A table cell cannot carry a literal pipe or a line break. */
176
+ const verbCell = (text) =>
177
+ String(text)
178
+ .replace(/\|/g, "\\|")
179
+ .replace(/[\r\n]+/g, " ");
180
+
181
+ /** One head table per represented language, with no forms invented for a word. */
182
+ function verbTables(rows, opts) {
183
+ const patterns = new Map();
184
+ for (const row of rows)
185
+ if (row.verbs && typeof row.verbs === "object")
186
+ patterns.set(row.language ?? opts.pageLanguage ?? "", {
187
+ verbs: row.verbs,
188
+ label: row.language_tag,
189
+ });
190
+ if (opts.verbs && !patterns.has(opts.pageLanguage ?? ""))
191
+ patterns.set(opts.pageLanguage ?? "", { verbs: opts.verbs });
192
+ const tables = [];
193
+ for (const [language, { verbs, label }] of [...patterns].sort(([a], [b]) => compare(a, b))) {
194
+ let endings;
195
+ if (verbs.invariant === true) endings = [["All persons", "unchanged"]];
196
+ else if (
197
+ VERB_PERSON_FIELDS.every((person) => typeof verbs.persons?.[person.name] === "string")
198
+ )
199
+ endings = VERB_PERSON_FIELDS.map((person) => [
200
+ person.label,
201
+ verbs.persons[person.name] ? `-${verbs.persons[person.name]}` : "unchanged",
202
+ ]);
203
+ else continue;
204
+ const title =
205
+ patterns.size > 1 ?
206
+ `**Verbs (${String(opts.labelOf?.(language) ?? label ?? language).replace(/[\r\n]+/g, " ")})**`
207
+ : "**Verbs**";
208
+ tables.push(
209
+ [
210
+ title,
211
+ "",
212
+ "| Person | Ending added to citation form |",
213
+ "| --- | --- |",
214
+ ...endings.map((ending) => `| ${ending.map(verbCell).join(" | ")} |`),
215
+ ].join("\n"),
216
+ );
217
+ }
218
+ return tables.join("\n\n");
219
+ }
220
+
120
221
  /**
121
222
  * Render a result of terms as a glossary.
122
223
  *
@@ -141,10 +242,18 @@ export function renderGlossary(result, opts = {}) {
141
242
  for (const sense of Array.isArray(row.senses) ? row.senses : []) {
142
243
  const same = senses.find((one) => one.text === sense?.text);
143
244
  const bodies = Array.isArray(sense?.bodies) ? sense.bodies : [];
144
- if (!same) senses.push({ text: sense?.text, bodies: [...bodies] });
145
- else
245
+ const omitBody = Boolean(
246
+ opts.pageOwner &&
247
+ row.owner === opts.pageOwner &&
248
+ bodies.length === 1 &&
249
+ bodies[0] === row.owner,
250
+ );
251
+ if (!same) senses.push({ text: sense?.text, bodies: [...bodies], omitBody });
252
+ else {
253
+ same.omitBody &&= omitBody;
146
254
  for (const body of bodies)
147
255
  if (!same.bodies.includes(body)) same.bodies.push(body);
256
+ }
148
257
  }
149
258
  for (const sense of senses) sense.bodies.sort(compare);
150
259
  const first = (column) =>
@@ -157,7 +266,8 @@ export function renderGlossary(result, opts = {}) {
157
266
  pronunciation: first("pronunciation"),
158
267
  name_sort: first("name_sort"),
159
268
  letter: first("letter"),
160
- class: undefined,
269
+ class: first("class"),
270
+ word: first("word"),
161
271
  etymology: undefined,
162
272
  };
163
273
  }),
@@ -181,8 +291,9 @@ function requireColumns(result, layout) {
181
291
  *
182
292
  * @param {object[]} entries - One per entry, with the row columns.
183
293
  * @param {LayoutOptions} opts
184
- * @param {boolean} words - Whether the entries are words, with a class and an
185
- * etymology.
294
+ * @param {boolean} words - Whether the entries are dictionary words, with
295
+ * their own anchors and etymology.
296
+ * @param {string} [head] - Optional dictionary head tables.
186
297
  * @returns {string}
187
298
  */
188
299
  function render(
@@ -194,14 +305,17 @@ function render(
194
305
  labelOf = () => undefined,
195
306
  showKey = true,
196
307
  pageLanguage,
308
+ entryAnchors = true,
197
309
  },
198
310
  words,
311
+ head = "",
199
312
  ) {
200
313
  const label = (address) => labelOf(address) ?? address;
201
314
  const usedTags = new Map();
202
315
  const usedBases = new Set();
203
316
  const usedModifiers = new Set();
204
317
  let anyCommon = false;
318
+ let anyPlural = false;
205
319
 
206
320
  const cite = (address) => {
207
321
  const text = label(address);
@@ -236,25 +350,40 @@ function render(
236
350
  paragraphs.push(`${"#".repeat(sectionLevel)} ${letter}`);
237
351
  }
238
352
  const parts = [];
239
- if (row.common === true) {
353
+ if (!words && row.common === true) {
240
354
  anyCommon = true;
241
355
  parts.push(COMMON_MARK);
242
356
  }
243
- parts.push(`**${row.name}**`);
357
+ let headword = `**${row.name}**`;
358
+ if (words && entryAnchors && typeof row.language === "string")
359
+ headword = `[${headword}]{#${dictionaryEntryAnchor(row.language, row.name)} .${DICTIONARY_ENTRY_CLASS}}`;
360
+ else if (!words && typeof row.word === "string") {
361
+ const { address, anchor } = splitAnchor(row.word);
362
+ const ref = readCanonicalKey(address);
363
+ if (ref && anchor && linkable(ref))
364
+ headword = `**[[${renderAddress(ref)}#${anchor}|${String(row.name).replace(/\|/g, "/")}]]**`;
365
+ }
366
+ parts.push(headword);
244
367
  if (row.pronunciation) parts.push(`/${row.pronunciation}/`);
245
368
 
246
369
  const italic = [];
247
370
  if (row.language_tag && row.language !== pageLanguage)
248
371
  italic.push(tag(row.language, row.language_tag));
249
- if (words && row.class) {
372
+ if (row.class) {
250
373
  const parsed = parseWordClass(row.class);
251
374
  if (!parsed.reason) {
252
375
  usedBases.add(parsed.base);
253
376
  for (const token of parsed.modifiers) usedModifiers.add(token);
254
- italic.push(abbreviateWordClass(parsed));
377
+ let classification = abbreviateWordClass(parsed);
378
+ if (words && parsed.base === "n" && typeof row.plural === "string" && row.plural) {
379
+ classification += `, pl. ${row.plural}`;
380
+ anyPlural = true;
381
+ }
382
+ italic.push(classification);
255
383
  } else italic.push(String(row.class));
256
384
  }
257
385
  if (italic.length) parts.push(`_${italic.join(" ")}_`);
386
+ if (words && row.field) parts.push(`(_${row.field}_)`);
258
387
 
259
388
  if (words) {
260
389
  const bracket = etymologyText(row.etymology, row.language, tag);
@@ -264,7 +393,8 @@ function render(
264
393
  const senses = Array.isArray(row.senses) ? row.senses : [];
265
394
  const senseText = (sense) => {
266
395
  const bodies = Array.isArray(sense?.bodies) ? sense.bodies : [];
267
- const by = bodies.length ? `(_${bodies.map(label).join(", ")}_) ` : "";
396
+ const by =
397
+ bodies.length && !sense.omitBody ? `(_${bodies.map(label).join(", ")}_) ` : "";
268
398
  return `${by}${sentence(sense?.text)}`;
269
399
  };
270
400
  if (senses.length === 1) parts.push(senseText(senses[0]));
@@ -287,9 +417,11 @@ function render(
287
417
  for (const modifier of WORD_CLASS_MODIFIERS)
288
418
  if (usedModifiers.has(modifier.token))
289
419
  key.push(`_${modifier.abbreviation}_ ${modifier.expansion}`);
420
+ if (anyPlural) key.push("_pl._ plural");
290
421
  if (anyCommon) key.push(`${COMMON_MARK} ${COMMON_KEY}`);
291
422
  const line = key.length ? `**Key:** ${key.join("; ")}.` : "";
292
423
  if (showKey === "only") return line;
424
+ if (showKey && head) paragraphs.unshift(head);
293
425
  if (showKey && line) paragraphs.unshift(line);
294
426
  return paragraphs.join("\n\n");
295
427
  }
@@ -36,19 +36,19 @@
36
36
  * where it is a word of its language compared without case. A title of
37
37
  * several words, joined by a space or a hyphen, always is.
38
38
  *
39
- * **A glossary entry with a `definition`** replaces the senses of its owner's
40
- * own rank or office of that name, and otherwise is a term of its owner's own,
41
- * cited from the owner. **An entry without one** marks what it names: the term
42
- * of its name in the language it resolves to
43
- * ({@link module:engine/glossary-language}), among the words and the ranks and
44
- * offices that reach its note, and failing that the term of its name in any
45
- * language that reaches it. Marking sets `common`, and replaces `name_sort`
46
- * and `pronunciation`, on the word's row in that language or on the rank's or
47
- * office's row.
39
+ * **A glossary entry naming a dictionary word** carries the word's class and
40
+ * a link to its entry in the resolved language's dictionary. Without a
41
+ * `definition` it carries the word's senses; with one it carries that sense
42
+ * instead. An owner's own rank or office contributes its descriptions after
43
+ * the word's senses, unless the entry states a definition. An entry naming
44
+ * another body's rank or office carries its senses on the entry's own row.
45
+ * `common`, `name_sort` and `pronunciation` belong to each owner's entry;
46
+ * nothing a glossary states changes a dictionary word.
48
47
  *
49
48
  * @module
50
49
  */
51
50
 
51
+ import { createHash } from "node:crypto";
52
52
  import { parseAddress, renderAddress } from "./address.mjs";
53
53
  import { glossaryLanguage } from "./glossary-language.mjs";
54
54
  import { isNoteRecord } from "./index-records.mjs";
@@ -65,10 +65,12 @@ export const DICTIONARY_COLUMNS = Object.freeze([
65
65
  "letter",
66
66
  "pronunciation",
67
67
  "class",
68
+ "plural",
69
+ "field",
68
70
  "etymology",
69
71
  "senses",
70
72
  "usage",
71
- "common",
73
+ "verbs",
72
74
  "depth",
73
75
  ]);
74
76
 
@@ -82,6 +84,8 @@ export const TERM_COLUMNS = Object.freeze([
82
84
  "name_sort",
83
85
  "letter",
84
86
  "pronunciation",
87
+ "class",
88
+ "word",
85
89
  "senses",
86
90
  "usage",
87
91
  "common",
@@ -126,6 +130,34 @@ const isSingleWord = (title) => !/[\s\-‐‑]/u.test(title);
126
130
  /** Whether a glossary entry states a definition. */
127
131
  const defines = (entry) => typeof entry.definition === "string" && entry.definition.trim() !== "";
128
132
 
133
+ /** A noun's stated plural, or the first literal ending replacement that fits. */
134
+ function pluralOf(word, grammar) {
135
+ if (parseWordClass(word.class).base !== "n") return null;
136
+ if (typeof word.plural === "string" && word.plural) return word.plural;
137
+ for (const rule of Array.isArray(grammar?.plural) ? grammar.plural : []) {
138
+ if (typeof rule?.ending !== "string" || typeof rule.becomes !== "string") continue;
139
+ if (word.form.endsWith(rule.ending))
140
+ return (
141
+ (rule.ending ? word.form.slice(0, -rule.ending.length) : word.form) + rule.becomes
142
+ );
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * The stable prose anchor of a word in one language's dictionary. The exact
149
+ * language and NFC form identify it, including case and accents.
150
+ *
151
+ * @param {string} language - The language's canonical note Address.
152
+ * @param {string} name - The dictionary headword.
153
+ * @returns {string} The anchor slug.
154
+ */
155
+ export function dictionaryEntryAnchor(language, name) {
156
+ return `word-${createHash("sha256")
157
+ .update(JSON.stringify([language, nfc(name)]))
158
+ .digest("hex")}`;
159
+ }
160
+
129
161
  /**
130
162
  * The heading letter of a sort key: its first letter or digit, accents taken
131
163
  * off and apostrophes skipped, in capitals; `#` where it has none.
@@ -259,10 +291,11 @@ export function dictionaryAndTermRows(records, all = records) {
259
291
  byForm.set(key, exact);
260
292
  byLowerForm.set(key, lower);
261
293
  }
262
- /** The word of a form as a language lists it, nearest in its lineage first. */
294
+ /** A form's nearest visible definition, traversing the complete lineage. */
263
295
  const wordIn = (language, name) => {
264
296
  const chain = lineage(language);
265
297
  for (let depth = 0; depth < chain.length; depth++) {
298
+ if (!visible.has(chain[depth])) continue;
266
299
  const word = byForm.get(chain[depth]).get(name);
267
300
  if (word) return { word, statedIn: chain[depth], depth };
268
301
  }
@@ -377,7 +410,7 @@ export function dictionaryAndTermRows(records, all = records) {
377
410
  }
378
411
  const allGovernance = [...governance.values()].flatMap((rows) => [...rows.values()]);
379
412
 
380
- // ── Word rows, before marks ──────────────────────────────────────────────
413
+ // ── Word rows ────────────────────────────────────────────────────────────
381
414
  const wordRows = new Map();
382
415
  const wordKey = (language, name) => `${language}\0${name}`;
383
416
  for (const [language] of languages) {
@@ -388,7 +421,7 @@ export function dictionaryAndTermRows(records, all = records) {
388
421
  const name = nfc(word.form);
389
422
  const key = wordKey(language, name);
390
423
  if (wordRows.has(key)) continue;
391
- wordRows.set(key, { language, name, found: wordIn(language, name), marks: [] });
424
+ wordRows.set(key, { language, name, found: wordIn(language, name) });
392
425
  }
393
426
  }
394
427
  }
@@ -404,48 +437,35 @@ export function dictionaryAndTermRows(records, all = records) {
404
437
  culture ?
405
438
  allGovernance.filter((row) => row.cultures.includes(note))
406
439
  : [...(governance.get(note)?.values() ?? [])];
407
- const reachLanguages = new Set([
408
- languageOf(record, note, undefined),
409
- ...(culture ? reach.map((row) => row.language) : []),
410
- ]);
411
- reachLanguages.delete(null);
412
440
  glossaryOf(note).forEach((entry, position) => {
413
441
  const name = nfc(entry.name);
414
442
  const language = languageOf(record, note, entry);
415
443
  const mark = { note, position, entry };
416
- if (defines(entry)) {
417
- const own = !culture ? governance.get(note)?.get(name) : undefined;
418
- if (own) {
419
- own.definition = mark;
420
- own.marks.push(mark);
421
- own.named = true;
422
- } else
423
- owned.push({
424
- owner: note,
425
- ownerKind: culture ? "culture" : "affiliation",
426
- language,
427
- name,
428
- cultures: culture ? [note] : culturesOf(note),
429
- mark,
430
- });
444
+ const found = wordRows.get(wordKey(language, name))?.found;
445
+ const own = !culture ? governance.get(note)?.get(name) : undefined;
446
+ if (own) {
447
+ own.language = language;
448
+ own.found = found;
449
+ if (defines(entry)) own.definition = mark;
450
+ own.marks.push(mark);
451
+ own.named = true;
431
452
  return;
432
453
  }
433
- const stated = typeof entry.language === "string" && entry.language;
434
- const among = stated ? new Set([language]) : reachLanguages;
435
- const found = [];
436
- for (const candidate of among)
437
- if (wordIn(candidate, name)) {
438
- const row = wordRows.get(wordKey(candidate, name));
439
- if (row) found.push({ language: candidate, row });
440
- }
441
- for (const row of reach)
442
- if (row.name === name && among.has(row.language))
443
- found.push({ language: row.language, row });
444
- const preferred = found.filter((one) => one.language === language);
445
- for (const { row } of preferred.length ? preferred : found) {
446
- row.marks.push(mark);
447
- row.named = true;
448
- }
454
+ const reached = reach.filter((row) => row.name === name);
455
+ const preferred = reached.filter((row) => row.language === language);
456
+ const providers = preferred.length ? preferred : reached;
457
+ if (!defines(entry)) for (const row of providers) row.named = true;
458
+ if (defines(entry) || found || providers.length)
459
+ owned.push({
460
+ owner: note,
461
+ ownerKind: culture ? "culture" : "affiliation",
462
+ language,
463
+ name,
464
+ cultures: culture ? [note] : culturesOf(note),
465
+ found,
466
+ providers: found || defines(entry) ? [] : providers,
467
+ mark,
468
+ });
449
469
  });
450
470
  }
451
471
 
@@ -484,38 +504,50 @@ export function dictionaryAndTermRows(records, all = records) {
484
504
 
485
505
  const citedBy = citationLookup(byKey.values());
486
506
  const dictionary = [];
487
- for (const { language, name, found, marks } of wordRows.values()) {
488
- const { word, depth, statedIn } = found;
507
+ const wordUsage = ({ word, statedIn }) =>
508
+ [...new Set(citedBy(statedIn, word.form).filter(citable))].sort(compare);
509
+ for (const { language, name, found } of wordRows.values()) {
510
+ const { word, depth } = found;
511
+ const grammar = languages.get(language)?.data?.grammar;
489
512
  const nameSort =
490
- marked(marks, "name_sort") ??
491
- (typeof word.name_sort === "string" && word.name_sort ? word.name_sort : name);
513
+ typeof word.name_sort === "string" && word.name_sort ? word.name_sort : name;
492
514
  dictionary.push({
493
515
  language,
494
516
  language_tag: tagOf(language),
495
517
  name,
496
518
  name_sort: nameSort,
497
519
  letter: dictionaryLetter(nameSort),
498
- pronunciation:
499
- marked(marks, "pronunciation") ??
500
- (typeof word.pronunciation === "string" ? word.pronunciation : null),
520
+ pronunciation: typeof word.pronunciation === "string" ? word.pronunciation : null,
501
521
  class: typeof word.class === "string" ? word.class : null,
522
+ plural: pluralOf(word, grammar),
523
+ field: typeof word.field === "string" ? word.field : null,
502
524
  etymology: etymologyOf(found),
503
525
  senses: glossSenses(word.gloss).map((text) => ({ text, bodies: [] })),
504
- usage: [...new Set(citedBy(statedIn, word.form).filter(citable))].sort(compare),
505
- common: marks.some((one) => one.entry.common === true),
526
+ usage: wordUsage(found),
527
+ verbs: isMap(grammar?.verbs) ? grammar.verbs : null,
506
528
  depth,
507
529
  });
508
530
  }
509
531
 
510
532
  const terms = [];
511
533
  const termRow = (
512
- { owner, ownerKind, language, name, cultures },
534
+ { owner, ownerKind, language, name, cultures, found },
513
535
  senses,
514
536
  usage,
515
537
  marks,
516
538
  origins,
517
539
  ) => {
518
- const nameSort = marked(marks, "name_sort") ?? name;
540
+ const word = found?.word;
541
+ const wordLanguage =
542
+ (
543
+ Array.isArray(languages.get(language)?.data?.words) &&
544
+ languages.get(language).data.words.length
545
+ ) ?
546
+ language
547
+ : found?.statedIn;
548
+ const nameSort =
549
+ marked(marks, "name_sort") ??
550
+ (typeof word?.name_sort === "string" && word.name_sort ? word.name_sort : name);
519
551
  terms.push({
520
552
  owner,
521
553
  owner_kind: ownerKind,
@@ -524,7 +556,9 @@ export function dictionaryAndTermRows(records, all = records) {
524
556
  name,
525
557
  name_sort: nameSort,
526
558
  letter: dictionaryLetter(nameSort),
527
- pronunciation: marked(marks, "pronunciation") ?? null,
559
+ pronunciation: marked(marks, "pronunciation") ?? word?.pronunciation ?? null,
560
+ class: word?.class ?? null,
561
+ word: found ? `${wordLanguage}#${dictionaryEntryAnchor(wordLanguage, name)}` : null,
528
562
  senses,
529
563
  usage: [...new Set(usage)].filter(citable).sort(compare),
530
564
  common: marks.some((one) => one.entry.common === true),
@@ -532,34 +566,50 @@ export function dictionaryAndTermRows(records, all = records) {
532
566
  cultures,
533
567
  });
534
568
  };
569
+ const combineSenses = (senses) => {
570
+ const combined = [];
571
+ for (const sense of senses) {
572
+ const same = combined.find((one) => one.text === sense.text);
573
+ if (!same) combined.push({ ...sense, bodies: [...sense.bodies] });
574
+ else
575
+ for (const body of sense.bodies)
576
+ if (!same.bodies.includes(body)) same.bodies.push(body);
577
+ }
578
+ return combined;
579
+ };
580
+ const governanceSenses = (row) => {
581
+ if (row.definition) return [{ text: row.definition.entry.definition, bodies: [row.owner] }];
582
+ return combineSenses([
583
+ ...glossSenses(row.found?.word.gloss).map((text) => ({ text, bodies: [] })),
584
+ ...[...row.pieces]
585
+ .sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind])
586
+ .filter((piece) => piece.text?.trim())
587
+ .map((piece) => ({ text: piece.text, bodies: [row.owner] })),
588
+ ]);
589
+ };
535
590
  for (const row of allGovernance) {
536
591
  const enters =
537
592
  !isSingleWord(row.name) ||
538
593
  row.named ||
539
594
  (row.language !== null && hasWordIgnoringCase(row.language, row.name));
540
595
  if (!enters) continue;
541
- const senses = [];
542
- if (row.definition)
543
- senses.push({ text: row.definition.entry.definition, bodies: [row.owner] });
544
- else
545
- for (const piece of [...row.pieces].sort(
546
- (a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind],
547
- ))
548
- if (piece.text?.trim() && !senses.some((sense) => sense.text === piece.text))
549
- senses.push({ text: piece.text, bodies: [row.owner] });
550
- termRow(row, senses, [...row.usage], row.marks, [
596
+ termRow(row, governanceSenses(row), [...row.usage], row.marks, [
551
597
  ...row.pieces.map((piece) => piece.kind),
552
598
  ...(row.marks.length ? ["glossary"] : []),
553
599
  ]);
554
600
  }
555
- for (const one of owned)
556
- termRow(
557
- one,
558
- [{ text: one.mark.entry.definition, bodies: [one.owner] }],
559
- [one.owner],
560
- [one.mark],
561
- ["glossary"],
562
- );
601
+ for (const one of owned) {
602
+ const defined = defines(one.mark.entry);
603
+ const senses =
604
+ defined ? [{ text: one.mark.entry.definition, bodies: [one.owner] }]
605
+ : one.found ? glossSenses(one.found.word.gloss).map((text) => ({ text, bodies: [] }))
606
+ : combineSenses(one.providers.flatMap(governanceSenses));
607
+ const usage =
608
+ defined ? [one.owner]
609
+ : one.found ? wordUsage(one.found)
610
+ : [one.owner, ...one.providers.flatMap((row) => [...row.usage])];
611
+ termRow(one, senses, usage, [one.mark], ["glossary"]);
612
+ }
563
613
 
564
614
  dictionary.sort(
565
615
  (a, b) =>