@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
@@ -17,6 +17,7 @@ import { slugify } from "./content-slug.mjs";
17
17
  import { scanPoetry } from "./content-poetry.mjs";
18
18
  import footnotePlugin from "markdown-it-footnote";
19
19
  import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
20
+ import { inlineText } from "./name-markup.mjs";
20
21
 
21
22
  /** Add bracket spans after recognized Markdown links and footnotes.
22
23
  * @param {import("markdown-it")} md - Markdown parser to extend.
@@ -57,7 +58,9 @@ export function spanMarkdownPlugin(md) {
57
58
  if (close === state.posMax)
58
59
  parsed.problems.push("span attributes need closing {…} braces");
59
60
  else {
60
- parsed = parseExtensionAttributes(state.src.slice(finish + 1, close));
61
+ parsed = parseExtensionAttributes(state.src.slice(finish + 1, close), {
62
+ positions: true,
63
+ });
61
64
  finish = close + 1;
62
65
  }
63
66
  }
@@ -73,6 +76,17 @@ export function spanMarkdownPlugin(md) {
73
76
  end: finish,
74
77
  text: state.src.slice(start + 1, end),
75
78
  problems,
79
+ attributePositions: Object.fromEntries(
80
+ Object.entries(parsed.positions ?? {}).map(([key, positions]) => [
81
+ key,
82
+ Object.fromEntries(
83
+ Object.entries(positions).map(([part, range]) => [
84
+ part,
85
+ { start: range.start + end + 2, end: range.end + end + 2 },
86
+ ]),
87
+ ),
88
+ ]),
89
+ ),
76
90
  };
77
91
  if (parsed.id) token.attrSet("id", slugify(parsed.id));
78
92
  if (parsed.classes.length) token.attrSet("class", parsed.classes.join(" "));
@@ -84,6 +98,11 @@ export function spanMarkdownPlugin(md) {
84
98
  if (child.meta?.span) {
85
99
  child.meta.start += start + 1;
86
100
  child.meta.end += start + 1;
101
+ for (const positions of Object.values(child.meta.attributePositions ?? {}))
102
+ for (const range of Object.values(positions)) {
103
+ range.start += start + 1;
104
+ range.end += start + 1;
105
+ }
87
106
  }
88
107
  state.tokens.push(...nested);
89
108
  state.push("span_close", "span", -1);
@@ -98,6 +117,7 @@ const parser = new MarkdownIt({ html: true })
98
117
  .use(spanMarkdownPlugin);
99
118
  /** Read span anchors and located attribute diagnostics outside literal code.
100
119
  * @param {string} source - Authored Markdown.
120
+ * @returns {import("./name-markup.mjs").SpanScan} Actual spans, explicit names and located diagnostics.
101
121
  */
