@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
@@ -36,19 +36,19 @@
36
36
  * where it is a word of its language compared without case. A title of
37
37
  * several words, joined by a space or a hyphen, always is.
38
38
  *
39
- * **A glossary entry with a `definition`** replaces the senses of its owner's
40
- * own rank or office of that name, and otherwise is a term of its owner's own,
41
- * cited from the owner. **An entry without one** marks what it names: the term
42
- * of its name in the language it resolves to
43
- * ({@link module:engine/glossary-language}), among the words and the ranks and
44
- * offices that reach its note, and failing that the term of its name in any
45
- * language that reaches it. Marking sets `common`, and replaces `name_sort`
46
- * and `pronunciation`, on the word's row in that language or on the rank's or
47
- * office's row.
39
+ * **A glossary entry naming a dictionary word** carries the word's class and
40
+ * a link to its entry in the resolved language's dictionary. Without a
41
+ * `definition` it carries the word's senses; with one it carries that sense
42
+ * instead. An owner's own rank or office contributes its descriptions after
43
+ * the word's senses, unless the entry states a definition. An entry naming
44
+ * another body's rank or office carries its senses on the entry's own row.
45
+ * `common`, `name_sort` and `pronunciation` belong to each owner's entry;
46
+ * nothing a glossary states changes a dictionary word.
48
47
  *
49
48
  * @module
50
49
  */
51
50
 
51
+ import { createHash } from "node:crypto";
52
52
  import { parseAddress, renderAddress } from "./address.mjs";
53
53
  import { glossaryLanguage } from "./glossary-language.mjs";
54
54
  import { isNoteRecord } from "./index-records.mjs";
@@ -65,10 +65,12 @@ export const DICTIONARY_COLUMNS = Object.freeze([
65
65
  "letter",
66
66
  "pronunciation",
67
67
  "class",
68
+ "plural",
69
+ "field",
68
70
  "etymology",
69
71
  "senses",
70
72
  "usage",
71
- "common",
73
+ "verbs",
72
74
  "depth",
73
75
  ]);
74
76
 
