@heroiclands/package-build 23.6.1 → 23.7.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.
@@ -26,8 +26,11 @@
26
26
  * - a word's `field` is one of `data.fields`;
27
27
  * - a `borrowed` source names its `language`;
28
28
  * - no form stands twice in one language;
29
- * - every `attested` Address and source language resolves, and a source
30
- * language names a language skill;
29
+ * - a word's `gloss` states at least one sense;
30
+ * - an affix that `replaces` the stem's close does not also `lengthen` it;
31
+ * - a word's `frame` names a frame of `data.sounds.frames`, its language's or
32
+ * an ancestor's;
33
+ * - every source language resolves, and names a language skill;
31
34
  * - a pronunciation is a respelling;
32
35
  * - a `built` piece no word or affix of the language glosses is a warning.
33
36
  *
@@ -49,6 +52,7 @@ import {
49
52
  BORROWED_SOURCE_KIND,
50
53
  LANGUAGE_SUBTYPE,
51
54
  WORD_DATA_KEYS,
55
+ glossSenses,
52
56
  pronunciationProblem,
53
57
  } from "./word-terms.mjs";
54
58
  import { WORD_CLASS_MODIFIERS, parseWordClass } from "./word-classes.mjs";
@@ -174,6 +178,13 @@ export function checkLanguageWords(note, { index } = {}) {
174
178
  }).map((key) => dataAt(key))
175
179
  : [];
176
180
  const glossOf = pieceGlosses(data, ...ancestors);
181
+ // The frames a word may be set in: its language's, then each ancestor's.
182
+ const frames = new Set();
183
+ for (const one of [data, ...ancestors]) {
184
+ const sounds = isMap(one?.sounds) ? one.sounds : {};
185
+ for (const frame of listOf(sounds.frames))
186
+ if (isMap(frame) && typeof frame.frame === "string") frames.add(frame.frame);
187
+ }
177
188
  const used = new Set();
178
189
  const forms = new Map();
179
190
  listOf(data.words).forEach((word, i) => {
@@ -236,10 +247,20 @@ export function checkLanguageWords(note, { index } = {}) {
236
247
  findings.push(...languageFindings(word.source.language, [...at("source"), "language"]));
237
248
  }
238
249
 
239
- listOf(word.attested).forEach((value, j) => {
240
- for (const result of addressFindings(value, [...at("attested"), j], undefined))
241
- if (!result.target) findings.push(result);
242
- });
250
+ if (typeof word.frame === "string" && !frames.has(word.frame))
251
+ findings.push(
252
+ finding(
253
+ at("frame"),
254
+ "error",
255
+ `the frame \`${word.frame}\` of \`${word.form}\` is not one of this language's ` +
256
+ "`data.sounds.frames`",
257
+ ),
258
+ );
259
+
260
+ if (Array.isArray(word.gloss) && !glossSenses(word.gloss).length)
261
+ findings.push(
262
+ finding(at("gloss"), "error", "a `gloss` list states at least one sense"),
263
+ );
243
264
 
244
265
  if (typeof word.pronunciation === "string") {
245
266
  const problem = pronunciationProblem(word.pronunciation);
@@ -266,6 +287,19 @@ export function checkLanguageWords(note, { index } = {}) {
266
287
  });
267
288
  });
268
289
 
290
+ listOf(data.affixes).forEach((affix, i) => {
291
+ if (!isMap(affix) || affix.lengthens !== true || affix.replaces == null) return;
292
+ findings.push(
293
+ finding(
294
+ ["data", "affixes", i, "lengthens"],
295
+ "error",
296
+ `\`${affix.form}\`: an affix that replaces the stem's close does not also lengthen ` +
297
+ "its last vowel; state one",
298
+ true,
299
+ ),
300
+ );
301
+ });
302
+
269
303
  for (const [token, i] of declared)
270
304
  if (!used.has(token))
