@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
@@ -28,12 +28,26 @@
28
28
  * **A leaf, deliberately.** `anchors.mjs` is shared by the compilers, the index
29
29
  * and the link checker and cannot import any of them; the figure pass is
30
30
  * imported by `anchors.mjs` and so cannot import it back. A module that imports
31
- * only the attribute grammar can be asked by all of them.
31
+ * the attribute grammar and Markdown literal ranges can be asked by all of them.
32
32
  *
33
33
  * @module
34
34
  */
35
35
 
36
36
  import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
37
+ import MarkdownIt from "markdown-it";
38
+
39
+ /** Literal block ranges use the renderer's Markdown grammar. */
40
+ const structureParser = new MarkdownIt({ html: true });
41
+
42
+ /** Body lines occupied by code or raw HTML, including comment examples. */
43
+ function literalHeadingLines(source) {
44
+ const lines = new Set();
45
+ for (const token of structureParser.parse(String(source ?? ""), {})) {
46
+ if (!token.map || !["fence", "code_block", "html_block"].includes(token.type)) continue;
47
+ for (let line = token.map[0]; line < token.map[1]; line++) lines.add(line);
48
+ }
49
+ return lines;
50
+ }
37
51
 
38
52
  /**
39
53
  * A heading line: its hashes and the text after them.
@@ -134,8 +148,8 @@ export function opensPage({ level, id }) {
134
148
  /**
135
149
  * Every heading in a body, with what its attribute block declares.
136
150
  *
137
- * Fenced code is skipped: `#` opens a comment in most of what gets fenced, and
138
- * an example heading in a document is not a heading of it.
151
+ * Code and raw HTML blocks are skipped: an example heading in a document is
152
+ * not a heading of it, including an example in an HTML comment.
139
153
  *
140
154
  * @param {string} source - A note's markdown body.
141
155
  * @param {number} [bodyLine=1] - The 1-based file line the body starts on, so a
@@ -148,18 +162,9 @@ export function scanHeadingAttributes(source, bodyLine = 1) {
148
162
  const lines = String(source ?? "").split("\n");
149
163
  const headings = [];
150
164
  const errors = [];
151
- let codeFence = null;
165
+ const literal = literalHeadingLines(source);
152
166
  for (let i = 0; i < lines.length; i++) {
153
- const fence = /^ {0,3}(`{3,}|~{3,})/.exec(lines[i]);
154
- if (codeFence) {
155
- if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length)
156
- codeFence = null;
157
- continue;
158
- }
159
- if (fence) {
160
- codeFence = fence[1];
161
- continue;
162
- }
167
+ if (literal.has(i)) continue;
163
168
  const heading = HEADING_LINE.exec(lines[i]);
164
169
  if (!heading) continue;
165
170
  const parsed = splitHeadingAttributes(heading[2]);
@@ -267,21 +272,9 @@ export function parseHeadingLine(line) {
267
272
  export function pageOpenings(source, blockLines = new Set()) {
268
273
  const lines = String(source ?? "").split("\n");
269
274
  const openings = new Map();
270
- let codeFence = null;
275
+ const literal = literalHeadingLines(source);
271
276
  for (const [index, line] of lines.entries()) {
272
- // A fence's closer must carry the same character as its opener and
273
- // be at least as long, same as {@link scanHeadingAttributes} tracks
274
- // it below — a longer fence is what lets a fenced example carry a
275
- // shorter fence of its own as literal content.
276
- const fence = /^ {0,3}(`{3,}|~{3,})/.exec(line);
277
- if (codeFence) {
278
- if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length) {
279
- codeFence = null;
280
- }
281
- } else if (fence) {
282
- codeFence = fence[1];
283
- }
284
- if (codeFence !== null || blockLines.has(index)) continue;
277
+ if (literal.has(index) || blockLines.has(index)) continue;
285
278
  const heading = parseHeadingLine(line);
286
279
  if (!heading?.startsPage) continue;
287
280
  openings.set(index, { ...heading, line: index + 1 });
@@ -74,10 +74,11 @@ import { isDraftNote } from "./note-vocabulary.mjs";
74
74
  import { DERIVED_PACKED_TYPES, NEVER_PACKED_TYPES } from "./note-claims.mjs";
75
75
  import { expandContentTables } from "./content-tables.mjs";
76
76
  import { spanMarkdownPlugin } from "./content-spans.mjs";
77
+ import { dictionaryEntryPlugin } from "./dictionary-layout.mjs";
77
78
  import { renderBlocks } from "./content-blocks.mjs";
78
79
  import { headingAttributesPlugin } from "./heading-attributes.mjs";
79
80
  import { renderFigureBlocks, scanFigures } from "./content-figures.mjs";
80
- import { positionInBody } from "./diagnostics.mjs";
81
+ import { positionInBody, splitFrontmatter } from "./diagnostics.mjs";
81
82
  // The pure `sohl:` frontmatter readers live in a leaf module so the item-type
82
83
  // registry can import them without reaching back through this one.
83
84
  // Re-exported here so every existing importer keeps its single import path.
@@ -108,6 +109,7 @@ export const md = markdownit({ html: true })
108
109
  .use(footnotePlugin)
109
110
  .use(deflistPlugin)
110
111
  .use(spanMarkdownPlugin)
112
+ .use(dictionaryEntryPlugin)
111
113
  // A heading's attribute block, written onto the heading element rather than
112
114
  // typeset into it — the reading the website's renderer does for itself.
113
115
  .use(headingAttributesPlugin)
@@ -231,7 +233,7 @@ export function renderFoundryMarkdown(body, figures, footnoteNumbers, docId, res
231
233
  }
232
234
 
233
235
  /**
234
- * Parses a markdown file with YAML frontmatter.
236
+ * Parses a markdown file with LF or CRLF YAML frontmatter delimiters.
235
237
  *
236
238
  * Returns `{ frontmatter, body, description, bodyLine, bodyColumn, raw }` where
237
239
  * `body` is the trimmed raw markdown after the frontmatter block, and
@@ -243,20 +245,27 @@ export function renderFoundryMarkdown(body, figures, footnoteNumbers, docId, res
243
245
  * `{ frontmatter: null, body: "", description: "", raw }` and no
244
246
  * position: there is no body to have one.
245
247
  * @param {string} filePath - Markdown file to read.
246
- * @param {{addressContext?: object}} [opts] - Address defaults for decoded fields.
248
+ * @param {object} [opts]
249
+ * @param {object} [opts.addressContext] - Address defaults for decoded fields.
250
+ * @param {string} [opts.raw] - Exact captured source; omitted, reads the file.
251
+ * @param {boolean} [opts.renderDescription=true] - Render the body's HTML description.
252
+ * False leaves `description` empty for readers that only use authored text.
247
253
  * @returns {{frontmatter: object|null, body: string, description: string, raw: string,
248
254
  * bodyLine?: number, bodyColumn?: number, parseError?: {message: string,
249
255
  * line?: number, column?: number}}} Parsed note and body position.
250
256
  */
