@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 23.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ **Dictionaries and glossaries**
8
+
9
+ - Language dictionaries and declared culture or affiliation glossaries appear automatically in journals, sites and books; remove SQL fences for these defaults.
10
+ - Dictionaries use one full alphabetical list with field tags and one key; existing anchored Dictionary, Lexicon and Glossary headings keep their introductions and receive the lists.
11
+ - Language notes with nonempty `data.words`, and cultures or affiliations with nonempty `data.glossary`, need no prose body; glossary sections show only their owner's declared entries.
12
+ - Glossary entries naming dictionary words show their class and word link, with dictionary senses or the entry's context definition; sole-owner sense labels are omitted on that owner's page, while other attribution remains.
13
+ - Common marks belong only to glossary terms; select or filter `terms.common`, because queries using `dictionary.common` fail.
14
+ - Custom culture views select the culture's own entries and reached ranks or offices, applying common marks only to entries the culture owns.
15
+ - Glossary entries without definitions must name a word in their resolved language or a reachable rank or office; specify the correct language or supply a definition when a name cannot resolve.
16
+
17
+ **Links and publishing**
18
+
19
+ - Correct links to headings inside HTML comments or raw HTML, which publish no section; builds also refuse section links absent from a dependency's published notes, including pages without prose anchors or journal documents.
20
+ - Rebuild and fetch package indexes so dependent packages can link to generated sections and SQL-expression anchors; readable item documentation anchors target their documentation journals.
21
+ - Engine publishers with dynamic content must await `prepareContentIndex` and pass its publication to `emitContentIndex` before writing metadata.
22
+
23
+ ### Patch Changes
24
+
25
+ - Language dictionaries can show optional noun plurals and one verb pattern beside existing gender labels; languages without these details retain their output.
26
+ - Dictionary fragments without a key omit the verb table; key-only fragments print abbreviations alone.
27
+ - Built words show element senses in dictionary etymologies and pass the built-piece check, including declared variants, inherited elements and front-vowel affix forms.
28
+ - Public dictionaries omit GM-only citations, preventing false link errors from hidden notes.
29
+
3
30
  ## 23.8.0
4
31
 
5
32
  ### Minor Changes
@@ -105,14 +105,19 @@ import { markupFindings } from "../engine/markup-validation.mjs";
105
105
  import { checkFormatting, checkPrettierConventions, lintMarkdown } from "../engine/prose-lint.mjs";
106
106
  import { lintProse, scoreProseTree } from "../engine/readability-lint.mjs";
107
107
  import { loadPackageBuildConfig } from "../config.mjs";
108
- import { emitContentIndex, indexRecordsFor, isAssetRecord } from "../engine/content-index.mjs";
108
+ import {
109
+ emitContentIndex,
110
+ prepareContentIndex,
111
+ indexRecordsFor,
112
+ isAssetRecord,
113
+ } from "../engine/content-index.mjs";
109
114
  import {
110
115
  buildSite,
111
116
  gatesFailed,
112
117
  formatUnaddressableFinding as formatUnaddressable,
113
118
  } from "../engine/site-build.mjs";
114
119
  import { auditLinks, buildLinkIndex, walkReachability } from "../engine/content-links.mjs";
115
- import { prepareTreeSqlTables } from "../engine/sql-tables.mjs";
120
+ import { prepareLinkSqlTables, prepareTreeSqlTables } from "../engine/sql-tables.mjs";
116
121
  // The one place a link finding is worded, shared with both builds.
117
122
  import { linkFindingMessage } from "../engine/wikilink-syntax.mjs";