271
305
  findings.push(
@@ -69,15 +69,49 @@ export const STRUCTURED_ADDRESSES = Object.freeze([
69
69
  type: "place",
70
70
  accepts: ["place"],
71
71
  },
72
- // A language's word Addresses and a glossary entry's language, read
73
- // leniently: a value that is no Address stays as written, for the words or
72
+ // A language's Addresses and a glossary entry's language, read leniently:
73
+ // a value that is no Address stays as written, for the language or
74
74
  // glossary check to refuse at its own key.
75
75
  {
76
76
  noteType: "skill",
77
- path: ["data", "words", "*", "attested"],
78
- shape: "list",
77
+ path: ["data", "tongues", "*", "language"],
78
+ shape: "value",
79
+ type: "skill",
80
+ accepts: ["skill"],
81
+ lenient: true,
82
+ },
83
+ ...["from", "to"].map((key) => ({
84
+ noteType: "skill",
85
+ path: ["data", "soundChanges", "*", key],
86
+ shape: "value",
87
+ type: "skill",
88
+ accepts: ["skill"],
89
+ lenient: true,
90
+ })),
91
+ {
92
+ noteType: "skill",
93
+ path: ["data", "cognates", "*", "forms"],
94
+ shape: "keys",
95
+ type: "skill",
96
+ accepts: ["skill"],
79
97
  lenient: true,
80
98
  },
99
+ ...[
100
+ ["folders", "folder", ["folder"], "none"],
101
+ ["cultures", "lore", ["lore"]],
102
+ ["lore", "lore", ["lore"]],
103
+ ["parents", "place", undefined],
104
+ ["addresses", undefined, undefined],
105
+ ["skip", undefined, undefined],
106
+ ].map(([kind, type, accepts, system]) => ({
107
+ noteType: "skill",
108
+ path: ["data", "scope", kind],
109
+ shape: "list",
110
+ ...(type ? { type } : {}),
111
+ ...(accepts ? { accepts } : {}),
112
+ ...(system ? { system } : {}),
113
+ lenient: true,
114
+ })),
81
115
  {
82
116
  noteType: "skill",
83
117
  path: ["data", "words", "*", "source", "language"],
@@ -132,6 +132,8 @@ import {
132
132
  checkLanguageWords,
133
133
  } from "./language-words.mjs";
134
134
  import { checkGlossary } from "./glossary-entries.mjs";
135
+ import { LANGUAGE_DATA_FIELDS } from "./language-terms.mjs";
136
+ import { checkLanguageData } from "./language-data.mjs";
135
137
  import { checkAffiliationReach } from "./culture-reach.mjs";
136
138
  import { checkLanguageNames, requiredGivenGenders } from "./language-names.mjs";
137
139
  import { SOCIAL_TIES, SOCIAL_TIE_TARGET_TYPES } from "./social-tie-terms.mjs";
@@ -1989,8 +1991,12 @@ export const NOTE_VOCABULARY = Object.freeze({
1989
1991
  skill: Object.freeze({
1990
1992
  stubbable: true,
1991
1993
  // A language note's words, held to the master word classes and to what
1992
- // the note declares; no other skill states them.
1993
- check: checkLanguageWords,
1994
+ // the note declares, and its register, sounds and cognates held to
1995
+ // what they name; no other skill states them.
1996
+ check: (note, context) => [
1997
+ ...checkLanguageWords(note, context),
1998
+ ...checkLanguageData(note, { ...context, types: Object.keys(NOTE_VOCABULARY) }),
1999
+ ],
1994
2000
  subTypes: Object.freeze([
1995
2001
  "social",
1996
2002
  "nature",
@@ -2017,6 +2023,7 @@ export const NOTE_VOCABULARY = Object.freeze({
2017
2023
  FAMILY_FIELD,
2018
2024
  ABBREVIATION_FIELD,
2019
2025
  ...WORD_DATA_FIELDS,
2026
+ ...LANGUAGE_DATA_FIELDS,
2020
2027
  ]),
2021
2028
  }),
2022
2029
 
@@ -44,6 +44,7 @@ import {
44
44
  wordRows,
45
45
  } from "./word-rows.mjs";
46
46
  import { DICTIONARY_COLUMNS, TERM_COLUMNS, dictionaryAndTermRows } from "./dictionary-rows.mjs";
47
+ import { LANGUAGE_COLUMN_TYPES, LANGUAGE_RELATIONS } from "./language-rows.mjs";
47
48
  import { renderDictionary, renderGlossary } from "./dictionary-layout.mjs";
48
49
  import { glossaryLanguage } from "./glossary-language.mjs";
49
50
  import { eventViewSections } from "./event-views.mjs";
@@ -99,12 +100,13 @@ export const SQL_FENCE_ATTRIBUTES = Object.freeze({
99
100
  "letter of a dictionary or a glossary.",
100
101
  }),
101
102
  key: Object.freeze({
102
- value: "`true` or `false`",
103
+ value: "`true`, `false` or `only`",
103
104
  default: "`true`",
104
105
  summary:
105
106
  "Whether a dictionary or a glossary prints its key, the line of abbreviations it " +
106
107
  "uses. `false` leaves the key out, for a page that states it once above several " +
107
- "fences.",
108
+ "fences; `only` prints the key for the fence's rows and nothing else, which is how " +
109
+ "that page states it. Refused on a fence set as a table.",
108
110
  }),
109
111
  layout: Object.freeze({
110
112
  value: "`table`, `dictionary` or `glossary`",
@@ -135,7 +137,7 @@ const escapeCell = (text) =>
135
137
  *
136
138
  * @param {string} markdown - The note body, frontmatter already stripped.
137
139
  * @returns {Array<{line: number, close: number, indent: string, query: string,
138
- * allowEmpty: boolean, sectionLevel: number, showKey: boolean, layout: string, block: string}>} One entry per
140
+ * allowEmpty: boolean, sectionLevel: number, showKey: boolean|"only", layout: string, block: string}>} One entry per
139
141
  * fence, in document order.
140
142
  */
141
143
  export function findSqlBlocks(markdown) {
@@ -170,19 +172,22 @@ export function findSqlBlocks(markdown) {
170
172
  errors.push(error.message);
171
173
  }
172
174
  }
175
+ /** @type {boolean|"only"} */
173
176
  let showKey = true;
174
177
  if (Object.hasOwn(args, "key")) {
175
- try {
176
- showKey = booleanAttribute(args.key, "key");
177
- } catch (error) {
178
- errors.push(error.message);
179
- }
178
+ if (args.key === "only") showKey = "only";
179
+ else if (args.key === "true" || args.key === "false") showKey = args.key === "true";
180
+ else errors.push("key needs the literal value true, false or only");
180
181
  }
181
182
  if (!Number.isInteger(level) || level < 1 || level > 6)
182
183
  errors.push("section-level needs an integer from 1 through 6");
183
184
  const layout = String(args.layout ?? "table");
184
185
  if (!SQL_LAYOUTS.includes(layout))
185
186
  errors.push(`layout is \`table\`, \`dictionary\` or \`glossary\`, not \`${layout}\``);
187
+ else if (Object.hasOwn(args, "key") && layout === "table")
188
+ errors.push(
189
+ "key sets a dictionary's or a glossary's key, and this fence's layout is `table`",
190
+ );
186
191
  blocks.push({
187
192
  line: i,
188
193
  close,
@@ -337,6 +342,7 @@ export async function openNotesDatabase(
337
342
  "language_lineage",
338
343
  "dictionary",
339
344
  "terms",
345
+ ...LANGUAGE_RELATIONS.map(([name]) => name),
340
346
  ])
341
347
  await connection.run(
342
348
  `CREATE VIEW ${WITH_STUBS_SCHEMA}.${relation} AS SELECT * FROM main.${relation}`,
@@ -556,7 +562,9 @@ async function createTableFromFile(connection, name, select) {
556
562
  * {@link module:engine/word-rows.GLOSSARY_COLUMNS},
557
563
  * {@link module:engine/word-rows.LINEAGE_COLUMNS},
558
564
  * {@link module:engine/dictionary-rows.DICTIONARY_COLUMNS} and
559
- * {@link module:engine/dictionary-rows.TERM_COLUMNS} name.
565
+ * {@link module:engine/dictionary-rows.TERM_COLUMNS} name; then each relation
566
+ * of a language's register, sounds, history and names, as
567
+ * {@link module:engine/language-rows} derives them.
560
568
  *
561
569
  * Derived from the same records `entries` reads, stubs included, and a GM
562
570
  * note's rows are left out on a public surface exactly as the note is, while a
@@ -623,6 +631,15 @@ async function createWordRelations(connection, stem, records, prefix, audience)
623
631
  `${prefix}terms`,
624
632
  TERM_COLUMNS,
625
633
  );
634
+ for (const [name, rowsOf, columns] of LANGUAGE_RELATIONS)
635
+ await createRowRelation(
636
+ connection,
637
+ `${stem}-${name}.jsonl`,
638
+ rowsOf(visible, records),
639
+ `${prefix}${name}`,
640
+ columns,
641
+ LANGUAGE_COLUMN_TYPES[name],
642
+ );
626
643
  }
627
644
 
628
645
  /**
@@ -635,12 +652,18 @@ async function createWordRelations(connection, stem, records, prefix, audience)
635
652
  * @param {object[]} rows - The rows, each carrying every column.
636
653
  * @param {string} view - The qualified view name.
637
654
  * @param {readonly string[]} columns - Its columns, for the empty case.
655
+ * @param {Readonly<Record<string, string>>} [types] - Each column's type, for
656
+ * the empty case; a column it leaves out is text. Without it, a whole-number
657
+ * column is a number and every other column text.
638
658
  * @returns {Promise<void>}
639
659
  */
640
- async function createRowRelation(connection, file, rows, view, columns) {
660
+ async function createRowRelation(connection, file, rows, view, columns, types) {
641
661
  if (!rows.length) {
642
662
  const typed = columns.map((column) => {
643
- const type = ROW_INTEGER_COLUMNS.includes(column) ? "BIGINT" : "VARCHAR";
663
+ const type =
664
+ types ? (types[column] ?? "VARCHAR")
665
+ : ROW_INTEGER_COLUMNS.includes(column) ? "BIGINT"
666
+ : "VARCHAR";
644
667
  return `NULL::${type} AS "${column}"`;
645
668
  });
646
669
  await connection.run(`CREATE VIEW ${view} AS SELECT ${typed.join(", ")} WHERE false`);
@@ -0,0 +1,206 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * The notes that cite a language's words, read from the notes' prose and from
16
+ * the names their frontmatter carries.
17
+ *
18
+ * **A marked use is an italic span of a note's body**: `_vazhmadêr_` or
19
+ * `*vazhmadêr*`, holding text alone — a span holding a link, a code span or
20
+ * other markup is no use, and neither is anything in a code fence. A use cites
21
+ * every word, of every language note of the package, whose form or one of
22
+ * whose `variants` it spells, compared with diacritics taken off and case
23
+ * folded, so `_Vazhmadêr_` and `_vazhmader_` both cite `vazhmadêr`. An
24
+ * italic span that spells no word cites nothing.
25
+ *
26
+ * **A note also cites a word its frontmatter names whole**: a full name or an
27
+ * alias, or an affiliation's rank or office title, that spells the word's form
28
+ * or a variant under the same folding. A word found only inside a longer name
29
+ * is no citation.
30
+ *
31
+ * A language note never cites its own words, and its own body cites nothing:
32
+ * its italics are the words it describes. The index carries what this derives as each language record's
33
+ * `citations` — the citing notes' Addresses, never their text — and the
34
+ * `attested` column of the `words` relation and the `usage` of a `dictionary`
35
+ * entry read it.
36
+ *
37
+ * @module
38
+ */
39
+
40
+ import MarkdownIt from "markdown-it";
41
+
42
+ import { isLanguageRecord, noteAddressOf } from "./word-rows.mjs";
43
+
44
+ /** The parser marked uses are read with: CommonMark's emphasis and nothing added. */
45
+ const PARSER = new MarkdownIt("commonmark");
46
+
47
+ /** Code-point order, so a list is the same on every machine. */
48
+ const compare = (a, b) =>
49
+ a < b ? -1
50
+ : a > b ? 1
51
+ : 0;
52
+
53
+ /** Whether a value is a plain map. */
54
+ const isMap = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
55
+
56
+ /**
57
+ * A form as uses are matched against it: diacritics taken off, case folded.
58
+ *
59
+ * @param {unknown} text - A form or a use.
60
+ * @returns {string} The folded form.
61
+ */
62
+ export function foldForm(text) {
63
+ return String(text ?? "")
64
+ .normalize("NFD")
65
+ .replace(/\p{M}/gu, "")
66
+ .toLowerCase()
67
+ .normalize("NFC")
68
+ .trim();
69
+ }
70
+
71
+ /**
72
+ * The marked uses in a note's body: the text of every italic span that holds
73
+ * text alone, in document order.
74
+ *
75
+ * @param {string} body - The note's Markdown body.
76
+ * @returns {string[]} Each span's text.
77
+ */
78
+ export function markedUses(body) {
79
+ const text = String(body ?? "");
80
+ if (!/[_*]/.test(text)) return [];
81
+ const uses = [];
82
+ for (const token of PARSER.parse(text, {})) {
83
+ if (token.type !== "inline" || !token.children) continue;
84
+ let span;
85
+ for (const child of token.children) {
86
+ if (child.type === "em_open") span = [];
87
+ else if (child.type === "em_close") {
88
+ if (span) {
89
+ const use = span.join("").trim();
90
+ if (use && !use.includes("[[")) uses.push(use);
91
+ }
92
+ span = undefined;
93
+ } else if (span) {
94
+ if (child.type === "text") span.push(child.content);
95
+ else span = undefined;
96
+ }
97
+ }
98
+ }
99
+ return uses;
100
+ }
101
+
102
+ /**
103
+ * The names a note carries whole, which cite a word they spell exactly: its
104
+ * full name and aliases, and an affiliation's rank and office titles.
105
+ *
106
+ * @param {Record<string, any>} record - An index record.
107
+ * @returns {string[]}
108
+ */
109
+ function namesCarried(record) {
110
+ const out = [];
111
+ const name = isMap(record.name) ? record.name : {};
112
+ if (typeof name.full === "string") out.push(name.full);
113
+ for (const alias of Array.isArray(name.aliases) ? name.aliases : [])
114
+ if (typeof alias === "string") out.push(alias);
115
+ const governance = isMap(record.data?.governance) ? record.data.governance : {};
116
+ for (const rank of Array.isArray(governance.ranks) ? governance.ranks : [])
117
+ if (isMap(rank) && typeof rank.title === "string") out.push(rank.title);
118
+ if (isMap(governance.offices)) out.push(...Object.keys(governance.offices));
119
+ return out;
120
+ }
121
+
122
+ /**
123
+ * Each language note's cited words: for every word some note's marked use
124
+ * spells, the Addresses of the notes citing it.
125
+ *
126
+ * @param {Iterable<Record<string, any>>} records - Index records.
127
+ * @param {Iterable<{record: Record<string, any>, body: string}>} notes - The
128
+ * notes whose bodies are read, each with its record.
129
+ * @returns {Map<string, Map<string, string[]>>} By language Address, by the
130
+ * word's form, the citing notes in code-point order.
131
+ */
132
+ export function wordCitations(records, notes) {
133
+ const byFold = new Map();
134
+ for (const record of records) {
135
+ if (!isLanguageRecord(record)) continue;
136
+ const language = noteAddressOf(record);
137
+ if (!language) continue;
138
+ for (const word of Array.isArray(record.data?.words) ? record.data.words : []) {
139
+ if (!isMap(word) || typeof word.form !== "string") continue;
140
+ const spellings = [
141
+ word.form,
142
+ ...(Array.isArray(word.variants) ? word.variants : []).filter(
143
+ (variant) => typeof variant === "string",
144
+ ),
145
+ ];
146
+ for (const spelling of spellings) {
147
+ const fold = foldForm(spelling);
148
+ if (!fold) continue;
149
+ if (!byFold.has(fold)) byFold.set(fold, []);
150
+ byFold.get(fold).push({ language, form: word.form });
151
+ }
152
+ }
153
+ }
154
+ const cited = new Map();
155
+ if (!byFold.size) return cited;
156
+ /** Cite every word a folded spelling matches from `note`, never from its own language. */
157
+ const cite = (note, fold) => {
158
+ for (const { language, form } of byFold.get(fold) ?? []) {
159
+ if (language === note) continue;
160
+ if (!cited.has(language)) cited.set(language, new Map());
161
+ const words = cited.get(language);
162
+ if (!words.has(form)) words.set(form, new Set());
163
+ words.get(form).add(note);
164
+ }
165
+ };
166
+ for (const { record, body } of notes) {
167
+ if (isLanguageRecord(record)) continue;
168
+ const note = noteAddressOf(record);
169
+ if (!note) continue;
170
+ for (const fold of new Set(markedUses(body).map(foldForm))) cite(note, fold);
171
+ }
172
+ for (const record of records) {
173
+ if (record?.documents || record?.type === "folder") continue;
174
+ const note = noteAddressOf(record);
175
+ if (!note) continue;
176
+ for (const name of namesCarried(record)) cite(note, foldForm(name));
177
+ }
178
+ const out = new Map();
179
+ for (const [language, words] of cited)
180
+ out.set(language, new Map([...words].map(([form, set]) => [form, [...set].sort(compare)])));
181
+ return out;
182
+ }
183
+
184
+ /**
185
+ * Set each language record's `citations` from {@link wordCitations}: one entry
186
+ * per cited word, in the order the words stand, `{ form, notes }`. A language
187
+ * with no cited word carries no `citations`.
188
+ *
189
+ * @param {Record<string, any>[]} records - Index records, written in place.
190
+ * @param {Iterable<{record: Record<string, any>, body: string}>} notes - The
191
+ * notes whose bodies are read.
192
+ * @returns {void}
193
+ */
194
+ export function attachCitations(records, notes) {
195
+ const cited = wordCitations(records, notes);
196
+ for (const record of records) {
197
+ if (!isLanguageRecord(record)) continue;
198
+ const words = cited.get(noteAddressOf(record) ?? "");
199
+ if (!words) continue;
200
+ const citations = [];
201
+ for (const word of record.data.words)
202
+ if (isMap(word) && words.has(word.form))
203
+ citations.push({ form: word.form, notes: words.get(word.form) });
204
+ record.citations = citations;
205
+ }
206
+ }
@@ -24,9 +24,12 @@
24
24
  * binds.
25
25
  *
26
26
  * A word row holds the words its own note states, and nothing is copied for a
27
- * daughter language. `language_lineage` is one row per language and each of
28
- * its ancestors through `data.family`, itself included at depth `0`, so a
29
- * language's words with its inherited ones are a join of the two.
27
+ * daughter language. Its `gloss` is the word's senses joined by `; `, its
28
+ * `senses` each sense in order, and its `attested` the notes whose prose cites
29
+ * it ({@link module:engine/word-citations}), or `NULL` where none does.
30
+ * `language_lineage` is one row per language and each of its ancestors through
31
+ * `data.family`, itself included at depth `0`, so a language's words with its
32
+ * inherited ones are a join of the two.
30
33
  *
31
34
  * @module
32
35
  */
@@ -39,13 +42,15 @@ import {
39
42
  GLOSSARY_ENTRY_KEYS,
40
43
  LANGUAGE_SUBTYPE,
41
44
  WORD_KEYS,
45
+ glossSenses,
46
+ glossText,
42
47
  } from "./word-terms.mjs";
43
48
 
44
49
  /** The columns a word row carries before the word's own keys. */
45
50
  const WORD_IDENTITY_COLUMNS = Object.freeze(["language", "position"]);
46
51
 
47
52
  /** The columns a word row derives after the word's own keys. */
48
- const WORD_DERIVED_COLUMNS = Object.freeze(["built_text", "built_parts"]);
53
+ const WORD_DERIVED_COLUMNS = Object.freeze(["built_text", "built_parts", "senses", "attested"]);
49
54
 
50
55
  /** Every column of the `words` relation, in order. */
51
56
  export const WORD_COLUMNS = Object.freeze([
@@ -94,10 +99,11 @@ export function pieceGlosses(...datas) {
94
99
  const loose = new Map();
95
100
  const take = (entries) => {
96
101
  for (const entry of Array.isArray(entries) ? entries : []) {
97
- if (!isMap(entry) || typeof entry.form !== "string" || typeof entry.gloss !== "string")
98
- continue;
99
- if (!exact.has(entry.form)) exact.set(entry.form, entry.gloss);
100
- if (!loose.has(bare(entry.form))) loose.set(bare(entry.form), entry.gloss);
102
+ if (!isMap(entry) || typeof entry.form !== "string") continue;
103
+ const gloss = glossText(entry.gloss);
104
+ if (gloss === null) continue;
105
+ if (!exact.has(entry.form)) exact.set(entry.form, gloss);
106
+ if (!loose.has(bare(entry.form))) loose.set(bare(entry.form), gloss);
101
107
  }
102
108
  };
103
109
  for (const data of datas) {
@@ -175,7 +181,7 @@ export function isLanguageRecord(record) {
175
181
  * @param {Record<string, any>} record - An index record.
176
182
  * @returns {string|null} The Address, or `null` for a record naming no note.
177
183
  */
178
- function noteAddressOf(record) {
184
+ export function noteAddressOf(record) {
179
185
  if (!record?.package || !record.type || !record.shortcode) return null;
180
186
  return renderAddress({
181
187
  package: String(record.package),
@@ -185,6 +191,31 @@ function noteAddressOf(record) {
185
191
  });
186
192
  }
187
193
 
194
+ /**
195
+ * A lookup of the notes citing a word, from records that carry `citations`.
196
+ *
197
+ * @param {Iterable<Record<string, any>>} records - Index records.
198
+ * @returns {(language: string, form: string) => string[]} The citing notes;
199
+ * empty where none cites the word.
200
+ */
201
+ export function citationLookup(records) {
202
+ const byLanguage = new Map();
203
+ for (const record of records) {
204
+ if (!isLanguageRecord(record) || !Array.isArray(record.citations)) continue;
205
+ const language = noteAddressOf(record);
206
+ if (!language) continue;
207
+ const words = new Map();
208
+ for (const entry of record.citations)
209
+ if (isMap(entry) && typeof entry.form === "string" && Array.isArray(entry.notes))
210
+ words.set(
211
+ entry.form,
212
+ entry.notes.filter((note) => typeof note === "string"),
213
+ );
214
+ byLanguage.set(language, words);
215
+ }
216
+ return (language, form) => byLanguage.get(language)?.get(form) ?? [];
217
+ }
218
+
188
219
  /**
189
220
  * The `words` rows of a set of index records.
190
221
  *
@@ -198,6 +229,17 @@ function noteAddressOf(record) {
198
229
  */
199
230
  export function wordRows(records, all = records) {
200
231
  const { languages, chain } = languageLineages(all);
232
+ const citedBy = citationLookup(all);
233
+ const visible = new Set();
234
+ for (const record of records) {
235
+ const key = noteAddressOf(record);
236
+ if (key) visible.add(key);
237
+ }
238
+ const known = new Set();
239
+ for (const record of all) {
240
+ const key = noteAddressOf(record);
241
+ if (key) known.add(key);
242
+ }
201
243
  const rows = [];
202
244
  for (const record of records) {
203
245
  if (!isLanguageRecord(record)) continue;
@@ -215,6 +257,13 @@ export function wordRows(records, all = records) {
215
257
  row.built_text = built.length ? built.join(" + ") : null;
216
258
  row.built_parts =
217
259
  built.length ? built.map((form) => ({ form, gloss: glossOf(form) })) : null;
260
+ row.gloss = glossText(entry.gloss);
261
+ row.senses = glossSenses(entry.gloss);
262
+ // A citing note the audience does not see is left out, as the note is.
263
+ const attested = citedBy(language, entry.form).filter(
264
+ (note) => visible.has(note) || !known.has(note),
265
+ );
266
+ row.attested = attested.length ? attested : null;
218
267
  rows.push(row);
219
268
  });
220
269
  }