@heroiclands/package-build 23.8.0 → 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 (57) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/bin/content-commands.mjs +12 -4
  3. package/docs/api.md +328 -28
  4. package/docs/authoring/first-note.md +1 -1
  5. package/docs/authoring/links-and-markup.md +140 -56
  6. package/docs/commands.md +19 -10
  7. package/docs/content-format.md +2 -2
  8. package/docs/diagnostics.md +1 -1
  9. package/docs/guides/packs.md +2 -1
  10. package/docs/guides/reference-pages.md +86 -63
  11. package/docs/reference/format-details.md +269 -123
  12. package/docs/reference/note-types.md +184 -170
  13. package/engine/anchors.mjs +7 -20
  14. package/engine/compile-corpus.mjs +3 -2
  15. package/engine/content-format.yaml +1 -0
  16. package/engine/content-index.mjs +412 -11
  17. package/engine/content-links.mjs +43 -15
  18. package/engine/content-tables.mjs +8 -4
  19. package/engine/culture-reach.mjs +61 -34
  20. package/engine/data-keys.mjs +3 -1
  21. package/engine/dictionary-layout.mjs +151 -19
  22. package/engine/dictionary-rows.mjs +129 -79
  23. package/engine/foundry-entries.mjs +21 -3
  24. package/engine/glossary-entries.mjs +11 -11
  25. package/engine/heading-attributes.mjs +21 -28
  26. package/engine/helpers.mjs +12 -6
  27. package/engine/journals.mjs +4 -1
  28. package/engine/language-data.mjs +53 -0
  29. package/engine/language-terms.mjs +86 -0
  30. package/engine/language-words.mjs +2 -2
  31. package/engine/lexical-sections.mjs +247 -0
  32. package/engine/note-claims.mjs +2 -0
  33. package/engine/note-state.mjs +9 -3
  34. package/engine/note-vocabulary.mjs +7 -6
  35. package/engine/pdf-build.mjs +9 -9
  36. package/engine/site-build.mjs +16 -2
  37. package/engine/sql-tables.mjs +262 -43
  38. package/engine/stub-lint.mjs +6 -2
  39. package/engine/web-wikilinks.mjs +8 -6
  40. package/engine/word-rows.mjs +20 -12
  41. package/engine/word-terms.mjs +15 -6
  42. package/package.json +1 -1
  43. package/types/engine/content-index.d.mts +65 -1
  44. package/types/engine/content-tables.d.mts +2 -1
  45. package/types/engine/culture-reach.d.mts +22 -2
  46. package/types/engine/data-keys.d.mts +7 -0
  47. package/types/engine/dictionary-layout.d.mts +35 -4
  48. package/types/engine/dictionary-rows.d.mts +9 -0
  49. package/types/engine/foundry-entries.d.mts +3 -1
  50. package/types/engine/heading-attributes.d.mts +2 -2
  51. package/types/engine/helpers.d.mts +4 -1
  52. package/types/engine/language-terms.d.mts +7 -0
  53. package/types/engine/lexical-sections.d.mts +95 -0
  54. package/types/engine/note-state.d.mts +2 -1
  55. package/types/engine/site-build.d.mts +4 -1
  56. package/types/engine/sql-tables.d.mts +33 -3
  57. package/types/engine/word-rows.d.mts +9 -7
@@ -14,6 +14,7 @@
14
14
  import { FENCE_LINE } from "./code-fences.mjs";
15
15
  import { PAGE_LIST_LANGUAGE } from "./page-lists.mjs";
16
16
  import { appendGeneratedSections } from "./generated-sections.mjs";
17
+ import { insertLexicalSections } from "./lexical-sections.mjs";
17
18
 