@@ -82,6 +84,8 @@ export const TERM_COLUMNS = Object.freeze([
82
84
  "name_sort",
83
85
  "letter",
84
86
  "pronunciation",
87
+ "class",
88
+ "word",
85
89
  "senses",
86
90
  "usage",
87
91
  "common",
@@ -126,6 +130,34 @@ const isSingleWord = (title) => !/[\s\-‐‑]/u.test(title);
126
130
  /** Whether a glossary entry states a definition. */
127
131
  const defines = (entry) => typeof entry.definition === "string" && entry.definition.trim() !== "";
128
132
 
133
+ /** A noun's stated plural, or the first literal ending replacement that fits. */
134
+ function pluralOf(word, grammar) {
135
+ if (parseWordClass(word.class).base !== "n") return null;
136
+ if (typeof word.plural === "string" && word.plural) return word.plural;
137
+ for (const rule of Array.isArray(grammar?.plural) ? grammar.plural : []) {
138
+ if (typeof rule?.ending !== "string" || typeof rule.becomes !== "string") continue;
139
+ if (word.form.endsWith(rule.ending))
140
+ return (
141
+ (rule.ending ? word.form.slice(0, -rule.ending.length) : word.form) + rule.becomes
142
+ );
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * The stable prose anchor of a word in one language's dictionary. The exact
149
+ * language and NFC form identify it, including case and accents.
150
+ *
151
+ * @param {string} language - The language's canonical note Address.
152
+ * @param {string} name - The dictionary headword.
153
+ * @returns {string} The anchor slug.
154
+ */
155
+ export function dictionaryEntryAnchor(language, name) {
156
+ return `word-${createHash("sha256")
157
+ .update(JSON.stringify([language, nfc(name)]))
158
+ .digest("hex")}`;
159
+ }
160
+
129
161
  /**
130
162
  * The heading letter of a sort key: its first letter or digit, accents taken
131
163
  * off and apostrophes skipped, in capitals; `#` where it has none.
@@ -259,10 +291,11 @@ export function dictionaryAndTermRows(records, all = records) {
259
291
  byForm.set(key, exact);
260
292
  byLowerForm.set(key, lower);
261
293
  }
262
- /** The word of a form as a language lists it, nearest in its lineage first. */
294
+ /** A form's nearest visible definition, traversing the complete lineage. */
263
295
  const wordIn = (language, name) => {
264
296
  const chain = lineage(language);
265
297
  for (let depth = 0; depth < chain.length; depth++) {
298
+ if (!visible.has(chain[depth])) continue;
266
299
  const word = byForm.get(chain[depth]).get(name);
267
300
  if (word) return { word, statedIn: chain[depth], depth };
268
301
  }
@@ -377,7 +410,7 @@ export function dictionaryAndTermRows(records, all = records) {
377
410
  }
378
411
  const allGovernance = [...governance.values()].flatMap((rows) => [...rows.values()]);
379
412
 
380
- // ── Word rows, before marks ──────────────────────────────────────────────
413
+ // ── Word rows ────────────────────────────────────────────────────────────
381
414
  const wordRows = new Map();
382
415
  const wordKey = (language, name) => `${language}\0${name}`;
383
416
  for (const [language] of languages) {
@@ -388,7 +421,7 @@ export function dictionaryAndTermRows(records, all = records) {
388
421
  const name = nfc(word.form);
389
422
  const key = wordKey(language, name);
390
423
  if (wordRows.has(key)) continue;
391
- wordRows.set(key, { language, name, found: wordIn(language, name), marks: [] });
424
+ wordRows.set(key, { language, name, found: wordIn(language, name) });
392
425
  }
393
426
  }
394
427
  }
@@ -404,48 +437,35 @@ export function dictionaryAndTermRows(records, all = records) {
404
437
  culture ?
405
438
  allGovernance.filter((row) => row.cultures.includes(note))
406
439
  : [...(governance.get(note)?.values() ?? [])];
407
- const reachLanguages = new Set([
408
- languageOf(record, note, undefined),
409
- ...(culture ? reach.map((row) => row.language) : []),
410
- ]);
411
- reachLanguages.delete(null);
412
440
  glossaryOf(note).forEach((entry, position) => {
413
441
  const name = nfc(entry.name);
414
442
  const language = languageOf(record, note, entry);
415
443
  const mark = { note, position, entry };
416
- if (defines(entry)) {
417
- const own = !culture ? governance.get(note)?.get(name) : undefined;
418
- if (own) {
419
- own.definition = mark;
420
- own.marks.push(mark);
421
- own.named = true;
422
- } else
423
- owned.push({
424
- owner: note,
425
- ownerKind: culture ? "culture" : "affiliation",
426
- language,
427
- name,
428
- cultures: culture ? [note] : culturesOf(note),
429
- mark,
430
- });
444
+ const found = wordRows.get(wordKey(language, name))?.found;
445
+ const own = !culture ? governance.get(note)?.get(name) : undefined;
446
+ if (own) {
447
+ own.language = language;
448
+ own.found = found;
449
+ if (defines(entry)) own.definition = mark;
450
+ own.marks.push(mark);
451
+ own.named = true;
431
452
  return;
432
453
  }
433
- const stated = typeof entry.language === "string" && entry.language;
434
- const among = stated ? new Set([language]) : reachLanguages;
435
- const found = [];
436
- for (const candidate of among)
437
- if (wordIn(candidate, name)) {
438
- const row = wordRows.get(wordKey(candidate, name));
439
- if (row) found.push({ language: candidate, row });
440
- }
441
- for (const row of reach)
442
- if (row.name === name && among.has(row.language))
443
- found.push({ language: row.language, row });
444
- const preferred = found.filter((one) => one.language === language);
445
- for (const { row } of preferred.length ? preferred : found) {
446
- row.marks.push(mark);
447
- row.named = true;
448
- }
454
+ const reached = reach.filter((row) => row.name === name);
455
+ const preferred = reached.filter((row) => row.language === language);
456
+ const providers = preferred.length ? preferred : reached;
457
+ if (!defines(entry)) for (const row of providers) row.named = true;
458
+ if (defines(entry) || found || providers.length)
459
+ owned.push({
460
+ owner: note,
461
+ ownerKind: culture ? "culture" : "affiliation",
462
+ language,
463
+ name,
464
+ cultures: culture ? [note] : culturesOf(note),
465
+ found,
466
+ providers: found || defines(entry) ? [] : providers,
467
+ mark,
468
+ });
449
469
  });
450
470
  }
451
471
 
@@ -484,38 +504,50 @@ export function dictionaryAndTermRows(records, all = records) {
484
504
 
485
505
  const citedBy = citationLookup(byKey.values());
486
506
  const dictionary = [];
487
- for (const { language, name, found, marks } of wordRows.values()) {
488
- const { word, depth, statedIn } = found;
507
+ const wordUsage = ({ word, statedIn }) =>
508
+ [...new Set(citedBy(statedIn, word.form).filter(citable))].sort(compare);
509
+ for (const { language, name, found } of wordRows.values()) {
510
+ const { word, depth } = found;
511
+ const grammar = languages.get(language)?.data?.grammar;
489
512
  const nameSort =
490
- marked(marks, "name_sort") ??
491
- (typeof word.name_sort === "string" && word.name_sort ? word.name_sort : name);
513
+ typeof word.name_sort === "string" && word.name_sort ? word.name_sort : name;
492
514
  dictionary.push({
493
515
  language,
494
516
  language_tag: tagOf(language),
495
517
  name,
496
518
  name_sort: nameSort,
497
519
  letter: dictionaryLetter(nameSort),
498
- pronunciation:
499
- marked(marks, "pronunciation") ??
500
- (typeof word.pronunciation === "string" ? word.pronunciation : null),
520
+ pronunciation: typeof word.pronunciation === "string" ? word.pronunciation : null,
501
521
  class: typeof word.class === "string" ? word.class : null,
522
+ plural: pluralOf(word, grammar),
523
+ field: typeof word.field === "string" ? word.field : null,
502
524
  etymology: etymologyOf(found),
503
525
  senses: glossSenses(word.gloss).map((text) => ({ text, bodies: [] })),
504
- usage: [...new Set(citedBy(statedIn, word.form).filter(citable))].sort(compare),
505
- common: marks.some((one) => one.entry.common === true),
526
+ usage: wordUsage(found),
527
+ verbs: isMap(grammar?.verbs) ? grammar.verbs : null,
506
528
  depth,
507
529
  });
508
530
  }
509
531
 
510
532
  const terms = [];
511
533
  const termRow = (
512
- { owner, ownerKind, language, name, cultures },
534
+ { owner, ownerKind, language, name, cultures, found },
513
535
  senses,
514
536
  usage,
515
537
  marks,
516
538
  origins,
517
539
  ) => {
518
- const nameSort = marked(marks, "name_sort") ?? name;
540
+ const word = found?.word;
541
+ const wordLanguage =
542
+ (
543
+ Array.isArray(languages.get(language)?.data?.words) &&
544
+ languages.get(language).data.words.length
545
+ ) ?
546
+ language
547
+ : found?.statedIn;
548
+ const nameSort =
549
+ marked(marks, "name_sort") ??
550
+ (typeof word?.name_sort === "string" && word.name_sort ? word.name_sort : name);
519
551
  terms.push({
520
552
  owner,
521
553
  owner_kind: ownerKind,
@@ -524,7 +556,9 @@ export function dictionaryAndTermRows(records, all = records) {
524
556
  name,
525
557
  name_sort: nameSort,
526
558
  letter: dictionaryLetter(nameSort),
527
- pronunciation: marked(marks, "pronunciation") ?? null,
559
+ pronunciation: marked(marks, "pronunciation") ?? word?.pronunciation ?? null,
560
+ class: word?.class ?? null,
561
+ word: found ? `${wordLanguage}#${dictionaryEntryAnchor(wordLanguage, name)}` : null,
528
562
  senses,
529
563
  usage: [...new Set(usage)].filter(citable).sort(compare),
530
564
  common: marks.some((one) => one.entry.common === true),
@@ -532,34 +566,50 @@ export function dictionaryAndTermRows(records, all = records) {
532
566
  cultures,
533
567
  });
534
568
  };
569
+ const combineSenses = (senses) => {
570
+ const combined = [];
571
+ for (const sense of senses) {
572
+ const same = combined.find((one) => one.text === sense.text);
573
+ if (!same) combined.push({ ...sense, bodies: [...sense.bodies] });
574
+ else
575
+ for (const body of sense.bodies)
576
+ if (!same.bodies.includes(body)) same.bodies.push(body);
577
+ }
578
+ return combined;
579
+ };
580
+ const governanceSenses = (row) => {
581
+ if (row.definition) return [{ text: row.definition.entry.definition, bodies: [row.owner] }];
582
+ return combineSenses([
583
+ ...glossSenses(row.found?.word.gloss).map((text) => ({ text, bodies: [] })),
584
+ ...[...row.pieces]
585
+ .sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind])
586
+ .filter((piece) => piece.text?.trim())
587
+ .map((piece) => ({ text: piece.text, bodies: [row.owner] })),
588
+ ]);
589
+ };
535
590
  for (const row of allGovernance) {
536
591
  const enters =
537
592
  !isSingleWord(row.name) ||
538
593
  row.named ||
539
594
  (row.language !== null && hasWordIgnoringCase(row.language, row.name));
540
595
  if (!enters) continue;
541
- const senses = [];
542
- if (row.definition)
543
- senses.push({ text: row.definition.entry.definition, bodies: [row.owner] });
544
- else
545
- for (const piece of [...row.pieces].sort(
546
- (a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind],
547
- ))
548
- if (piece.text?.trim() && !senses.some((sense) => sense.text === piece.text))
549
- senses.push({ text: piece.text, bodies: [row.owner] });
550
- termRow(row, senses, [...row.usage], row.marks, [
596
+ termRow(row, governanceSenses(row), [...row.usage], row.marks, [
551
597
  ...row.pieces.map((piece) => piece.kind),
552
598
  ...(row.marks.length ? ["glossary"] : []),
553
599
  ]);
554
600
  }
555
- for (const one of owned)
556
- termRow(
557
- one,
558
- [{ text: one.mark.entry.definition, bodies: [one.owner] }],
559
- [one.owner],
560
- [one.mark],
561
- ["glossary"],
562
- );
601
+ for (const one of owned) {
602
+ const defined = defines(one.mark.entry);
603
+ const senses =
604
+ defined ? [{ text: one.mark.entry.definition, bodies: [one.owner] }]
605
+ : one.found ? glossSenses(one.found.word.gloss).map((text) => ({ text, bodies: [] }))
606
+ : combineSenses(one.providers.flatMap(governanceSenses));
607
+ const usage =
608
+ defined ? [one.owner]
609
+ : one.found ? wordUsage(one.found)
610
+ : [one.owner, ...one.providers.flatMap((row) => [...row.usage])];
611
+ termRow(one, senses, usage, [one.mark], ["glossary"]);
612
+ }
563
613
 
564
614
  dictionary.sort(
565
615
  (a, b) =>
@@ -78,6 +78,8 @@ import { isHomepage } from "./homepage.mjs";
78
78
  import { declaresNoPack } from "./pack-router.mjs";
79
79
  import { assertNoDeclaredPackage } from "./note-package.mjs";
80
80
  import { collectAnchors } from "./anchors.mjs";
81
+ import { scanSpans } from "./content-spans.mjs";
82
+ import { DICTIONARY_ENTRY_CLASS } from "./dictionary-layout.mjs";
81
83
  import { journalPageId, splitPages } from "./journals.mjs";
82
84
  import { routerFor } from "./pack-router.mjs";
83
85
  import { loadPackConfig } from "./pack-config.mjs";
@@ -94,7 +96,9 @@ import { publishesContentPages } from "../content-config.mjs";
94
96
  export const LEAD_ANCHOR = "$lead";
95
97
 
96
98
  /**
97
- * Every page of a note's journal, as `anchorName → whole UUID`.
99
+ * Every anchor of a note's journal, as `anchorName → Foundry target`.
100
+ * A dictionary word's native heading carries its fragment after the
101
+ * containing page's UUID.
98
102
  *
99
103
  * Whole, not a fragment appended to the entry's UUID: nothing owns a page
100
104
  * address, so a complete link restates no fact, and it keeps the page-id hash
@@ -112,9 +116,20 @@ export function anchorsOf(entryUuid, entryId, body, name) {
112
116
  const anchors = {};
113
117
  splitPages(body, name).forEach((page, index) => {
114
118
  const uuid = pageUuid(entryUuid, journalPageId(entryId, page));
119
+ const words = new Set(
120
+ scanSpans(page.markdown)
121
+ .spans.filter((span) =>
122
+ span.attrs.some(
123
+ ([key, value]) =>
124
+ key === "class" && value.split(/\s+/).includes(DICTIONARY_ENTRY_CLASS),
125
+ ),
126
+ )
127
+ .map((span) => span.id),
128
+ );
115
129
  if (index === 0) anchors[LEAD_ANCHOR] = uuid;
116
130
  if (page.anchorSlug) anchors[page.anchorSlug] = uuid;
117
- for (const anchor of collectAnchors(page.markdown)) anchors[anchor.slug] = uuid;
131
+ for (const anchor of collectAnchors(page.markdown))
132
+ anchors[anchor.slug] = words.has(anchor.slug) ? `${uuid}#${anchor.slug}` : uuid;
118
133
  });
119
134
  return anchors;
120
135
  }
@@ -290,7 +305,10 @@ export function entriesForNote(fm, name, address, body, ctx) {
290
305
  id: fm.id,
291
306
  uuid: own,
292
307
  systemUuids: systemUuidsFor(fm.type, fm.id),
293
- anchors: own && fm.type === "doc" ? anchorsOf(own, fm.id, body ?? "", name) : undefined,
308
+ anchors:
309
+ own && packForType(fm.type)?.docType === "JournalEntry" ?
310
+ anchorsOf(own, fm.id, body ?? "", name)
311
+ : undefined,
294
312
  },
295
313
  ];
296
314
  }
@@ -16,12 +16,12 @@
16
16
  * `lore` note of `subType: culture` or on an `affiliation`, a list of
17
17
  * `{ name, name_sort?, common?, definition?, pronunciation?, language? }`.
18
18
  *
19
- * It holds the terms that belong to the people or body using them rather than
20
- * to a language's grammar: euphemisms, idioms, terms of art, and titles that
21
- * are not ranks. An entry with a `definition` creates a term or replaces every
22
- * sense the term's other sources give it. An entry without one changes no
23
- * sense, and only marks a term the note already has — a word of a language
24
- * that reaches it, or a rank or office — as common, sorted or pronounced.
19
+ * An entry chooses a common word, gives a dictionary word a contextual
20
+ * meaning, or defines a phrase, compound or term of art. An entry naming a
21
+ * word of its resolved language carries that word's senses and class unless
22
+ * its own `definition` supplies the sense. A rank or office can also be named.
23
+ * Common marks, sort keys and pronunciation overrides belong to the entry's
24
+ * own glossary.
25
25
  *
26
26
  * The shape is the inner-key check's; this check owns what an entry means:
27
27
  *
@@ -88,7 +88,7 @@ export function checkGlossary(note, { index } = {}) {
88
88
  if (!Array.isArray(data.glossary)) return [];
89
89
 
90
90
  const findings = [];
91
- const terms = index?.notes ? noteTerms(index, note) : undefined;
91
+ const hasTerms = Boolean(index?.notes);
92
92
  const seen = new Set();
93
93
  data.glossary.forEach((entry, i) => {
94
94
  if (!isMap(entry) || typeof entry.name !== "string" || !entry.name.trim()) return;
@@ -116,7 +116,7 @@ export function checkGlossary(note, { index } = {}) {
116
116
  ),
117
117
  );
118
118
  }
119
- if (!terms) return;
119
+ if (!hasTerms) return;
120
120
  const stated = entry.language !== undefined && entry.language !== null;
121
121
  const named = stated ? languageProblem(index, entry.language) : undefined;
122
122
  if (named) findings.push(at([i, "language"], "error", named));
@@ -133,15 +133,15 @@ export function checkGlossary(note, { index } = {}) {
133
133
  "; state `language` on the entry or `data.language` on the note",
134
134
  ),
135
135
  );
136
- const senses = terms.get(name);
136
+ const senses = noteTerms(index, note, entry).get(name);
137
137
  const defined = typeof entry.definition === "string" && entry.definition.trim() !== "";
138
138
  if (!defined && !senses)
139
139
  findings.push(
140
140
  at(
141
141
  [i, "name"],
142
142
  "error",
143
- `\`${entry.name}\` names nothing: it is no word of a language that reaches ` +
144
- "this note and no rank or office it holds, and the entry gives no " +
143
+ `\`${entry.name}\` names nothing: it is no word of its resolved language ` +
144
+ "and no rank or office that reaches this note, and the entry gives no " +
145
145
  "`definition` to make it a term",
146
146
  ),
147
147
  );
@@ -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,6 +74,7 @@ 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";
@@ -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)
@@ -781,12 +783,14 @@ import { collectAnchors } from "./anchors.mjs";
781
783
  * omitted.
782
784
  * @param {readonly object[]} [opts.records] - The corpus, derived once per
783
785
  * compile and handed in. Required: see {@link assertSuppliedCorpus}.
786
+ * @param {Map<string, object[]>} [opts.sqlTables] - Prepared directives,
787
+ * including the anchors and Foundry page maps of their expanded bodies.
784
788
  * @returns {{byShortcode: Map, types: Set}} From `buildWikilinkIndex`.
785
789
  */
786
790
  export function buildContentLinkIndex(
787
791
  contentBase,
788
792
  router = packRouter(),
789
- { config, records } = {},
793
+ { config, records, sqlTables } = {},
790
794
  ) {
791
795
  const docs = [];
792
796
  /** The files this package ships, by canonical address. */
@@ -812,6 +816,7 @@ export function buildContentLinkIndex(
812
816
  // authored fields, and neither may be handed the keys the index derived.
813
817
  const fm = authoredFrontmatter(record);
814
818
  const absPath = noteFile(contentBase, record);
819
+ const prepared = sqlTables?.get(absPath);
815
820
  // The id a note's document is filed under: its authored pin, or the
816
821
  // one derived from its canonical address. Derived by the index
817
822
  // against the configuration this build resolved — never here through
@@ -861,11 +866,12 @@ export function buildContentLinkIndex(
861
866
  // discarded here — the walk yields the body and nothing read it.
862
867
  // Read from the record rather than from a second reading of the
863
868
  // note's headings — the one-anchor-reader rule.
864
- anchors: new Set(
865
- (record.anchors ?? [])
869
+ anchors: new Set([
870
+ ...(record.anchors ?? [])
866
871
  .filter((anchor) => anchor.kind === "prose")
867
872
  .map((anchor) => anchor.slug),
868
- ),
873
+ ...(prepared?.anchors ?? []).map((anchor) => anchor.slug),
874
+ ]),
869
875
  // The note's event ids, which a wikilink may not name: a link to
870
876
  // one is refused with its own message rather than as unknown.
871
877
  eventAnchors: new Set(
@@ -873,8 +879,8 @@ export function buildContentLinkIndex(
873
879
  .filter((anchor) => anchor.kind === "event")
874
880
  .map((anchor) => anchor.slug),
875
881
  ),
876
- anchorUuids: record.foundry?.note?.anchors,
877
- docAnchorUuids: documentation?.foundry?.note?.anchors,
882
+ anchorUuids: prepared?.anchorUuids ?? record.foundry?.note?.anchors,
883
+ docAnchorUuids: prepared?.docAnchorUuids ?? documentation?.foundry?.note?.anchors,
878
884
  });
879
885
  }
880
886
  // Packages this build links *into* but does not publish. Each publishes
@@ -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";
@@ -760,13 +761,15 @@ export class Journals extends BasePackCompiler {
760
761
  * An entry holding only its infobox is empty by this test. The panel is a
761
762
  * rendering of `data:` that the content index already carries, so a
762
763
  * compendium entry of nothing else tells a reader what the index told them.
764
+ * A frontmatter dictionary or glossary supplies pages of its own, so it
765
+ * qualifies before the shared body expansion inserts it.
763
766
  *
764
767
  * @param {object} fm - The note's frontmatter.
765
768
  * @param {string} body - The note body, frontmatter stripped.
766
769
  * @returns {boolean} True to skip the note.
767
770
  */
768
771
  skipNote(fm, body) {
769
- return !journalHasContent(body);
772
+ return !journalHasContent(body) && !hasLexicalEntries(fm);
770
773
  }
771
774
 
772
775
  /**