102
122
  export function scanSpans(source) {
103
123
  const text = String(source ?? ""),
@@ -112,14 +132,28 @@ export function scanSpans(source) {
112
132
  starts.push(offset);
113
133
  offset += line.length + 1;
114
134
  }
135
+ const located = (start, end) => {
136
+ let lo = 0,
137
+ hi = starts.length;
138
+ while (lo + 1 < hi) {
139
+ const mid = (lo + hi) >>> 1;
140
+ if (starts[mid] <= start) lo = mid;
141
+ else hi = mid;
142
+ }
143
+ return { start, end, line: lo + 1, column: start - starts[lo] + 1 };
144
+ };
115
145
  const collect = (children, position) => {
116
- for (const child of children ?? []) {
146
+ for (const [index, child] of (children ?? []).entries()) {
117
147
  if (!child.meta?.span) continue;
118
148
  const start = position(child.meta.start),
119
149
  end = position(child.meta.end);
120
- const prefix = text.slice(0, start),
121
- line = prefix.split("\n").length,
122
- column = start - prefix.lastIndexOf("\n");
150
+ const { line, column } = located(start, end);
151
+ let depth = 1,
152
+ close = index + 1;
153
+ for (; close < children.length; close++) {
154
+ if (children[close].type === "span_open") depth++;
155
+ if (children[close].type === "span_close" && --depth === 0) break;
156
+ }
123
157
  spans.push({
124
158
  id: child.attrGet("id") ?? "",
125
159
  line,
@@ -127,13 +161,50 @@ export function scanSpans(source) {
127
161
  start,
128
162
  end,
129
163
  text: child.meta.text,
164
+ contentEnd: position(child.meta.start + 1 + child.meta.text.length),
165
+ visible: inlineText(children.slice(index + 1, close)),
130
166
  attrs: child.attrs ?? [],
167
+ attributePositions: Object.fromEntries(
168
+ Object.entries(child.meta.attributePositions ?? {}).map(([key, positions]) => [
169
+ key,
170
+ Object.fromEntries(
171
+ Object.entries(positions).map(([part, range]) => [
172
+ part,
173
+ located(position(range.start), position(range.end)),
174
+ ]),
175
+ ),
176
+ ]),
177
+ ),
131
178
  });
132
179
  for (const message of child.meta.problems) errors.push({ line, column, message });
133
180
  }
134
181
  };
182
+ let row;
135
183
  for (const token of tokens) {
136
- if (token.type !== "inline" || !token.map) continue;
184
+ if (token.type === "tr_open" && token.map) {
185
+ const line = lines[token.map[0]],
186
+ offsets = [];
187
+ let content = "";
188
+ // The existing table rule removes a backslash immediately before
189
+ // a pipe. Retain that projection's original character positions;
190
+ // actual row/cell tokens still own all table parsing and trimming.
191
+ for (let i = 0; i < line.length; i++) {
192
+ if (line[i] === "\\" && line[i + 1] === "|") continue;
193
+ offsets.push(i);
194
+ content += line[i];
195
+ }
196
+ offsets.push(line.length);
197
+ row = { content, offsets, line: token.map[0], cursor: 0 };
198
+ } else if (token.type === "tr_close") row = undefined;
199
+ if (token.type !== "inline") continue;
200
+ if (!token.map) {
201
+ if (!row) continue;
202
+ const column = row.content.indexOf(token.content, row.cursor);
203
+ if (column < 0) continue;
204
+ row.cursor = column + token.content.length;
205
+ collect(token.children, (pos) => starts[row.line] + row.offsets[column + pos]);
206
+ continue;
207
+ }
137
208
  const contentLines = token.content.split("\n"),
138
209
  positions = [];
139
210
  let local = 0;
@@ -159,7 +230,15 @@ export function scanSpans(source) {
159
230
  }
160
231
  spans.sort((a, b) => a.start - b.start);
161
232
  errors.sort((a, b) => a.line - b.line || a.column - b.column);
162
- return { spans, errors };
233
+ return {
234
+ spans,
235
+ names: spans.filter((span) =>
236
+ span.attrs.some(
237
+ ([key, value]) => key === "class" && value.split(/\s+/).includes("name"),
238
+ ),
239
+ ),
240
+ errors,
241
+ };
163
242
  }
164
243
 
165
244
  /** Emit span tags while leaving their inline content as Markdown for Hugo.
@@ -182,7 +261,7 @@ export function renderSpans(source) {
182
261
  const attrs = span.attrs.map(([key, value]) => ` ${key}="${escape(value)}"`).join("");
183
262
  markdown =
184
263
  markdown.slice(0, span.start) +
185
- `<span${attrs}>${renderSpans(span.text).markdown}</span>` +
264
+ `<span${attrs}>${renderSpans(markdown.slice(span.start + 1, span.contentEnd)).markdown}</span>` +
186
265
  markdown.slice(span.end);
187
266
  }
188
267
  return { markdown, errors };
@@ -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,13 @@ 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
+ syntax: sqlTables?.syntax,
158
+ }),
152
159
  errors,
153
160
  warnings,
154
- lineMap,
155
- ...(generatedFrom === undefined ? {} : { generatedFrom }),
156
161
  };
157
162
  }
@@ -43,9 +43,10 @@
43
43
 
44
44
  import fs from "node:fs";
45
45
  import path from "node:path";
46
+ import { capturedSources } from "./content-sources.mjs";
46
47
 
47
48
  import { matchAllOutsideCode } from "./code-fences.mjs";
48
- import { positionInBody } from "./diagnostics.mjs";
49
+ import { positionInBody, splitFrontmatter } from "./diagnostics.mjs";
49
50
 
50
51
  /**
51
52
  * A list item's marker, immediately followed by `[ ]` or `[x]` and a space —
@@ -114,15 +115,30 @@ export function checkTaskLists(body, file, { bodyLine = 1, bodyColumn = 1 } = {}
114
115
  * @param {object} [opts]
115
116
  * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore
116
117
  * in addition to the dot-directories always skipped.
118
+ * @param {Map<string, {raw: string}>} [opts.sources] - Exact captured source strings; omitted, walks and reads the tree.
117
119
  * @returns {{findings: Array<{file: string, line: number, column: number,
118
120
  * severity: "warning", message: string}>, files: number}} The findings, and
119
121
  * how many files were read.
120
122
  */
121
- export function lintContentTaskLists(contentBase, { skipDirectories = [] } = {}) {
123
+ export function lintContentTaskLists(contentBase, { skipDirectories = [], sources } = {}) {
122
124
  const skip = new Set(skipDirectories);
123
125
  /** @type {Array<{file: string, line: number, column: number, severity: "warning", message: string}>} */
124
126
  const findings = [];
125
127
  let files = 0;
128
+ if (sources) {
129
+ for (const [full, content] of capturedSources(
130
+ contentBase,
131
+ sources,
132
+ skipDirectories,
133
+ new Set([".md", ".markdown"]),
134
+ )) {
135
+ files++;
136
+ findings.push(
137
+ ...checkTaskLists(...bodyOf(content, path.relative(process.cwd(), full))),
138
+ );
139
+ }
140
+ return { findings, files };
141
+ }
126
142
 
127
143
  /** @param {string} dir - Directory to descend into. */
128
144
  const walk = (dir) => {
@@ -171,10 +187,10 @@ export function lintContentTaskLists(contentBase, { skipDirectories = [] } = {})
171
187
  * arguments {@link checkTaskLists} takes.
172
188
  */
173
189
  function bodyOf(content, file) {
174
- const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
190
+ const match = splitFrontmatter(content);
175
191
  if (!match) return [content, file, { bodyLine: 1, bodyColumn: 1 }];
176
192
 
177
- const raw = match[2];
193
+ const raw = match.body;
178
194
  const body = raw.trim();
179
195
  const bodyStart = content.length - raw.length + (raw.length - raw.trimStart().length);
180
196
  const before = content.slice(0, bodyStart);
@@ -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
 
@@ -46,6 +46,17 @@ import fs from "node:fs";
46
46
  import path from "node:path";
47
47
  import YAML, { LineCounter } from "yaml";
48
48
 
49
+ /**
50
+ * Split opening YAML frontmatter from its untrimmed body without parsing it.
51
+ * LF and CRLF delimiters retain the exact source strings and their offsets.
52
+ * @param {string} raw - Complete Markdown source.
53
+ * @returns {{yaml: string, body: string}|null} Source slices, or no fence.
54
+ */
55
+ export function splitFrontmatter(raw) {
56
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n)?([\s\S]*)$/);
57
+ return match ? { yaml: match[1], body: match[2] } : null;
58
+ }
59
+
49
60
  /**
50
61
  * The `file:line:column` locator, with whatever is known.
51
62
  *
@@ -201,9 +212,9 @@ export function positionInBody(body, offset, { bodyLine = 1, bodyColumn = 1, lin
201
212
  */
202
213
  export function positionInFrontmatter(raw, key, value = undefined, { topLevel = false } = {}) {
203
214
  if (typeof raw !== "string" || !key) return {};
204
- const fence = raw.match(/^---\n([\s\S]*?)\n---/);
215
+ const fence = splitFrontmatter(raw);
205
216
  if (!fence) return {};
206
- const block = fence[1];
217
+ const block = fence.yaml;
207
218
  const lines = block.split("\n");
208
219
 
209
220
  // A value locates the exact entry; the key alone locates its declaration.
@@ -437,10 +448,10 @@ export function yamlKeyPath(field) {
437
448
  export function frontmatterValueAt(raw, keyPath) {
438
449
  if (typeof raw !== "string" || !Array.isArray(keyPath) || keyPath.length === 0)
439
450
  return undefined;
440
- const fence = raw.match(/^---\n([\s\S]*?)\n---/);
451
+ const fence = splitFrontmatter(raw);
441
452
  if (!fence) return undefined;
442
453
  try {
443
- const node = YAML.parseDocument(fence[1]).getIn(keyPath, true);
454
+ const node = YAML.parseDocument(fence.yaml).getIn(keyPath, true);
444
455
  const value = node?.value;
445
456
  return ["string", "number", "boolean"].includes(typeof value) ? value : undefined;
446
457
  } catch {
@@ -473,9 +484,9 @@ export function frontmatterValueAt(raw, keyPath) {
473
484
  */
474
485
  export function positionOfFrontmatterPath(raw, keyPath, { key = false } = {}) {
475
486
  if (typeof raw !== "string") return {};
476
- const fence = raw.match(/^---\n([\s\S]*?)\n---/);
487
+ const fence = splitFrontmatter(raw);
477
488
  if (!fence) return {};
478
- const position = positionOfYamlPath(fence[1], keyPath, { key });
489
+ const position = positionOfYamlPath(fence.yaml, keyPath, { key });
479
490
  // +1: the fence's line 1 is the file's line 2, the opening `---` being the
480
491
  // first. `positionOfYamlPath` already counts from 1 within the block.
481
492
  return position.line === undefined ? {} : { ...position, line: position.line + 1 };