@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
@@ -160,6 +160,59 @@ export function checkLanguageData(note, { index, types } = {}) {
160
160
 
161
161
  const findings = [];
162
162
 
163
+ // Only the shape of a supplied declaration: no word is compared with it.
164
+ const grammar = isMap(data.grammar) ? data.grammar : {};
165
+ listOf(grammar.plural).forEach((row, i) => {
166
+ if (row == null || (Array.isArray(row) && row.length === 0))
167
+ findings.push(
168
+ finding(
169
+ ["data", "grammar", "plural", i],
170
+ "error",
171
+ `\`data.grammar.plural[${i}]\` must be a map with ending and becomes strings`,
172
+ ),
173
+ );
174
+ });
175
+ const verbs = grammar.verbs;
176
+ if (Array.isArray(verbs) && verbs.length === 0)
177
+ findings.push(
178
+ finding(
179
+ ["data", "grammar", "verbs"],
180
+ "error",
181
+ "`data.grammar.verbs` must state `invariant: true` or the six `persons` strings",
182
+ ),
183
+ );
184
+ if (isMap(verbs)) {
185
+ const invariant = Object.hasOwn(verbs, "invariant");
186
+ const persons = Object.hasOwn(verbs, "persons");
187
+ if (invariant === persons)
188
+ findings.push(
189
+ finding(
190
+ ["data", "grammar", "verbs"],
191
+ "error",
192
+ "`data.grammar.verbs` must state exactly one of `invariant: true` or `persons`",
193
+ ),
194
+ );
195
+ else if (invariant && (verbs.invariant == null || verbs.invariant === false))
196
+ findings.push(
197
+ finding(
198
+ ["data", "grammar", "verbs", "invariant"],
199
+ "error",
200
+ "`data.grammar.verbs.invariant` must be true",
201
+ ),
202
+ );
203
+ else if (
204
+ persons &&
205
+ (verbs.persons == null || (Array.isArray(verbs.persons) && verbs.persons.length === 0))
206
+ )
207
+ findings.push(
208
+ finding(
209
+ ["data", "grammar", "verbs", "persons"],
210
+ "error",
211
+ "`data.grammar.verbs.persons` must be a map of all six ending strings",
212
+ ),
213
+ );
214
+ }
215
+
163
216
  /**
164
217
  * The note an Address names, or a finding where it names none.
165
218
  *
@@ -43,6 +43,7 @@ export const LANGUAGE_DATA_KEYS = Object.freeze([
43
43
  "older",
44
44
  "negation",
45
45
  "naming",
46
+ "grammar",
46
47
  ]);
47
48
 
48
49
  /**
@@ -1594,6 +1595,81 @@ function listOfMaps(name, fields, describe) {
1594
1595
  });
1595
1596
  }
1596
1597
 
1598
+ /**
1599
+ * The six present persons of the one optional regular verb pattern, in
1600
+ * presentation order. Empty endings leave the citation form unchanged.
1601
+ *
1602
+ * @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
1603
+ */
1604
+ export const VERB_PERSON_FIELDS = Object.freeze(
1605
+ [
1606
+ ["I", "I"],
1607
+ ["you", "you"],
1608
+ ["heShe", "he/she"],
1609
+ ["we", "we"],
1610
+ ["youAll", "you all"],
1611
+ ["they", "they"],
1612
+ ].map(([name, label]) =>
1613
+ Object.freeze({
1614
+ name,
1615
+ label,
1616
+ kind: "string",
1617
+ required: true,
1618
+ allowEmpty: true,
1619
+ shape: "an ending string, empty for unchanged",
1620
+ describe: `The ending added to the citation form for ${label}.`,
1621
+ }),
1622
+ ),
1623
+ );
1624
+
1625
+ /** The two alternatives of a supplied optional verb pattern. */
1626
+ const VERB_PATTERN_FIELDS = Object.freeze([
1627
+ Object.freeze({
1628
+ name: "invariant",
1629
+ kind: "boolean",
1630
+ shape: "true, as the alternative to persons",
1631
+ describe: "True where verbs keep the citation form for every person; excludes `persons`.",
1632
+ }),
1633
+ Object.freeze({
1634
+ name: "persons",
1635
+ kind: "map",
1636
+ shape: "a map — `{ I, you, heShe, we, youAll, they }` of ending strings",
1637
+ fields: VERB_PERSON_FIELDS,
1638
+ describe: "All six present endings, each added to the citation form; excludes `invariant`.",
1639
+ }),
1640
+ ]);
1641
+
1642
+ /** The optional, display-only noun and verb patterns of one language. */
1643
+ const GRAMMAR_FIELDS = Object.freeze([
1644
+ innerList(
1645
+ "plural",
1646
+ Object.freeze(
1647
+ ["ending", "becomes"].map((name) =>
1648
+ Object.freeze({
1649
+ name,
1650
+ kind: "string",
1651
+ required: true,
1652
+ allowEmpty: true,
1653
+ shape: "a literal ending string, which may be empty",
1654
+ describe:
1655
+ name === "ending" ?
1656
+ "The bare suffix replaced by this rule; empty matches every noun."
1657
+ : "The bare replacement; empty removes the matching suffix.",
1658
+ }),
1659
+ ),
1660
+ ),
1661
+ "Default noun plurals, replacing the first matching literal ending in authored order. " +
1662
+ "A word's explicit plural wins; no word is checked against this rule.",
1663
+ ),
1664
+ Object.freeze({
1665
+ name: "verbs",
1666
+ kind: "map",
1667
+ shape: "a map — `{ invariant: true }` or `{ persons: { I, you, heShe, we, youAll, they } }`",
1668
+ fields: VERB_PATTERN_FIELDS,
1669
+ describe: "One optional regular present pattern, printed once at the dictionary head.",
1670
+ }),
1671
+ ]);
1672
+
1597
1673
  /**
1598
1674
  * The `data` fields this module declares, in the order the vocabulary lists
1599
1675
  * them. {@link module:engine/language-data.checkLanguageData} checks what the
@@ -1602,6 +1678,16 @@ function listOfMaps(name, fields, describe) {
1602
1678
  * @type {readonly object[]}
1603
1679
  */
1604
1680
  export const LANGUAGE_DATA_FIELDS = Object.freeze([
1681
+ Object.freeze({
1682
+ name: "grammar",
1683
+ kind: "map",
1684
+ shape: "a map — `{ plural?, verbs? }`",
1685
+ fields: GRAMMAR_FIELDS,
1686
+ describe:
1687
+ "Optional noun plurals and one verb pattern for dictionary presentation. Only the " +
1688
+ "shape of supplied keys is checked; no word must follow or state a rule. A language " +
1689
+ "skill note only.",
1690
+ }),
1605
1691
  listOfMaps(
1606
1692
  "tongues",
1607
1693
  TONGUE_FIELDS,
@@ -36,7 +36,7 @@
36
36
  * an ancestor's;
37
37
  * - every source language resolves, and names a language skill;
38
38
  * - a pronunciation is a respelling;
39
- * - a `built` piece no word or affix of the language glosses is a warning.
39
+ * - a `built` piece no word, affix or element of the language glosses is a warning.
40
40
  *
41
41
  * {@link checkLanguageFamily} owns `data.family`, the parent language a
42
42
  * daughter reads words from: it is stated only on a language, names a
@@ -285,7 +285,7 @@ export function checkLanguageWords(note, { index } = {}) {
285
285
  finding(
286
286
  [...at("built"), j],
287
287
  "warning",
288
- `the piece \`${piece}\` of \`${word.form}\` matches no word or affix of this ` +
288
+ `the piece \`${piece}\` of \`${word.form}\` matches no word, affix or element of this ` +
289
289
  "language or its ancestors, so it prints with no gloss",
290
290
  ),
291
291
  );
@@ -0,0 +1,247 @@
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
+ * Automatic dictionaries and glossaries, derived synchronously from the same
16
+ * rows their SQL layouts read. An existing anchored section receives its list
17
+ * after its authored text; otherwise the note receives a new final section.
18
+ * The insertion and the publisher read the same Markdown, on every surface.
19
+ *
20
+ * @module
21
+ */
22
+
23
+ import { renderAddress } from "./address.mjs";
24
+ import { encodeAddresses } from "./address-values.mjs";
25
+ import { scanBlocks } from "./content-blocks.mjs";
26
+ import { renderDictionary, renderGlossary } from "./dictionary-layout.mjs";
27
+ import { DICTIONARY_COLUMNS, TERM_COLUMNS, dictionaryAndTermRows } from "./dictionary-rows.mjs";
28
+ import { glossaryLanguage } from "./glossary-language.mjs";
29
+ import { scanHeadingAttributes } from "./heading-attributes.mjs";
30
+ import { isNoteRecord } from "./index-records.mjs";
31
+
32
+ /** The automatic lexical sections and the authored anchors each may reuse. */
33
+ export const LEXICAL_SECTIONS = Object.freeze(
34
+ [
35
+ {
36
+ slug: "dictionary",
37
+ heading: "Dictionary",
38
+ aliases: ["dictionary", "lexicon"],
39
+ dataKey: "words",
40
+ types: [{ type: "skill", subType: "language" }],
41
+ },
42
+ {
43
+ slug: "glossary",
44
+ heading: "Glossary",
45
+ aliases: ["glossary"],
46
+ dataKey: "glossary",
47
+ types: [{ type: "lore", subType: "culture" }, { type: "affiliation" }],
48
+ },
49
+ ].map((section) =>
50
+ Object.freeze({
51
+ ...section,
52
+ aliases: Object.freeze(section.aliases),
53
+ types: Object.freeze(section.types.map((kind) => Object.freeze(kind))),
54
+ }),
55
+ ),
56
+ );
57
+
58
+ /** The lexical section this note's own nonempty frontmatter requests. */
59
+ function sectionFor(record) {
60
+ return LEXICAL_SECTIONS.find(
61
+ (section) =>
62
+ section.types.some(
63
+ (kind) =>
64
+ record?.type === kind.type &&
65
+ (!kind.subType || record.subType === kind.subType),
66
+ ) &&
67
+ Array.isArray(record.data?.[section.dataKey]) &&
68
+ record.data[section.dataKey].length > 0,
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Whether frontmatter supplies an automatic lexical section, including on a
74
+ * note with no authored prose. Such a note has content and is not a stub.
75
+ *
76
+ * @param {object|null|undefined} frontmatter - A note or its index record.
77
+ * @returns {boolean} Whether its own nonempty words or glossary qualify.
78
+ */
79
+ export function hasLexicalEntries(frontmatter) {
80
+ return Boolean(sectionFor(frontmatter));
81
+ }
82
+
83
+ /** A note's canonical identity, including a data-only note that was a stub. */
84
+ const keyOf = (record) =>
85
+ record?.package && record.type && record.shortcode ?
86
+ renderAddress({
87
+ package: record.package,
88
+ system: "note",
89
+ type: record.type,
90
+ shortcode: record.shortcode,
91
+ })
92
+ : undefined;
93
+
94
+ /**
95
+ * One automatic section, whose rows are rendered at its containing heading's
96
+ * level when inserted.
97
+ *
98
+ * @typedef {object} LexicalSection
99
+ * @property {string} slug - The canonical section anchor and layout name.
100
+ * @property {string} heading - The heading used when none is authored.
101
+ * @property {readonly string[]} aliases - Authored heading anchors to reuse.
102
+ * @property {{columns: string[], rows: object[]}} result - The selected rows.
103
+ * @property {import("./dictionary-layout.mjs").LayoutOptions} options - Labels,
104
+ * linkability and the page's language, shared by every surface.
105
+ */
106
+
107
+ /**
108
+ * Automatic lexical sections by owning note's canonical Address. Only
109
+ * nonempty own `words` or `glossary` arrays qualify. A glossary selects only
110
+ * explicitly declared owner entries, including a rank only when named there.
111
+ *
112
+ * @param {object[]} records - The audience's visible index records.
113
+ * @param {object[]} [all=records] - Complete records for lineage traversal.
114
+ * @returns {Map<string, LexicalSection[]>} Sections by owning Address.
115
+ */
116
+ export function lexicalSectionsFor(records, all = records) {
117
+ const visible = encodeAddresses(records).filter(isNoteRecord);
118
+ const complete = encodeAddresses(all).filter(isNoteRecord);
119
+ const byKey = new Map(visible.map((record) => [keyOf(record), record]));
120
+ const addressContext = {
121
+ types: new Set(complete.map((record) => record.type)),
122
+ packages: new Set(complete.map((record) => record.package)),
123
+ system: "note",
124
+ };
125
+ const { dictionary, terms } = dictionaryAndTermRows(visible, complete);
126
+ const sections = new Map();
127
+ for (const [key, record] of byKey) {
128
+ if (!key) continue;
129
+ const spec = sectionFor(record);
130
+ if (!spec) continue;
131
+ const language = spec.slug === "dictionary";
132
+ const rows =
133
+ language ?
134
+ dictionary.filter((row) => row.language === key)
135
+ : terms.filter((row) => row.owner === key && row.origins.includes("glossary"));
136
+ const pageLanguage = glossaryLanguage(undefined, record, {
137
+ self: key,
138
+ keyOf: (value) => (typeof value === "string" ? value : undefined),
139
+ lookup: (address) => byKey.get(address),
140
+ });
141
+ sections.set(key, [
142
+ {
143
+ slug: spec.slug,
144
+ heading: spec.heading,
145
+ aliases: spec.aliases,
146
+ result: { columns: [...(language ? DICTIONARY_COLUMNS : TERM_COLUMNS)], rows },
147
+ options: {
148
+ addressContext: { ...addressContext, package: record.package },
149
+ pageLanguage,
150
+ pageOwner: key,
151
+ ...(language ? { verbs: record.data?.grammar?.verbs } : {}),
152
+ labelOf: (address) => byKey.get(address)?.name?.full,
153
+ linkable: (ref) => {
154
+ const target = byKey.get(renderAddress({ ...ref, system: "note" }));
155
+ return Boolean(target && (target.address || hasLexicalEntries(target)));
156
+ },
157
+ },
158
+ },
159
+ ]);
160
+ }
161
+ return sections;
162
+ }
163
+
164
+ /** Authored section headings outside literal content and named containers. */
165
+ function headings(markdown) {
166
+ const containers = scanBlocks(markdown).blocks;
167
+ return scanHeadingAttributes(markdown)
168
+ .headings.filter(
169
+ (heading) =>
170
+ !containers.some(
171
+ (block) => heading.line - 1 >= block.start && heading.line - 1 <= block.end,
172
+ ),
173
+ )
174
+ .map((heading) => ({ line: heading.line - 1, level: heading.level, slug: heading.id }));
175
+ }
176
+
177
+ /**
178
+ * Insert lists at the end of their authored anchored sections, or append a
179
+ * new H1 section when no heading names one. Source lines after an insertion
180
+ * retain their original positions; inserted lines point to their heading.
181
+ *
182
+ * @param {string} markdown - The body with authored directives expanded.
183
+ * @param {LexicalSection[]} [sections=[]] - From {@link lexicalSectionsFor}.
184
+ * @param {{authored?: string, lineMap?: object[], generatedFrom?: number}} [context] -
185
+ * The original authored body, source mappings and the start of appended
186
+ * nonlexical generated sections. Without `authored`, `markdown` is the source.
187
+ * @returns {{markdown: string, lineMap: object[], generatedFrom?: number}}
188
+ */
189
+ export function insertLexicalSections(
190
+ markdown,
191
+ sections = [],
192
+ { authored = markdown, lineMap, generatedFrom } = {},
193
+ ) {
194
+ const lines = String(markdown ?? "").split("\n");
195
+ const locations = lineMap ? [...lineMap] : lines.map((_, line) => ({ line, generated: false }));
196
+ if (!sections.length)
197
+ return {
198
+ markdown: lines.join("\n"),
199
+ lineMap: locations,
200
+ ...(generatedFrom === undefined ? {} : { generatedFrom }),
201
+ };
202
+ const edits = [];
203
+ const realHeadings = headings(String(authored ?? ""))
204
+ .map((heading) => ({
205
+ ...heading,
206
+ sourceLine: heading.line,
207
+ line: locations.findIndex(
208
+ (location, at) =>
209
+ location.line === heading.line &&
210
+ !location.generated &&
211
+ (generatedFrom === undefined || at < generatedFrom),
212
+ ),
213
+ }))
214
+ .filter((heading) => heading.line >= 0);
215
+ for (const section of sections) {
216
+ const own = realHeadings.find((heading) => section.aliases.includes(heading.slug));
217
+ const next =
218
+ own &&
219
+ realHeadings.find((heading) => heading.line > own.line && heading.level <= own.level);
220
+ const at = next?.line ?? (own ? (generatedFrom ?? lines.length) : lines.length);
221
+ const level = own?.level ?? 1;
222
+ const render = section.slug === "dictionary" ? renderDictionary : renderGlossary;
223
+ const list = render(section.result, {
224
+ ...section.options,
225
+ sectionLevel: Math.min(6, level + 1),
226
+ });
227
+ if (!list && own) continue;
228
+ const text = `${own ? "" : `# ${section.heading} {#${section.slug}}\n\n`}${list}`;
229
+ edits.push({
230
+ at,
231
+ source: own?.sourceLine ?? Math.max(0, String(authored ?? "").split("\n").length - 1),
232
+ lines: ["", ...text.split("\n"), ""],
233
+ });
234
+ }
235
+ for (const edit of edits.sort((a, b) => b.at - a.at)) {
236
+ const location = { line: edit.source, generated: true };
237
+ lines.splice(edit.at, 0, ...edit.lines);
238
+ locations.splice(edit.at, 0, ...edit.lines.map(() => ({ ...location })));
239
+ if (generatedFrom !== undefined && edit.at <= generatedFrom)
240
+ generatedFrom += edit.lines.length;
241
+ }
242
+ return {
243
+ markdown: lines.join("\n"),
244
+ lineMap: locations,
245
+ ...(generatedFrom === undefined ? {} : { generatedFrom }),
246
+ };
247
+ }
@@ -105,6 +105,7 @@ import { HOMEPAGE_TYPE } from "./homepage.mjs";
105
105
  import { FOLDER_TYPE } from "./folder-notes.mjs";
106
106
  import { BUNDLE_TYPE } from "./bundle-notes.mjs";
107
107
  import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
108
+ import { hasLexicalEntries } from "./lexical-sections.mjs";
108
109
 
109
110
  /**
110
111
  * Note types that compile into **no compendium document, by design**.
@@ -769,6 +770,7 @@ export function unclaimedNoteFindings(
769
770
  // claiming this note", because the answer there is yes.
770
771
  const produces = documentClassesFor(type, resolved, {
771
772
  hasProse: () => {
773
+ if (hasLexicalEntries(fm)) return true;
772
774
  const source = parsed?.get(absPath);
773
775
  return source ? Boolean(source.body) : noteHasProse(absPath);
774
776
  },
@@ -14,7 +14,7 @@
14
14
  /**
15
15
  * The three states of a note, and the one observable fact they rest on.
16
16
  *
17
- * A note whose body is empty is a **stub**: frontmatter only — identity and
17
+ * A note whose body is empty and has no automatic lexical content is a **stub**: frontmatter only — identity and
18
18
  * statistics — reaching the content index and queryable there, generating no
19
19
  * page, no address and no link target. A note with a body and the declared
20
20
  * `draft` tag is a **draft**: it publishes, compiles and resolves like any
@@ -39,6 +39,7 @@
39
39
  */
40
40
 
41
41
  import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
42
+ import { hasLexicalEntries } from "./lexical-sections.mjs";
42
43
 
43
44
  /**
44
45
  * The three states, in the order they read as a ladder.
@@ -97,7 +98,8 @@ export function isStubbableType(type, vocabulary = NOTE_VOCABULARY) {
97
98
  }
98
99
 
99
100
  /**
100
- * Whether a note is a stub: an empty body on a type an empty body suppresses.
101
+ * Whether a note is a stub: an empty body on a type an empty body suppresses,
102
+ * without an automatic dictionary or glossary supplied by its frontmatter.
101
103
  *
102
104
  * The one place the two halves are put together, so the index, the lint and the
103
105
  * guard cannot answer it differently.
@@ -109,7 +111,11 @@ export function isStubbableType(type, vocabulary = NOTE_VOCABULARY) {
109
111
  * @returns {boolean} Whether the note is a stub.
110
112
  */
111
113
  export function isStubNote(frontmatter, body, vocabulary = NOTE_VOCABULARY) {
112
- return isEmptyBody(body) && isStubbableType(frontmatter?.type, vocabulary);
114
+ return (
115
+ isEmptyBody(body) &&
116
+ !hasLexicalEntries(frontmatter) &&
117
+ isStubbableType(frontmatter?.type, vocabulary)
118
+ );
113
119
  }
114
120
 
115
121
  /**
@@ -600,7 +600,7 @@ const WORD_DATA_FIELDS = Object.freeze([
600
600
  fields: WORD_FIELDS,
601
601
  }),
602
602
  describe:
603
- "The language's words, one map each, in the order they print. A language skill " +
603
+ "The words this language states, one map each, in authored order; its automatic dictionary sorts them alphabetically. A language skill " +
604
604
  "note only. See [a language's words](format-details.md#a-languages-words).",
605
605
  }),
606
606
  Object.freeze({
@@ -671,9 +671,10 @@ const GLOSSARY_FIELD = Object.freeze({
671
671
  fields: GLOSSARY_ENTRY_FIELDS,
672
672
  }),
673
673
  describe:
674
- "Its own glossary terms: euphemisms, idioms, terms of art and titles that are not " +
675
- "ranks. An entry with a `definition` creates a term or replaces its senses; one " +
676
- "without marks a term the note already has. Every entry resolves to a language. " +
674
+ "Its own glossary entries: common vocabulary, dictionary words with a contextual " +
675
+ "meaning, and phrases, compounds or terms of art. A named dictionary word supplies " +
676
+ "its senses and class unless `definition` supplies the sense. Common marks belong " +
677
+ "to this owner. Every entry resolves to a language. " +
677
678
  "Empty when unstated. See [glossary terms](format-details.md#glossary-terms).",
678
679
  });
679
680
 
@@ -1820,8 +1821,8 @@ export const NOTE_VOCABULARY = Object.freeze({
1820
1821
  ...LANGUAGE_FIELD,
1821
1822
  describe:
1822
1823
  "The trade, temple or order tongue the affiliation keeps, as a language " +
1823
- "skill note. It reaches the affiliation's culture, and its words enter that " +
1824
- "culture's glossary. A glossary entry that states no `language` is in it.",
1824
+ "skill note. A glossary entry that states no `language` is in it. The " +
1825
+ "affiliation chooses its own common vocabulary and context meanings.",
1825
1826
  },
1826
1827
  GLOSSARY_FIELD,
1827
1828
  {
@@ -1013,7 +1013,15 @@ async function prepareWorld(resolved, { contentBase, findings, resolveReplacemen
1013
1013
  scheme,
1014
1014
  };
1015
1015
  const collected = collectContentPages(contentBase, ctx);
1016
- const gates = siteGates(collected.pages, collected, { config: resolved, records });
1016
+ const sqlTables = await prepareTreeSqlTables(contentBase, {
1017
+ config: resolved,
1018
+ skipDirectories: resolved.skipDirectories,
1019
+ records,
1020
+ parsed,
1021
+ database: db,
1022
+ audience: "public",
1023
+ });
1024
+ const gates = siteGates(collected.pages, collected, { config: resolved, records, sqlTables });
1017
1025
  if (gatesFailed(gates)) {
1018
1026
  for (const [name, list] of Object.entries(gates)) {
1019
1027
  if (!Array.isArray(list)) continue;
@@ -1030,14 +1038,6 @@ async function prepareWorld(resolved, { contentBase, findings, resolveReplacemen
1030
1038
 
1031
1039
  const byFile = new Map(collected.pages.map((page) => [page.file, page]));
1032
1040
  const universe = tableUniverse(collected.pages);
1033
- const sqlTables = await prepareTreeSqlTables(contentBase, {
1034
- config: resolved,
1035
- skipDirectories: resolved.skipDirectories,
1036
- records,
1037
- parsed,
1038
- database: db,
1039
- audience: "public",
1040
- });
1041
1041
  // The address space an `![[…]]` embed resolves against — the same one the
1042
1042
  // site builds, so one authored picture reaches both surfaces or neither.
1043
1043
  const assets = assetAddressIndex(records, {
@@ -430,9 +430,11 @@ export function writeHomepages(outRoot, pages, config, { related, records, forei
430
430
  * @param {object} options
431
431
  * @param {object} options.config - The resolved build configuration.
432
432
  * @param {object[]} options.records - Content-index records.
433
+ * @param {Map<string, object[]>} [options.sqlTables] - Prepared directives,
434
+ * whose emitted anchors belong to the pages they expand in.
433
435
  * @returns {object} The gate results and, when they pass, the built index.
434
436
  */
435
- export function siteGates(pages, findings, { config, records }) {
437
+ export function siteGates(pages, findings, { config, records, sqlTables }) {
436
438
  const out = {
437
439
  // Always empty here: the homepage count is decided in `buildSite`
438
440
  // before the content walk, and a failing count returns without ever
@@ -464,7 +466,18 @@ export function siteGates(pages, findings, { config, records }) {
464
466
  out.unaddressable = unaddressableForeignPackages(foreign.index);
465
467
  if (out.unaddressable.length) return out;
466
468
 
467
- out.index = buildSiteIndex(pages, {
469
+ const expanded =
470
+ sqlTables ?
471
+ pages.map((page) => ({
472
+ ...page,
473
+ body: expandContentTables(page.body ?? "", {
474
+ source: page.file,
475
+ sqlTables: sqlTables.get(page.file),
476
+ pageLists: sqlTables.pageLists?.get(page.file),
477
+ }).markdown,
478
+ }))
479
+ : pages;
480
+ out.index = buildSiteIndex(expanded, {
468
481
  package: config.contentPackage,
469
482
  foreignIndex: foreign.index,
470
483
  foreignReferences: foreign.references,
@@ -1367,6 +1380,7 @@ export function buildSite({ config, sqlTables } = {}) {
1367
1380
  const gates = siteGates([...pages, ...homepageEntries], content, {
1368
1381
  config: resolved,
1369
1382
  records: ctx.records,
1383
+ sqlTables,
1370
1384
  });
1371
1385
  if (gatesFailed(gates)) {
1372
1386
  return {