18
19
  /**
19
20
  * Replace prepared SQL fences and page lists with Markdown while retaining
@@ -29,7 +30,8 @@ import { appendGeneratedSections } from "./generated-sections.mjs";
29
30
  * Prepared results.
30
31
  * A note's generated sections — see {@link module:engine/generated-sections} —
31
32
  * are appended to the body first, from the `generated` text the preparation
32
- * set on its results, and expanded with it.
33
+ * set on its results, and expanded with it. Automatic lexical lists, carried
34
+ * on `lexical`, then fill their authored sections or append a new section.
33
35
  *
34
36
  * @returns {{markdown: string, errors: object[], warnings: object[], lineMap: object[],
35
37
  * generatedFrom?: number}} `generatedFrom` is the 0-based line of the
@@ -148,10 +150,12 @@ export function expandContentTables(markdown, { source = "", sqlTables, pageList
148
150
  i = close;
149
151
  }
150
152
  return {
151
- markdown: out.join("\n"),
153
+ ...insertLexicalSections(out.join("\n"), sqlTables?.lexical, {
154
+ authored,
155
+ lineMap,
156
+ generatedFrom,
157
+ }),
152
158
  errors,
153
159
  warnings,
154
- lineMap,
155
- ...(generatedFrom === undefined ? {} : { generatedFrom }),
156
160
  };
157
161
  }
@@ -15,17 +15,14 @@
15
15
  * Which terms reach a culture or an affiliation, and whether an affiliation
16
16
  * reaches a culture at all.
17
17
  *
18
- * A culture's terms are the words of its language, and the ranks, offices and
19
- * language words of every affiliation that reaches it — through its own
20
- * `data.culture`, the one the index resolves for it, or the culture of an
21
- * affiliation above it through `data.parents` —
22
- * each affiliation's language being the one its glossary resolves to
23
- * ({@link module:engine/glossary-language}). An affiliation's terms are its own
24
- * ranks and offices and the words of that language. A language's words include
25
- * the words it inherits through `data.family`, and a word is a term only where
18
+ * A culture can name the ranks and offices of every affiliation that reaches
19
+ * it through `data.culture` or `data.parents`. An affiliation can name its own
20
+ * ranks and offices. Each glossary entry can also name a word of its resolved
21
+ * language ({@link module:engine/glossary-language}). A language's words include
22
+ * those it inherits through `data.family`, and a word is a term only where
26
23
  * its class's base is a glossary base. The `dictionary` relation
27
- * ({@link module:engine/dictionary-rows}) names a glossary entry's term from
28
- * the same reach.
24
+ * ({@link module:engine/dictionary-rows}) uses the same resolved-language and
25
+ * governance sources.
29
26
  *
30
27
  * **An affiliation that reaches no culture is a warning**, at its `type`, in a
31
28
  * tree that describes peoples, which is a tree holding at least one culture
@@ -42,7 +39,7 @@ import { AddressLink } from "./address-values.mjs";
42
39
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
43
40
  import { glossaryLanguage } from "./glossary-language.mjs";
44
41
  import { parseWordClass, WORD_CLASS_BASES } from "./word-classes.mjs";
45
- import { LANGUAGE_SUBTYPE } from "./word-terms.mjs";
42
+ import { glossSenses, LANGUAGE_SUBTYPE } from "./word-terms.mjs";
46
43
 
47
44
  /** The bases whose words are glossary terms. */