118
123
  import {
@@ -1438,7 +1443,7 @@ function linksCommand() {
1438
1443
  config,
1439
1444
  records,
1440
1445
  ...scope,
1441
- sqlTables: await prepareTreeSqlTables(contentBase, {
1446
+ sqlTables: await prepareLinkSqlTables(contentBase, {
1442
1447
  config,
1443
1448
  records,
1444
1449
  parsed,
@@ -1616,12 +1621,15 @@ function contentIndexCommand() {
1616
1621
  type: "string",
1617
1622
  });
1618
1623
  },
1619
- handler: (argv) => {
1624
+ handler: async (argv) => {
1620
1625
  try {
1621
1626
  const config = loadPackConfig();
1627
+ const contentBase = argv.root ?? config.paths.content;
1628
+ const publication = await prepareContentIndex({ config, contentBase });
1622
1629
  const { file, notes, bytes, full, draft, stub, dictionary, terms } =
1623
1630
  emitContentIndex({
1624
1631
  config,
1632
+ publication,
1625
1633
  ...(argv.root ? { contentBase: argv.root } : {}),
1626
1634
  ...(argv.out ? { outDir: argv.out } : {}),
1627
1635
  });
package/docs/api.md CHANGED
@@ -426,7 +426,14 @@ Drawing a declared infobox: HTML for separate Foundry pages appended after autho
426
426
 
427
427
  ### `engine.noteState`
428
428
 
429
- The three states of a note — **stub**, **draft**, **full** — and the one observable fact they rest on. A note whose body is empty is a stub: it carries its facts into the index and is queryable there, and it has no page, no address and no link target. Nothing is stored: the ladder is derived once in the SQL `entries` view and no note can assert its own state.
429
+ The three states of a note — **stub**, **draft**, **full** — describe its page
430
+ content. A stubbable note with an empty body and no generated Dictionary or
431
+ Glossary content is a stub: its facts remain queryable in the index, but it
432
+ has no page, address or link target. `isStubNote` recognizes nonempty lexical
433
+ frontmatter as generated content, so collectors retain the resulting page.
434
+ That lexical frontmatter supplies content without an authored body.
435
+ The ladder is derived in the SQL `entries` view; a note cannot assert its own
436
+ state.
430
437
 
431
438
  | Export | Signature | Returns | Use it when |
432
439
  | --------------------- | ------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -458,7 +465,7 @@ What a draft note says about itself, in one sentence, on every surface it reache
458
465
 
459
466
  ### `engine.stubLint`
460
467
 
461
- Every typed note must have a nonempty body. Empty and whitespace-only bodies are errors regardless of description or `draft` tag, including structural types such as `folder` and `homepage`. Placeholder-only bodies also produce errors. Nonempty bodies with fewer than 25 prose words produce warnings unless tagged `draft`. A `folder` note is held only to the nonempty rule: any body, placeholder or short, passes with or without `draft`. Untyped vault scaffolding is excluded. The lint, content-index, package compile, site and PDF commands enforce body errors before publishing. The state classification helpers still describe incomplete records for inspection; their classifications do not exempt notes from validation.
468
+ Nonempty `data.words` on a `skill`/`language` note, or `data.glossary` on a culture or affiliation, supplies content without an authored body. Absent or empty arrays supply no such exemption. Every other typed note must have a nonempty body: empty and whitespace-only bodies are errors regardless of description or `draft` tag, including `folder` and `homepage`. Nonempty placeholder-only bodies still produce errors, including on lexical notes. Nonempty bodies with fewer than 25 prose words produce warnings unless tagged `draft`. A `folder` note is held only to the nonempty rule: any body, placeholder or short, passes with or without `draft`. Untyped vault scaffolding is excluded. The lint, content-index, package compile, site and PDF commands enforce body errors before publishing.
462
469
 
463
470
  | Export | Signature | Returns | Use it when |
464
471
  | ------------------ | ---------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------- |
@@ -861,32 +868,87 @@ The published content index — the artifact packages exchange addresses through
861
868
 
862
869
  Emitting this package's cross-package link manifest. `engine/content-address.mjs` owns the address _grammar_ — how a key is version is read, how a foreign file resolves. This module owns the _pass_: walking a content tree and deriving, for every note it publishes, the addresses that entry states. The two halves were split across the format module and a hand-written script in each consuming repository, which is how the two scripts came to differ in ways nobody chose — one routes its UUIDs through the pack router and one does not, and neither knew.
863
870
 
864
- | Export | Signature | Returns | Use it when |
865
- | ----------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
866
- | `LEAD_ANCHOR` | `const LEAD_ANCHOR` | — | The reserved anchor name for a journal's **first** page. |
867
- | `anchorsOf` | `function anchorsOf(entryUuid, entryId, body, name)` | {Record<string, string>} The anchors. | Every page of a note's journal, as `anchorName → whole UUID`. |
868
- | `entriesForNote` | `function entriesForNote(fm, name, address, body, ctx)` | {Array<object>} One or two entries, in {@link buildManifest}'s shape. | The manifest entries a single note produces. |
869
- | `collectFoundryEntries` | `function collectFoundryEntries(contentBase, ctx)` | `{{entries: Array<object>, notes: number, skipped: Array<{file: string, reason: string}>}}` | Every note this package publishes, as manifest entries. |
870
- | `foundryIdentities` | `function foundryIdentities(config` | `{{contentPackage: string, foundryPackageId: string, packRouter: object, web: boolean, skipDirectories: readonly string[]}}` | The identities an emission runs against, from configuration. |
871
- | `entryContext` | `function entryContext(config` | `{{contentPackage: string, foundryPackageId: string, packRouter: object, web: boolean, skipDirectories: readonly string[]}}` | The identities an emission runs against, from configuration. |
871
+ | Export | Signature | Returns | Use it when |
872
+ | ----------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
873
+ | `LEAD_ANCHOR` | `const LEAD_ANCHOR` | — | The reserved anchor name for a journal's **first** page. |
874
+ | `anchorsOf` | `function anchorsOf(entryUuid, entryId, body, name)` | {Record<string, string>} The anchors. | Every anchor of a note's journal, as `anchorName → Foundry target`. |
875
+ | `entriesForNote` | `function entriesForNote(fm, name, address, body, ctx)` | {Array<object>} One or two entries, in {@link buildManifest}'s shape. | The manifest entries a single note produces. |
876
+ | `collectFoundryEntries` | `function collectFoundryEntries(contentBase, ctx)` | `{{entries: Array<object>, notes: number, skipped: Array<{file: string, reason: string}>}}` | Every note this package publishes, as manifest entries. |
877
+ | `foundryIdentities` | `function foundryIdentities(config` | `{{contentPackage: string, foundryPackageId: string, packRouter: object, web: boolean, skipDirectories: readonly string[]}}` | The identities an emission runs against, from configuration. |
878
+ | `entryContext` | `function entryContext(config` | `{{contentPackage: string, foundryPackageId: string, packRouter: object, web: boolean, skipDirectories: readonly string[]}}` | The identities an emission runs against, from configuration. |
879
+
880
+ A generated dictionary headword maps to its containing page's UUID followed
881
+ by its word fragment. Other authored spans keep their containing-page
882
+ mapping.
872
883
 
873
884
  ### `engine.contentIndex`
874
885
 
875
- Emitting this package's content index. Every content build already walks the whole note tree and parses every note's frontmatter — the pack compilers, the site build, and the content-table expander each do it — and every one of them throws the result away when it finishes. So nothing outside a build can ask a question about the content: "which beings carry no `kbcat`?", "what does this table actually select?", "did that type rename leave anything behind?" have no answer short of writing a throwaway script that re-walks the tree. Eight dead Bestiary tables shipped for weeks behind exactly that gap.
876
-
877
- | Export | Signature | Returns | Use it when |
878
- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
879
- | `noteAddress` | `function noteAddress(frontmatter, contentPackage)` | {{slug: string, canonical: string}\|null} `slug` is what goes inside `[[…]]` within this package; `canonical` is the fully qualified key the manifest files the note under, carrying the package and the system as well. | The address a wikilink writes to reach a note, or `null` when it has none. |
880
- | `sortKeysDeep` | `function sortKeysDeep(value)` | {unknown} The value with every plain object's keys in sorted order. | Recursively sort an object's keys, so serialization is order-independent. |
881
- | `asciiName` | `function asciiName(name)` | {string\|null} The ASCII form, or `null` when there is no name, or nothing printable survives. | A note's display name reduced to printable 7-bit ASCII. |
882
- | `asciiAliases` | `function asciiAliases(aliases)` | {Array<string>} Possibly empty, never null: a note with no aliases has an empty set of them, which is a fact rather than a missing value, and a consumer iterating it should not have to check first. | A note's `name.aliases` reduced to printable 7-bit ASCII, in order. |
883
- | `serializeContentIndex` | `function serializeContentIndex(records)` | {string} One compact JSON object per line, newline-terminated. | Serialize records as JSON Lines. |
884
- | `indexRecordsFor` | `indexRecordsFor({ contentBase, config, skipDirectories, problems, parsed })` | {object[]} One record per note, plus one per documentation entry. | The index records for a content tree, without writing anything. |
885
- | `indexRecordsForNote` | `indexRecordsForNote({ frontmatter, body, bodyLine, relPath, absPath, raw, contentPackage, manifest, addressContext })` | `object[]` | Derive the note's own record and its documentation record from parsed text. |
886
- | `emitContentIndex` | `emitContentIndex({ contentBase, outDir, config })` | `{{file: string, notes: number, assets: number, records: number, bytes: number, full: number, draft: number, stub: number}}` Where it was written, how many notes and how many assets it holds, how many records that is in all, its size, and the ladder those notes sit on. | Emit this package's content index. |
887
- | `buildIndexRecord` | `buildIndexRecord({ frontmatter, relPath, absPath, raw, contentPackage, body, bodyLine, manifest })` | `Record<string, any>` — the record, with derived fields sorted deep | building one note's content-index record; refuses a note that authors a key the index derives itself (`package` among them) before building anything |
888
- | `collectContentIndex` | `collectContentIndex(contentBase, { contentPackage, skipDirectories, assetsBase, manifest, problems, foreignRecord, parsed })` | `Array<Record<string, any>>` | reading a whole content tree into index records, in a total order independent of directory-read order; an item note yields two records (its own and its documentation journal's), and each asset root yields one per file; each note carries its resolved culture as `data.culture` |
889
- | `resolveCultures` | `resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems })` | `Array<Record<string, any>>` — the same records | writing each note's resolved culture into its record as `data.culture`: its own value, else its parents', else for a polity its seat's and governed places', pooled; parents that disagree give a finding into `problems`, or one thrown Error listing every conflict |
886
+ Collecting and publishing this package's content index. Records describe note frontmatter, assets, prose and event anchors, and the document targets those notes produce. Dynamic publication prepares the same generated Markdown and scalar expressions as the rendering surfaces, using each source note's audience.
887
+
888
+ | Export | Signature | Returns | Use it when |
889
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
890
+ | `noteAddress` | `function noteAddress(frontmatter, contentPackage)` | {{slug: string, canonical: string}\|null} `slug` is what goes inside `[[…]]` within this package; `canonical` is the fully qualified key the manifest files the note under, carrying the package and the system as well. | The address a wikilink writes to reach a note, or `null` when it has none. |
891
+ | `sortKeysDeep` | `function sortKeysDeep(value)` | {unknown} The value with every plain object's keys in sorted order. | Recursively sort an object's keys, so serialization is order-independent. |
892
+ | `asciiName` | `function asciiName(name)` | {string\|null} The ASCII form, or `null` when there is no name, or nothing printable survives. | A note's display name reduced to printable 7-bit ASCII. |
893
+ | `asciiAliases` | `function asciiAliases(aliases)` | {Array<string>} Possibly empty, never null: a note with no aliases has an empty set of them, which is a fact rather than a missing value, and a consumer iterating it should not have to check first. | A note's `name.aliases` reduced to printable 7-bit ASCII, in order. |
894
+ | `serializeContentIndex` | `function serializeContentIndex(records)` | {string} One compact JSON object per line, newline-terminated. | Serialize records as JSON Lines. |
895
+ | `indexRecordsFor` | `indexRecordsFor({ contentBase, config, skipDirectories, problems, parsed })` | {object[]} One record per note, plus one per documentation entry. | The index records for a content tree, without writing anything. |
896
+ | `indexRecordsForNote` | `indexRecordsForNote({ frontmatter, body, bodyLine, relPath, absPath, raw, contentPackage, manifest, addressContext })` | `object[]` | Derive the note's own record and its documentation record from parsed text. |
897
+ | `prepareContentIndex` | `async prepareContentIndex({ contentBase, config } = {})` | `Promise<PreparedContentIndex>` — the captured source context and complete expanded bodies, described below | Prepare dynamic publication without writing files. |
898
+ | `emitContentIndex` | `emitContentIndex({ contentBase, outDir, config, publication } = {})` | `{{file: string, notes: number, assets: number, records: number, bytes: number, full: number, draft: number, stub: number, dictionary: {file: string, rows: number}, terms: {file: string, rows: number}}}` | Synchronously publish metadata and the dictionary and terms datafiles; dynamic content requires a complete `publication`. |
899
+ | `buildIndexRecord` | `buildIndexRecord({ frontmatter, relPath, absPath, raw, contentPackage, body, bodyLine, manifest })` | `Record<string, any>` — the record, with derived fields sorted deep | building one note's content-index record; refuses a note that authors a key the index derives itself (`package` among them) before building anything |
900
+ | `collectContentIndex` | `collectContentIndex(contentBase, { contentPackage, skipDirectories, assetsBase, manifest, problems, foreignRecord, parsed })` | `Array<Record<string, any>>` | reading a whole content tree into index records, in a total order independent of directory-read order; an item note yields two records (its own and its documentation journal's), and each asset root yields one per file; each note carries its resolved culture as `data.culture` |
901
+ | `resolveCultures` | `resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems })` | `Array<Record<string, any>>` — the same records | writing each note's resolved culture into its record as `data.culture`: its own value, else its parents', else for a polity its seat's and governed places', pooled; parents that disagree give a finding into `problems`, or one thrown Error listing every conflict |
902
+
903
+ `prepareContentIndex` resolves `contentBase` from the configured `paths.content`
904
+ when omitted. Its `PreparedContentIndex` contains:
905
+
906
+ | Field | Value |
907
+ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
908
+ | `config` | The resolved configuration object used for preparation. |
909
+ | `contentBase` | The absolute source root. |
910
+ | `records` | The complete note, documentation and asset records, including hidden providers needed for lineage. |
911
+ | `parsed` | A `Map` keyed by absolute note file, holding authored `frontmatter`, `body`, `raw`, `bodyLine` and `bodyColumn`. |
912
+ | `expandedBodies` | A `Map` keyed by absolute note file, holding `markdown`, its body-relative `lineMap` of `{line, generated}` entries, and optional `generatedFrom`. |
913
+ | `foundryBodies` | A `Map` with the same body shape, prepared against the complete native compendium corpus. |
914
+
915
+ Preparation expands SQL tables, page lists, automatic sections and scalar
916
+ expressions. Prose declarations in `expandedBodies` use each source's audience:
917
+ public notes use public providers and usage; GM notes use the complete corpus.
918
+ Automatic lexical sections keep that source audience on every surface.
919
+ Explicit native SQL uses the complete corpus in `foundryBodies`.
920
+
921
+ Native own and documentation target maps derive their actual page identities
922
+ from `foundryBodies`. Each map contains only prose slugs declared in the
923
+ source-audience body and present in the native body, plus the journal's
924
+ reserved `$lead` target. A prose target present only on the website remains
925
+ valid for website links and prose-link checks; a native link to it is refused.
926
+ Native-only slugs and private heading names stay out of a public source's
927
+ metadata. Event anchors and authored source positions remain attached to their
928
+ sources. A published empty prose-anchor list declares no prose targets,
929
+ including on web-only pages.
930
+
931
+ Pass the envelope directly to the synchronous emitter:
932
+
933
+ ```js
934
+ const publication = await prepareContentIndex({ config, contentBase });
935
+ const result = emitContentIndex({ publication, outDir });
936
+ ```
937
+
938
+ The emitter defaults `config` and `contentBase` from the envelope. If supplied,
939
+ they must name that same configuration object and source root. It consumes
940
+ `records`, `parsed`, `expandedBodies` and `foundryBodies` without mutating them;
941
+ frozen inputs are valid. Every note requires a parsed source and both body
942
+ entries.
943
+ Missing or mismatched preparation is refused before creating or replacing any
944
+ metadata, dictionary or terms file.
945
+
946
+ Without `publication`, the synchronous emitter accepts authored bodies and
947
+ pure frontmatter dictionary or glossary sections. Active SQL, page lists,
948
+ scalar expressions, events or other generated sections require
949
+ `prepareContentIndex`. The CLI performs this preparation automatically.
950
+ `indexRecordsFor` and `collectContentIndex` collect authored records and pure
951
+ lexical sections; use preparation and emission to publish dynamic body targets.
890
952
 
891
953
  `engine.contentIndex` also re-exports eight names from neighbouring leaf modules, at this same import path:
892
954
 
@@ -897,7 +959,7 @@ Emitting this package's content index. Every content build already walks the who
897
959
  | `noteFile` | `noteFile(contentBase, record)` | `string` — the note's absolute path | composing an index record's `file.path` (recorded relative, for a byte-stable artifact) back into an openable absolute path |
898
960
  | `authoredFrontmatter` | `authoredFrontmatter(record)` | `Record<string, any>` — the frontmatter without `DERIVED_KEYS` | reading a note's frontmatter back from its index record, its resolved `data.culture` included, so a pass can lint or compile from the index without reasoning about derived keys as if the author wrote them |
899
961
  | `isNoteRecord` | `isNoteRecord(record)` | `boolean` | telling a note's own record apart from its documentation journal's and from an asset's, when enumerating the corpus |
900
- | `isStub` | `isStub(record)` | `boolean` | telling a stub — a note with an empty body — apart from a note that publishes a page; asked of the absent address, which is the whole signal |
962
+ | `isStub` | `isStub(record)` | `boolean` | telling a stub apart from a note that publishes a page; asked of the absent address, which is the whole signal |
901
963
  | `isAssetRecord` | `isAssetRecord(record)` | `boolean` | telling a record that addresses a file apart from one that addresses a note; the `asset` block is what marks it |
902
964
  | `recordPath` | `recordPath(record)` | `string` — the path the record names inside its package | ordering or reporting across both record shapes: a note names the `.md` it was parsed from, an asset the file it is |
903
965
 
@@ -1002,7 +1064,7 @@ content tree can also compile into Foundry compendium packs.
1002
1064
  | `collectContentPages` | `function collectContentPages(contentBase, ctx)` | `{{pages: object[], addressFindings: object[], fmLinkFindings: object[]}}` | The content tree's pages, and what could not be addressed. |
1003
1065
  | `collectHomepages` | `function collectHomepages(contentBase, ctx)` | `{{pages: object[], addressFindings: object[]}}` The homepage notes, in walk order, and the ones among them that could not be addressed. | The package's homepage notes — the authored page at `/<contentPackage>/`. |
1004
1066
  | `writeHomepages` | `function writeHomepages(outRoot, pages, config)` | {number} How many pages were written. | Writes each homepage as the package root's `_index.md`. |
1005
- | `siteGates` | `function siteGates(pages, findings,` | {object} The gate results and, when they pass, the built index. | The integrity gates a site build runs before it writes anything. |
1067
+ | `siteGates` | `siteGates(pages, findings, options)` | {object} The gate results and, when they pass, the built index. | The integrity gates a site build runs before it writes anything. |
1006
1068
  | `emptyGates` | `function emptyGates()` | {object} An all-clear gate result. | The gate result of a build that ran none of them. |
1007
1069
  | `gatesFailed` | `function gatesFailed(gates)` | — | Whether any gate produced a finding. |
1008
1070
  | `tableUniverse` | `function tableUniverse(pages)` | {Map<string, object[]>} Package → the notes it may tabulate. | The universe a generated table searches, grouped by package. |
@@ -1014,6 +1076,10 @@ content tree can also compile into Foundry compendium packs.
1014
1076
  | `sitePageDecorator` | `sitePageDecorator(config, index)` | Function | Add the site's infobox and being metadata to a rendered page. |
1015
1077
  | `buildSite` | `buildSite({ config, sqlTables })` | `{{gates: object, stats: object\|null, tableErrors: object[], wikiErrors: object[], mapFindings: object[], manifests: object\|null}}` | Builds a Hugo content tree from a content tree, and reports what it found. |
1016
1078
 
1079
+ `siteGates` takes `{ config, records, sqlTables? }`. Pass the prepared
1080
+ `sqlTables` map so anchors emitted by automatic dictionary sections are present in the
1081
+ index for checking page links.
1082
+
1017
1083
  ### `engine.sitePreview`
1018
1084
 
1019
1085
  `prepareSitePreview({ config })` reads the saved content tree and declared dependency indexes, prepares its SQL corpus, and returns a private preview context. Pass the active note's absolute path and complete buffer text to `render(file, text)`. A successful result has `{ ok: true, markdown, frontmatter, findings: [] }`; a failed result has `{ ok: false, findings }` and no replacement content. Findings identify the source file and carry a line and column when the offending text can be located.
@@ -1224,12 +1290,17 @@ Shared helpers for the pack compilers in `packages/package-build/`. The HeroicLa
1224
1290
  | `buildStats` | `function buildStats(systemVersion` | {object} The `_stats` block. | Default `_stats` block for compiled compendium entries. |
1225
1291
  | `statsForPack` | `function statsForPack(packSystem, config` | {object} The `_stats` block for that pack. | The `_stats` block for one pack, stamped with the system that pack is for. |
1226
1292
  | `defaultStats` | `function defaultStats()` | {object} The default `_stats` block, shared by every compiler. | The `_stats` block every compiler stamps on an entry it emits, built once. |
1227
- | `buildContentLinkIndex` | `buildContentLinkIndex(contentBase, router, { skipDirectories, config, records, problems })` | `{{byShortcode: Map, types: Set}}` From `buildWikilinkIndex`. | Indexes **every** note in the content tree so any pack compiler can resolve a wikilink to any other document. |
1293
+ | `buildContentLinkIndex` | `buildContentLinkIndex(contentBase, router?, options?)` | `{{byShortcode: Map, types: Set}}` From `buildWikilinkIndex`. | Indexes **every** note in the content tree so any pack compiler can resolve a wikilink to any other document. |
1228
1294
  | `convertNoteWikilinks` | `convertNoteWikilinks(body, { type, id, pack, docPack, index, name, file, bodyLine, bodyColumn, lineMap })` | `{{markdown: string, unresolved: Array<object>}}` | Converts the wikilinks in one note's markdown, reporting any that have no target in the content tree. |
1229
1295
  | `expandNoteTables` | `function expandNoteTables(body,` | `{{markdown: string, lineMap: Array<{line: number, generated: boolean}>}}` The body with every table expanded, and where each emitted line came from — which is what lets a diagnostic about the expanded body name an authored position. | Expand prepared SQL tables in one note's markdown, before wikilinks are resolved — so a generated cell may itself be a wikilink. |
1230
1296
  | `folderFilename` | `function folderFilename(name, id)` | — | Builds a compendium-source filename for a folder JSON document: `folder_Name_id.json` with non-alphanumeric runs replaced by underscores. |
1231
1297
  | `walkMarkdownTree` | `walkMarkdownTree(rootDir, { skipDirectories })` | generator yielding `{frontmatter, body, description, raw, bodyLine?, bodyColumn?, file, absPath}` | walking a content tree and reading every markdown file's frontmatter and body; `skipDirectories` is required rather than defaulted, so two callers cannot silently disagree about the corpus's scope |
1232
1298
 
1299
+ `buildContentLinkIndex` accepts `{ config?, records, sqlTables? }`.
1300
+ `records` is the required, supplied content corpus. The optional prepared
1301
+ `sqlTables` map supplies generated anchors and their Foundry page mappings,
1302
+ so glossary word links resolve to their dictionary entries.
1303
+
1233
1304
  ### `engine.itemRegistry`
1234
1305
 
1235
1306
  **The resolved item-type registry** — the consuming repository's `itemBuilders` table, and the type whitelist derived from its keys. Both are read from the one resolved configuration, so they are literally the same object's keys and values: a type cannot be whitelisted for compilation without the builder that compiles it, which is the guarantee this exists for. Where a consumer declares **several** registries, one per system, the vocabulary is their union and every lookup below takes the system that is asking — a type both systems declare has two builders, and answering with one of them because it was declared first is the silent-wrong-output failure this package spends its time removing. The Item compiler dispatches through {@link itemBuilder}, so the table a consumer configured is the table its notes compile with — the whitelist and the dispatch would otherwise come from different places, and a consumer supplying its own registry got the types it asked for and the builders it did not.
@@ -2074,3 +2145,232 @@ set by `joinGeneratedSections`. Subsequent fences are generated event queries
2074
2145
  whose stub-inclusive results are identical; only authored fences receive a
2075
2146
  stub-inclusion comparison. Query answers and comparisons are cached for one
2076
2147
  preparation and kept separate.
2148
+
2149
+ `prepareTreeSqlTables` retains the complete record membership when opening a
2150
+ public database, so derived citations can distinguish hidden notes from
2151
+ unknown addresses. Only public records contribute rendered sources and links.
2152
+
2153
+ | Export | Contract |
2154
+ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
2155
+ | `prepareLinkSqlTables` | Prepare SQL, page-list and automatic lexical sections for link checking, using one preparation per required source audience. |
2156
+
2157
+ `engine/sql-tables.mjs` exports `prepareLinkSqlTables(contentBase, options)`.
2158
+ Its options are `config`, `skipDirectories`, complete `records`, the shared
2159
+ `parsed` map and `foreignIndex`. It returns prepared tables with `pageLists`
2160
+ and `inline` maps, or `undefined` when no source needs a directive or automatic
2161
+ lexical section. Public
2162
+ sources use public rows; GM sources use all rows. Query results remain local
2163
+ to each audience preparation, and the caller’s records remain complete for
2164
+ checking authored references. Unresolved derived citations retain the
2165
+ renderer’s plain-label behavior.
2166
+
2167
+ `generatedSectionsFor(db, records, contentBase, options?)` accepts complete
2168
+ lineage records as `options.all` (default `records`). Its result includes
2169
+ `lexical`, a map from note file to `LexicalSection[]`, beside `sections`,
2170
+ `views` and `derived`. `attachGeneratedSections(prepared, generated)` places
2171
+ those lists on each note's prepared results as `lexical`; body expansion
2172
+ inserts them alongside the prepared directives.
2173
+
2174
+ ## Dictionary and glossary rows
2175
+
2176
+ The wildcard engine subpath exposes
2177
+ `@heroiclands/package-build/engine/dictionary-rows` for deriving the
2178
+ `dictionary` and `terms` SQL relations from content-index records:
2179
+
2180
+ | Export | Signature | Returns | Use it when |
2181
+ | ----------------------- | ----------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------- |
2182
+ | `DICTIONARY_COLUMNS` | `const DICTIONARY_COLUMNS` | A readonly list of column names | Declaring the schema of a language's dictionary. |
2183
+ | `TERM_COLUMNS` | `const TERM_COLUMNS` | A readonly list of column names | Declaring the schema of owner-specific glossary terms. |
2184
+ | `ETYMOLOGY_KEYS` | `const ETYMOLOGY_KEYS` | A readonly list of keys | Reading the structure of a dictionary word's etymology. |
2185
+ | `dictionaryLetter` | `dictionaryLetter(text)` | `string` | Deriving a heading letter from a sort key. |
2186
+ | `dictionaryEntryAnchor` | `dictionaryEntryAnchor(language, name)` | `string` | Deriving the stable entry anchor for a word in a language's dictionary. |
2187
+ | `dictionaryAndTermRows` | `dictionaryAndTermRows(records, all = records)` | `{ dictionary: object[], terms: object[] }` | Deriving dictionary words and glossary terms for the selected records. |
2188
+ | `serializeRows` | `serializeRows(rows)` | `string` | Writing deterministic JSON Lines with keys sorted at every depth. |
2189
+
2190
+ `dictionaryEntryAnchor` takes the language's canonical Address and the
2191
+ word's form. Its `word-` anchor is derived from the full SHA-256 digest of
2192
+ that language and the NFC-normalized form, with case and accents preserved.
2193
+ It identifies the word's entry on that language page, including a word
2194
+ inherited from an ancestor.
2195
+
2196
+ `records` is the selected audience; `all` supplies the complete record set for
2197
+ language and ownership resolution. A dictionary word takes its sort key and
2198
+ pronunciation from the language's word declaration. A glossary entry can
2199
+ supply its own sort key and pronunciation, using the dictionary word's
2200
+ values where unstated.
2201
+
2202
+ A term naming a dictionary word carries its `class` and a `word` Address
2203
+ linking to its dictionary entry. The target is in the resolved language's
2204
+ dictionary when that language has own words; otherwise it is in the visible
2205
+ language that defines the word. The term's `language` stays resolved to the
2206
+ owner or entry's language. Without an entry's `definition`, its `senses`
2207
+ contain every dictionary gloss sense with
2208
+ an empty `bodies` list. With a definition, that owner's meaning is the sense
2209
+ instead. A rank or office of the same owner contributes its distinct
2210
+ descriptions after the dictionary senses; the owner's definition replaces
2211
+ both. Ranks, offices and terms outside the dictionary carry their source
2212
+ bodies. `common` belongs to
2213
+ glossary entries and `terms`, with each owner's choice kept on that owner's
2214
+ row. The [format
2215
+ reference](reference/format-details.md#dictionaries-and-glossaries) defines
2216
+ all columns and the rules for culture reach and ranks. The `cultures` list
2217
+ records associations for custom queries. Automatic Glossary sections select
2218
+ only their owner's explicitly declared `data.glossary` entries. A custom
2219
+ view can also select reached rank and office rows, keeping common marks only
2220
+ on its owner's own rows.
2221
+
2222
+ Dictionary rows carry `plural`, the noun's nonempty explicit plural or its
2223
+ listed language's first matching literal suffix replacement, and `verbs`,
2224
+ that language's stated `{invariant: true}` or six-person pattern, otherwise
2225
+ null. These optional facts use no ancestor grammar and perform no word-rule
2226
+ validation. The raw `words` relation's `plural` remains the authored value.
2227
+ Dictionary SQL types are projected only for that relation, retaining its
2228
+ etymology struct, sense list, usage list and integer depth even when all
2229
+ optional grammar values are null.
2230
+
2231
+ `@heroiclands/package-build/engine/language-terms` exposes the person
2232
+ descriptors used by the schema, SQL pattern type and head table:
2233
+
2234
+ | Export | Signature | Returns | Use it when |
2235
+ | -------------------- | -------------------------- | ------------------------------ | -------------------------------------------------------------------------------------------- |
2236
+ | `VERB_PERSON_FIELDS` | `const VERB_PERSON_FIELDS` | Readonly inner-key descriptors | Reading the six present person names, labels and ending-string shapes in presentation order. |
2237
+
2238
+ `serializeRows` returns the empty string for no rows; otherwise each object
2239
+ occupies one line and the file ends with a newline.
2240
+
2241
+ ## Dictionary and glossary layouts
2242
+
2243
+ `@heroiclands/package-build/engine/dictionary-layout` renders SQL results as
2244
+ Markdown for the dictionary or glossary on its owner's note:
2245
+
2246
+ | Export | Signature | Returns | Use it when |
2247
+ | ------------------------- | ------------------------------------ | -------------------------- | ----------------------------------------------------------------------------------- |
2248
+ | `LAYOUT_REQUIRED_COLUMNS` | `const LAYOUT_REQUIRED_COLUMNS` | Required columns by layout | Checking the columns a dictionary or glossary query must select. |
2249
+ | `COMMON_MARK` | `const COMMON_MARK` | `string` | Reading the mark a common glossary entry opens with. |
2250
+ | `DICTIONARY_ENTRY_CLASS` | `const DICTIONARY_ENTRY_CLASS` | `string` | Identifying generated headword spans that are native dictionary targets in Foundry. |
2251
+ | `dictionaryEntryPlugin` | `dictionaryEntryPlugin(md)` | `void` | Registering native dictionary headword rendering on the Foundry Markdown parser. |
2252
+ | `renderDictionary` | `renderDictionary(result, options?)` | `string` | Rendering a language's words with classes, etymology, senses and citations. |
2253
+ | `renderGlossary` | `renderGlossary(result, options?)` | `string` | Rendering an owner's glossary entries, ranks and offices. |
2254
+
2255
+ `result` is `{ columns: string[], rows: object[] }`. Both layouts require
2256
+ `name`, `senses` and `usage`; a missing column throws an error naming it.
2257
+ `LayoutOptions` accepts `linkable(ref)`, `sectionLevel`, `addressContext`,
2258
+ `labelOf(address)`, `showKey` (`true`, `false` or `"only"`), `pageLanguage`, `pageOwner`,
2259
+ `verbs` and `entryAnchors` (default `true`). These control link availability, letter-heading level,
2260
+ Address resolution, labels, the abbreviation key and the language whose tag
2261
+ is omitted. `entryAnchors` controls canonical dictionary headword targets;
2262
+ custom SQL dictionary views set it to `false`, leaving those targets to the
2263
+ language's automatic Dictionary section. A dictionary marks nothing common;
2264
+ a glossary prints the mark and its explanation when a selected term carries
2265
+ `common: true`.
2266
+
2267
+ A dictionary entry has a stable word anchor. A glossary headword with a
2268
+ `word` Address links to that entry, and its `class` prints beside the language
2269
+ tag. Glossary rows sharing a headword and language combine into one entry;
2270
+ identical senses print once with all their source bodies. Dictionary-derived
2271
+ senses carry no body label. Dictionary entries print their `field` in italic
2272
+ parentheses and their etymology in brackets.
2273
+
2274
+ Nouns print their optional `plural` beside the class. Each represented
2275
+ language's optional `verbs` column supplies one table after the key and
2276
+ before the first letter, with empty endings shown as "unchanged". For an
2277
+ automatic dictionary, `options.verbs` supplies the owning language's pattern
2278
+ even when its own words contain only excluded classes. The existing
2279
+ nonempty-own-words gate still applies. `showKey: false` suppresses the key
2280
+ and verb table while retaining entries; `showKey: "only"` prints the
2281
+ abbreviation key alone. With the default or `showKey: true`, a stated pattern
2282
+ still prints when the result needs no abbreviation key. Each head-enabled
2283
+ result prints its represented languages' tables independently.
2284
+
2285
+ `pageOwner` is the containing page's canonical note Address. A glossary omits
2286
+ a sense's body label only when every contributing row belongs to that page
2287
+ and attributes the sense solely to itself. Other bodies and shared senses
2288
+ keep their labels. Without `pageOwner` or a selected `owner` column, all body
2289
+ labels remain.
2290
+
2291
+ `DICTIONARY_ENTRY_CLASS` is `"dictionary-entry"`. The Foundry parser's
2292
+ `dictionaryEntryPlugin` promotes the marked headword at the start of a
2293
+ dictionary paragraph to an inline native H6 heading in a div container. It
2294
+ keeps the containing journal page. Site and book rendering retain the
2295
+ headword's span and paragraph. `anchorsOf` maps this dictionary target to
2296
+ its containing page's UUID with the word fragment; generic authored spans
2297
+ keep their containing-page behavior.
2298
+
2299
+ ## Automatic dictionary and glossary sections
2300
+
2301
+ `@heroiclands/package-build/engine/lexical-sections` derives and inserts the
2302
+ same automatic lists for the site, Foundry journal, book and published index:
2303
+
2304
+ | Export | Signature | Returns | Use it when |
2305
+ | ----------------------- | ------------------------------------------------------ | ------------------------------------- | ----------------------------------------------------------------- |
2306
+ | `LEXICAL_SECTIONS` | `const LEXICAL_SECTIONS` | A frozen list of section declarations | Reading section headings, accepted anchors and frontmatter gates. |
2307
+ | `hasLexicalEntries` | `hasLexicalEntries(frontmatter)` | `boolean` | Checking whether a note supplies automatic lexical content. |
2308
+ | `lexicalSectionsFor` | `lexicalSectionsFor(records, all = records)` | `Map<string, LexicalSection[]>` | Deriving lists keyed by their owner's canonical Address. |
2309
+ | `insertLexicalSections` | `insertLexicalSections(markdown, sections?, context?)` | `{markdown, lineMap, generatedFrom?}` | Inserting lists while preserving authored text and source lines. |
2310
+
2311
+ Each registry declaration has `slug`, `heading`, `aliases`, `dataKey` and
2312
+ `types`, whose entries have `type` and an optional `subType`. The Dictionary
2313
+ declaration applies to `skill`/`language` notes with nonempty `data.words`,
2314
+ and accepts `dictionary` and `lexicon` heading anchors. The Glossary
2315
+ declaration applies to cultures (`lore`/`culture`) and affiliations with
2316
+ nonempty `data.glossary`, and accepts `glossary`. `hasLexicalEntries` reads
2317
+ these same type and nonempty-array gates; it does not validate the authored
2318
+ body.
2319
+
2320
+ `records` is the visible audience; `all` supplies complete lineage records.
2321
+ Each `LexicalSection` has `slug`, `heading`, `aliases`, a query-shaped
2322
+ `result: {columns, rows}` and dictionary-layout `options`. Dictionary rows
2323
+ cover the language's full dictionary; glossary rows select its owner's
2324
+ explicit entries only, including a rank or office only when named there.
2325
+ A qualifying words declaration containing only excluded word classes still
2326
+ produces its Dictionary heading, without entries or a key.
2327
+
2328
+ `insertLexicalSections` takes the body after authored directives expand.
2329
+ Pass the original body as `context.authored` together with the expansion's
2330
+ line map, so generated headings cannot select or end an authored section.
2331
+ Without `authored`, the helper treats `markdown` as its source. It inserts a
2332
+ list at the end of the first matching authored anchored section, after its
2333
+ text and before the next authored heading at the same or a higher level.
2334
+ Headings inside comments, raw HTML, literal code or named blocks do not select
2335
+ sections.
2336
+ Without that heading, it appends an H1 with the section's canonical anchor.
2337
+ Letter headings sit one level below the containing heading, up to H6. The
2338
+ optional context is `{authored?, lineMap?, generatedFrom?}`; the returned line map keeps
2339
+ authored positions, marks inserted lines as generated and adjusts the start
2340
+ of appended nonlexical sections. It leaves the supplied line-map array
2341
+ unchanged.
2342
+
2343
+ ## Glossary term validation
2344
+
2345
+ The wildcard module `@heroiclands/package-build/engine/culture-reach`
2346
+ exposes the term lookup used for glossary validation;
2347
+ `@heroiclands/package-build/engine/glossary-entries` exposes its findings:
2348
+
2349
+ | Export | Signature | Returns | Use it when |
2350
+ | --------------- | ------------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------- |
2351
+ | `noteTerms` | `noteTerms(index, note, entry?)` | `Map<string, string[]>` | Reading eligible dictionary words and reached rank or office descriptions by NFC name. |
2352
+ | `checkGlossary` | `checkGlossary(note, { index } = {})` | `object[]` | Checking the meanings and language references of a culture's or affiliation's glossary entries. |
2353
+
2354
+ `noteTerms` selects words from the entry's resolved language and its
2355
+ ancestors; without `entry`, it uses the note's own resolved language. It also
2356
+ includes ranks and offices that reach the note. `checkGlossary` returns
2357
+ located findings for entries naming no language, duplicate names, invalid
2358
+ pronunciations and entries without a definition that match neither a word in
2359
+ their resolved language nor a reached rank or office. A definition repeating
2360
+ the only sense it replaces produces a warning. With no `index`, the check
2361
+ validates only what the note states locally.
2362
+
2363
+ ## Built-piece resolution
2364
+
2365
+ The wildcard engine subpath exposes `pieceGlosses` from
2366
+ `@heroiclands/package-build/engine/word-rows`.
2367
+
2368
+ | Export | Signature | Returns | Use it when |
2369
+ | -------------- | ------------------------ | ----------------------------------- | ---------------------------------------------------------------------------- |
2370
+ | `pieceGlosses` | `pieceGlosses(...datas)` | A lookup returning a string or null | Resolving a word's built pieces for lint, word rows or dictionary etymology. |
2371
+
2372
+ Pass language `data` objects nearest first, in `lineageChain` order. Each
2373
+ language contributes words, affixes and elements, in that order. Words and
2374
+ affixes supply `gloss`, including an affix matched by its `front` form. Elements
2375
+ supply `sense` and match their `form` and `variants`. Exact matches precede matches without joining hyphens; case and
2376
+ accents are preserved. A missing gloss returns `null`.
@@ -45,4 +45,4 @@ The full Address is `<package>-note-lore-harbor`; `<package>` comes from `conten
45
45
 
46
46
  Add `description` for a short page summary, `tags: [draft]` to mark unfinished prose, or `data` for fields the note's type permits. A system block such as `sohl:` or `hm3:` adds mechanics and can produce a Foundry Actor or Item. It does not replace the shared prose or make another JournalEntry. [Frontmatter](frontmatter.md) explains the allowed keys, and the [note-type reference](../reference/note-types.md) lists every type-specific field.
47
47
 
48
- Every typed note must have a nonempty body after its frontmatter. An empty or whitespace-only body is an error, even with `tags: [draft]` or a description. This includes folder and homepage notes. A nonempty body with fewer than 25 prose words produces a warning unless the note has a `draft` tag. A folder note needs only a nonempty body: any content passes, with or without the tag. See [note states](../reference/format-details.md#three-states-of-a-note).
48
+ Nonempty `data.words` on a language note, or `data.glossary` on a culture or affiliation, supplies content without an authored body. Every other typed note must have a nonempty body after its frontmatter, including folder and homepage notes. An empty or whitespace-only body is otherwise an error, even with `tags: [draft]` or a description. A nonempty body with fewer than 25 prose words produces a warning unless the note has a `draft` tag. A folder note needs only a nonempty body: any content passes, with or without the tag. See [note states](../reference/format-details.md#three-states-of-a-note).