251
- export function parseMarkdownFile(filePath, { addressContext } = {}) {
252
- const content = fs.readFileSync(filePath, "utf8");
253
- const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
257
+ export function parseMarkdownFile(
258
+ filePath,
259
+ { addressContext, raw: capturedRaw, renderDescription = true } = {},
260
+ ) {
261
+ const content = capturedRaw ?? fs.readFileSync(filePath, "utf8");
262
+ const fmMatch = splitFrontmatter(content);
254
263
  if (!fmMatch) {
255
264
  return { frontmatter: null, body: "", description: "", raw: content };
256
265
  }
257
266
  let frontmatter;
258
267
  try {
259
- frontmatter = yaml.parse(fmMatch[1]) || {};
268
+ frontmatter = yaml.parse(fmMatch.yaml) || {};
260
269
  } catch (err) {
261
270
  const firstLine = String(err.message).split("\n", 1)[0];
262
271
  const message = firstLine.replace(/ at line \d+, column \d+.*$/, "");
@@ -277,7 +286,7 @@ export function parseMarkdownFile(filePath, { addressContext } = {}) {
277
286
  try {
278
287
  decodeNoteAddresses(frontmatter, addressContext);
279
288
  } catch (error) {
280
- const position = positionOfYamlPath(fmMatch[1], error.keyPath ?? [], {
289
+ const position = positionOfYamlPath(fmMatch.yaml, error.keyPath ?? [], {
281
290
  key: error.addressKey,
282
291
  });
283
292
  if (position.line) position.line++;
@@ -286,9 +295,9 @@ export function parseMarkdownFile(filePath, { addressContext } = {}) {
286
295
  throw error;
287
296
  }
288
297
  }
289
- const raw = fmMatch[2];
298
+ const raw = fmMatch.body;
290
299
  const body = raw.trim();
291
- const description = body ? renderFoundryMarkdown(body) : "";
300
+ const description = body && renderDescription ? renderFoundryMarkdown(body) : "";
292
301
  // Where the trimmed body starts in the *file*, so an offset within it can
293
302
  // be reported as a file position. The frontmatter's lines and the
294
303
  // blank lines `trim()` removes both sit in between, and the trim can take
@@ -374,13 +383,19 @@ export function assertSuppliedCorpus(records, who) {
374
383
  * Required: the scope is the caller's to state, so two passes cannot
375
384
  * disagree about which files are the corpus.
376
385
  * @param {object} [opts.addressContext] - Address resolution context.
386
+ * @param {Map<string, {raw: string}>} [opts.sources] - Selected sources in walk order.
387
+ * Supplied, the walk parses these strings instead of reading the filesystem.
388
+ * @param {boolean} [opts.renderDescription=true] - Render each body's description.
377
389
  * @yields {{frontmatter: object|null, body: string, description: string,
378
390
  * file: string, absPath: string, bodyLine?: number, bodyColumn?: number}}
379
391
  * One entry per `.md` file found.
380
392
  * @throws {Error} When `skipDirectories` is not stated — see
381
393
  * {@link assertStatedScope}.
382
394
  */
383
- export function* walkMarkdownTree(rootDir, { skipDirectories, addressContext } = {}) {
395
+ export function* walkMarkdownTree(
396
+ rootDir,
397
+ { skipDirectories, addressContext, sources, renderDescription = true } = {},
398
+ ) {
384
399
  // Stated by the caller, never resolved here. A default here
385
400
  // — `loadPackConfig().skipDirectories` — read whichever configuration
386
401
  // resolved from the working directory rather than the one the caller was
@@ -393,6 +408,20 @@ export function* walkMarkdownTree(rootDir, { skipDirectories, addressContext } =
393
408
  // the ambient config rather than the passed one, which a fixture can pass
394
409
  // on indefinitely.
395
410
  assertStatedScope(skipDirectories, "walkMarkdownTree");
411
+ if (sources) {
412
+ for (const [absPath, source] of sources) {
413
+ yield {
414
+ ...parseMarkdownFile(absPath, {
415
+ addressContext,
416
+ raw: source.raw,
417
+ renderDescription,
418
+ }),
419
+ file: path.basename(absPath),
420
+ absPath,
421
+ };
422
+ }
423
+ return;
424
+ }
396
425
  if (!fs.existsSync(rootDir)) return;
397
426
  const stack = [rootDir];
398
427
  while (stack.length > 0) {
@@ -411,7 +440,7 @@ export function* walkMarkdownTree(rootDir, { skipDirectories, addressContext } =
411
440
  stack.push(absPath);
412
441
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
413
442
  yield {
414
- ...parseMarkdownFile(absPath, { addressContext }),
443
+ ...parseMarkdownFile(absPath, { addressContext, renderDescription }),
415
444
  file: entry.name,
416
445
  absPath,
417
446
  };
@@ -781,12 +810,16 @@ import { collectAnchors } from "./anchors.mjs";
781
810
  * omitted.
782
811
  * @param {readonly object[]} [opts.records] - The corpus, derived once per
783
812
  * compile and handed in. Required: see {@link assertSuppliedCorpus}.
813
+ * @param {Map<string, object[]>} [opts.sqlTables] - Prepared directives,
814
+ * including the anchors and Foundry page maps of their expanded bodies.
815
+ * @param {ReturnType<import("./metadata-index.mjs").loadForeignIndexes>} [opts.foreign] -
816
+ * Captured dependency address and reference views for this context.
784
817
  * @returns {{byShortcode: Map, types: Set}} From `buildWikilinkIndex`.
785
818
  */
786
819
  export function buildContentLinkIndex(
787
820
  contentBase,
788
821
  router = packRouter(),
789
- { config, records } = {},
822
+ { config, records, sqlTables, foreign: capturedForeign } = {},
790
823
  ) {
791
824
  const docs = [];
792
825
  /** The files this package ships, by canonical address. */
@@ -812,6 +845,7 @@ export function buildContentLinkIndex(
812
845
  // authored fields, and neither may be handed the keys the index derived.
813
846
  const fm = authoredFrontmatter(record);
814
847
  const absPath = noteFile(contentBase, record);
848
+ const prepared = sqlTables?.get(absPath);
815
849
  // The id a note's document is filed under: its authored pin, or the
816
850
  // one derived from its canonical address. Derived by the index
817
851
  // against the configuration this build resolved — never here through
@@ -861,11 +895,12 @@ export function buildContentLinkIndex(
861
895
  // discarded here — the walk yields the body and nothing read it.
862
896
  // Read from the record rather than from a second reading of the
863
897
  // note's headings — the one-anchor-reader rule.
864
- anchors: new Set(
865
- (record.anchors ?? [])
898
+ anchors: new Set([
899
+ ...(record.anchors ?? [])
866
900
  .filter((anchor) => anchor.kind === "prose")
867
901
  .map((anchor) => anchor.slug),
868
- ),
902
+ ...(prepared?.anchors ?? []).map((anchor) => anchor.slug),
903
+ ]),
869
904
  // The note's event ids, which a wikilink may not name: a link to
870
905
  // one is refused with its own message rather than as unknown.
871
906
  eventAnchors: new Set(
@@ -873,8 +908,8 @@ export function buildContentLinkIndex(
873
908
  .filter((anchor) => anchor.kind === "event")
874
909
  .map((anchor) => anchor.slug),
875
910
  ),
876
- anchorUuids: record.foundry?.note?.anchors,
877
- docAnchorUuids: documentation?.foundry?.note?.anchors,
911
+ anchorUuids: prepared?.anchorUuids ?? record.foundry?.note?.anchors,
912
+ docAnchorUuids: prepared?.docAnchorUuids ?? documentation?.foundry?.note?.anchors,
878
913
  });
879
914
  }
880
915
  // Packages this build links *into* but does not publish. Each publishes
@@ -886,7 +921,7 @@ export function buildContentLinkIndex(
886
921
  index: foreign,
887
922
  references: foreignReferences,
888
923
  stale,
889
- } = loadForeignIndexes(resolved, [resolved.contentPackage], PACKAGE_BASE);
924
+ } = capturedForeign ?? loadForeignIndexes(resolved, [resolved.contentPackage], PACKAGE_BASE);
890
925
  if (stale.length) {
891
926
  for (const st of stale) {
892
927
  log.error(`Unusable content index for "${st.package}": ${st.reason}`);
@@ -63,6 +63,7 @@ export const DERIVED_KEYS = Object.freeze([
63
63
  "asset",
64
64
  "resolvedDates",
65
65
  "citations",
66
+ "names",
66
67
  ]);
67
68
 
68
69
  /**
package/engine/index.mjs CHANGED
@@ -187,6 +187,9 @@ export * as foundryEntries from "./foundry-entries.mjs";
187
187
  /** Publishing the note tree as a queryable JSON Lines index. */
188
188
  export * as contentIndex from "./content-index.mjs";
189
189
 
190
+ /** One invocation's captured content and context-specific prepared views. */
191
+ export * as contentSession from "./content-session.mjs";
192
+
190
193
  /** The asset types — `icon`, `image`, `audio` — and the roots they are walked from. */
191
194
  export * as assetTypes from "./asset-types.mjs";
192
195
 
@@ -73,6 +73,7 @@ import { reckoningContext } from "./reckoning-markers.mjs";
73
73
  import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
74
74
  import { JOURNAL_TYPES } from "./ids.mjs";
75
75
  import { journalHasContent } from "./note-state.mjs";
76
+ import { hasLexicalEntries } from "./lexical-sections.mjs";
76
77
  import { draftNoticeFor } from "./draft-notice.mjs";
77
78
  import { scanFigures } from "./content-figures.mjs";
78
79
  import { separateFootnotes } from "./content-footnotes.mjs";
@@ -102,9 +103,9 @@ const WITHHELD_OWNERSHIP = 0;
102
103
  * @param {string} markdown - A note's body, as {@link splitPages} receives it.
103
104
  * @returns {Set<number>} 0-based line indices inside a named block.
104
105
  */
105
- function namedBlockLines(markdown) {
106
+ function namedBlockLines(markdown, syntax) {
106
107
  const lines = new Set();
107
- for (const { start, end } of scanBlocks(markdown).blocks) {
108
+ for (const { start, end } of (syntax ? syntax(markdown).blocks : scanBlocks(markdown)).blocks) {
108
109
  for (let i = start; i <= end; i++) lines.add(i);
109
110
  }
110
111
  return lines;
@@ -141,17 +142,18 @@ function namedBlockLines(markdown) {
141
142
  * picture's address to the role its asset declares, so a figure page's
142
143
  * name reads `Map 1` rather than `Figure 1` where it is due — see
143
144
  * {@link module:engine/content-figures.scanFigures}.
145
+ * @param {import("./content-session.mjs").ContentSyntax} [syntax] - Session scanner projections.
144
146
  * @returns {Array<{name: string, anchorSlug: string|null, level: number,
145
147
  * classes: string[], figure: object|null, continues?: string,
146
148
  * markdown: string}>} Pages in document order.
147
149
  */
148
- export function splitPages(body, leadName = "Introduction", resolveRole) {
150
+ export function splitPages(body, leadName = "Introduction", resolveRole, syntax) {
149
151
  const { markdown, definitions } = separateFootnotes(body);
150
152
  const lines = markdown.split("\n");
151
153
  const figureStarts = new Map(
152
154
  scanFigures(markdown, { resolveRole }).figures.map((figure) => [figure.line - 1, figure]),
153
155
  );
154
- const blockLines = namedBlockLines(markdown);
156
+ const blockLines = namedBlockLines(markdown, syntax);
155
157
  const openings = pageOpenings(markdown, blockLines);
156
158
  const pages = [];
157
159
  const beforeFirstH1 = [];
@@ -635,6 +637,7 @@ export function buildPages(rawPages, entryId, noteName, figures, resolveRole) {
635
637
  * a picture's address to the role its asset declares, so a figure page's
636
638
  * name reads `Map 1` rather than `Figure 1` where it is due — see
637
639
  * {@link module:engine/content-figures.scanFigures}.
640
+ * @param {import("./content-session.mjs").ContentSyntax} [params.syntax] - Session scanner projections.
638
641
  * @returns {object} The JournalEntry document, keyed for the pack.
639
642
  */
640
643
  export function buildJournalEntry({
@@ -647,8 +650,9 @@ export function buildJournalEntry({
647
650
  infoboxes = [],
648
651
  notice = "",
649
652
  resolveRole,
653
+ syntax,
650
654
  }) {
651
- const rawPages = splitPages(markdown, leadName, resolveRole);
655
+ const rawPages = splitPages(markdown, leadName, resolveRole, syntax);
652
656
  const pages = buildPages(
653
657
  rawPages,
654
658
  id,
@@ -760,13 +764,15 @@ export class Journals extends BasePackCompiler {
760
764
  * An entry holding only its infobox is empty by this test. The panel is a
761
765
  * rendering of `data:` that the content index already carries, so a
762
766
  * compendium entry of nothing else tells a reader what the index told them.
767
+ * A frontmatter dictionary or glossary supplies pages of its own, so it
768
+ * qualifies before the shared body expansion inserts it.
763
769
  *
764
770
  * @param {object} fm - The note's frontmatter.
765
771
  * @param {string} body - The note body, frontmatter stripped.
766
772
  * @returns {boolean} True to skip the note.
767
773
  */
768
774
  skipNote(fm, body) {
769
- return !journalHasContent(body);
775
+ return !journalHasContent(body) && !hasLexicalEntries(fm);
770
776
  }
771
777
 
772
778
  /**
@@ -824,6 +830,7 @@ export class Journals extends BasePackCompiler {
824
830
  const roles = pathnameRoles(this.linkIndex);
825
831
 
826
832
  return buildJournalEntry({
833
+ syntax: this.corpus?.syntax,
827
834
  id,
828
835
  name,
829
836
  markdown: [
@@ -44,7 +44,7 @@
44
44
  * syllables, its consonants per vowel or both, each range's `min` no
45
45
  * higher than its `max`; an edge stands once for each class of name; a kind
46
46
  * of name has one set of draws; and a held-from row takes `elements` only
47
- * where it takes an `element`, and is clipped or given a genitive only
47
+ * where it takes an `element`, and is clipped or given a joining ending only
48
48
  * where it takes a name.
49
49
  *
50
50
  * @module
@@ -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
  *
@@ -574,7 +627,7 @@ export function checkLanguageData(note, { index, types } = {}) {
574
627
  ),
575
628
  );
576
629
  if (!HELD_NAMES.includes(row.takes))
577
- for (const key of ["clip", "genitive"])
630
+ for (const key of ["clip", "joining"])
578
631
  if (row[key] != null)
579
632
  findings.push(
580
633
  finding(
@@ -122,7 +122,7 @@ export const HELD_FROM_COLUMNS = Object.freeze([
122
122
  "source",
123
123
  "takes",
124
124
  "clip",
125
- "genitive",
125
+ "joining",
126
126
  "elements",
127
127
  ]);
128
128
  /** Every column of the `near_names` relation, in order. */
@@ -232,7 +232,7 @@ export const LANGUAGE_COLUMN_TYPES = Object.freeze({
232
232
  },
233
233
  name_edges: { position: "BIGINT", sounds: LIST, vowel: "BOOLEAN" },
234
234
  name_draws: { position: "BIGINT", elements: LIST },
235
- held_from: { position: "BIGINT", genitive: LIST, elements: LIST },
235
+ held_from: { position: "BIGINT", joining: LIST, elements: LIST },
236
236
  near_names: { position: "BIGINT", min_syllables: "BIGINT" },
237
237
  stress: {},
238
238
  finals: { position: "BIGINT" },
@@ -653,8 +653,8 @@ export function heldFromRows(records) {
653
653
  return listRows(
654
654
  records,
655
655
  (data) => namingOf(data).heldFrom,
656
- ["source", "takes", "clip", "genitive", "elements"],
657
- { lists: ["genitive", "elements"] },
656
+ ["source", "takes", "clip", "joining", "elements"],
657
+ { lists: ["joining", "elements"] },
658
658
  );
659
659
  }
660
660
 
@@ -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
  /**
@@ -1141,10 +1142,10 @@ export const ELEMENT_FIELDS = Object.freeze([
1141
1142
  "ancestor.",
1142
1143
  }),
1143
1144
  Object.freeze({
1144
- name: "genitive",
1145
+ name: "joining",
1145
1146
  kind: "string",
1146
1147
  shape: "the element as written, such as `-borgar`",
1147
- describe: "The element in the genitive, where a name governing another word takes it.",
1148
+ describe: "The element's joining form, as written when it qualifies another word.",
1148
1149
  }),
1149
1150
  ]);
1150
1151
 
@@ -1471,11 +1472,11 @@ export const HELD_FROM_FIELDS = Object.freeze([
1471
1472
  describe: "How the name is clipped: `first`, to its first element; whole where absent.",
1472
1473
  }),
1473
1474
  Object.freeze({
1474
- name: "genitive",
1475
+ name: "joining",
1475
1476
  kind: "list",
1476
1477
  shape: "a list of endings",
1477
1478
  entries: STRINGS,
1478
- describe: "The genitive endings, one of which stands at the seam after the name.",
1479
+ describe: "The joining endings, one of which stands at the seam after the name.",
1479
1480
  }),
1480
1481
  Object.freeze({
1481
1482
  name: "elements",
@@ -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
  );