48
45
  const GLOSSARY_BASES = new Set(
@@ -189,6 +186,11 @@ export function entryLanguage(index, note, entry) {
189
186
  * @property {Map<string, Map<string, string[]>>} terms - Each culture's and
190
187
  * affiliation's terms by NFC name, each with the sense texts its sources
191
188
  * give it.
189
+ * @property {Map<string, Map<string, string[]>>} governance - The ranks and
190
+ * offices each owner can name.
191
+ * @property {Map<string, Array<{form: string, gloss: unknown}>>} words - Each
192
+ * language's dictionary words.
193
+ * @property {Map<string, string|undefined>} families - Each language's parent.
192
194
  */
193
195
 
194
196
  /** @type {WeakMap<object, GlossaryCorpus>} */
@@ -239,6 +241,23 @@ function reachedCultures(index, note) {
239
241
  return [...out];
240
242
  }
241
243
 
244
+ /** Add the nearest dictionary definition of each form in a language's lineage. */
245
+ function addLanguageWords(terms, key, language, words, families) {
246
+ const seen = new Set();
247
+ const forms = new Set();
248
+ for (let current = language; current && !seen.has(current); current = families.get(current)) {
249
+ seen.add(current);
250
+ for (const word of words.get(current) ?? []) {
251
+ const form = glossaryName(word.form);
252
+ if (forms.has(form)) continue;
253
+ forms.add(form);
254
+ const senses = glossSenses(word.gloss);
255
+ if (!senses.length) addTerm(terms, key, form, undefined);
256
+ for (const sense of senses) addTerm(terms, key, form, sense);
257
+ }
258
+ }
259
+ }
260
+
242
261
  /**
243
262
  * The glossary corpus of an index.
244
263
  *
@@ -257,18 +276,7 @@ export function glossaryCorpus(index) {
257
276
  if (key) families.set(key, addressKeyOf(dataOf(note).family, index, "skill"));
258
277
  }
259
278
  const terms = new Map();
260
- // A language's words include those it inherits through `data.family`.
261
- const addWords = (key, language) => {
262
- const seen = new Set();
263
- for (
264
- let current = language;
265
- current && !seen.has(current);
266
- current = families.get(current)
267
- ) {
268
- seen.add(current);
269
- for (const word of words.get(current) ?? []) addTerm(terms, key, word.form, word.gloss);
270
- }
271
- };
279
+ const governance = new Map();
272
280
  for (const note of notes) {
273
281
  const culture = isCultureNote(note);
274
282
  if (!culture && typeOf(note) !== "affiliation") continue;
@@ -276,18 +284,22 @@ export function glossaryCorpus(index) {
276
284
  if (!key) continue;
277
285
  if (!terms.has(key)) terms.set(key, new Map());
278
286
  const data = dataOf(note);
279
- if (culture) {
280
- addWords(key, addressKeyOf(data.language, index, "skill"));
281
- continue;
282
- }
287
+ addLanguageWords(terms, key, entryLanguage(index, note, undefined), words, families);
288
+ if (culture) continue;
283
289
  addGovernance(terms, key, data);
284
- addWords(key, entryLanguage(index, note, undefined));
290
+ addGovernance(governance, key, data);
285
291
  for (const home of reachedCultures(index, note)) {
286
292
  addGovernance(terms, home, data);
287
- addWords(home, entryLanguage(index, note, undefined));
293
+ addGovernance(governance, home, data);
288
294
  }
289
295
  }
290
- const corpus = { describesPeoples: notes.some(isCultureNote), terms };
296
+ const corpus = {
297
+ describesPeoples: notes.some(isCultureNote),
298
+ terms,
299
+ governance,
300
+ words,
301
+ families,
302
+ };
291
303
  CORPUS.set(index, corpus);
292
304
  return corpus;
293
305
  }
@@ -298,16 +310,31 @@ export function glossaryCorpus(index) {
298
310
  *
299
311
  * @param {object} index - The link index.
300
312
  * @param {object} note - The culture or affiliation note.
313
+ * @param {object} [entry] - A glossary entry, whose resolved language selects
314
+ * its dictionary words. Absent, the note's own language is used.
301
315
  * @returns {Map<string, string[]>} The terms; empty for a note no source reaches.
302
316
  */
303
- export function noteTerms(index, note) {
317
+ export function noteTerms(index, note, entry) {
304
318
  const key = ownKey(note, index);
305
- return (key && glossaryCorpus(index).terms.get(key)) || new Map();
319
+ const corpus = glossaryCorpus(index);
320
+ if (!entry) return (key && corpus.terms.get(key)) || new Map();
321
+ const own = new Map(
322
+ [...(corpus.governance.get(key) ?? [])].map(([name, senses]) => [name, [...senses]]),
323
+ );
324
+ addLanguageWords(
325
+ new Map([[key, own]]),
326
+ key,
327
+ entryLanguage(index, note, entry),
328
+ corpus.words,
329
+ corpus.families,
330
+ );
331
+ return own;
306
332
  }
307
333
 
308
334
  /**
309
335
  * Warn of an affiliation that reaches no culture, its own or one it inherits,
310
- * in a tree that describes peoples.
336
+ * in a tree that describes peoples. Its ranks and offices then have no
337
+ * culture associations for term queries.
311
338
  *
312
339
  * @param {object} note - An `affiliation` note.
313
340
  * @param {{index?: object}} [context]
@@ -323,7 +350,7 @@ export function checkAffiliationReach(note, { index } = {}) {
323
350
  severity: "warning",
324
351
  message:
325
352
  "this affiliation reaches no culture, its own or one inherited through " +
326
- "`data.parents`, so its ranks, offices and language enter no culture's glossary; " +
353
+ "`data.parents`, so its ranks and offices have no culture associations for term queries; " +
327
354
  "state `data.culture`",
328
355
  },
329
356
  ];
@@ -70,6 +70,8 @@ import { nearest } from "./near-miss.mjs";
70
70
  * every surface shows it exactly as written, never recased.
71
71
  * @property {string} shape - The shape in words, for a finding and the reference.
72
72
  * @property {boolean} [required] - The key must be stated, with a value.
73
+ * @property {boolean} [allowEmpty] - A required string may be empty where
74
+ * empty text is a stated value.
73
75
  * @property {boolean} [nullable] - An explicit `null` states the key, for a
74
76
  * required key whose null has a meaning of its own.
75
77
  * @property {string} [label] - The infobox row label, where humanising the key
@@ -234,7 +236,7 @@ export function pathLabel(path) {
234
236
  function stated(value, spec) {
235
237
  if (value === undefined) return false;
236
238
  if (value === null) return Boolean(spec.nullable);
237
- if (typeof value === "string") return value.trim() !== "";
239
+ if (typeof value === "string") return Boolean(spec.allowEmpty) || value.trim() !== "";
238
240
  return true;
239
241
  }
240
242
 
@@ -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
  }