@heroiclands/package-build 23.7.0 → 23.8.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 (71) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +32 -0
  3. package/bin/content-commands.mjs +20 -6
  4. package/ci/check-banners.mjs +37 -19
  5. package/container.mjs +48 -10
  6. package/docs/api.md +87 -43
  7. package/docs/authoring/frontmatter.md +30 -1
  8. package/docs/authoring/links-and-markup.md +229 -36
  9. package/docs/commands.md +12 -2
  10. package/docs/content-format.md +24 -1
  11. package/docs/design.md +31 -0
  12. package/docs/guides/e2e.md +37 -1
  13. package/docs/project-setup.md +30 -15
  14. package/docs/reference/format-details.md +418 -220
  15. package/docs/reference/note-types.md +90 -31
  16. package/e2e.mjs +89 -2
  17. package/engine/base-compiler.mjs +11 -18
  18. package/engine/compile-corpus.mjs +2 -1
  19. package/engine/content-alerts.mjs +4 -2
  20. package/engine/content-blocks.mjs +18 -36
  21. package/engine/content-format.yaml +3 -0
  22. package/engine/content-index.mjs +35 -22
  23. package/engine/content-poetry.mjs +9 -0
  24. package/engine/diagnostics.mjs +24 -11
  25. package/engine/frontmatter-lint.mjs +1 -1
  26. package/engine/generate.mjs +12 -22
  27. package/engine/helpers.mjs +8 -6
  28. package/engine/infobox.mjs +3 -0
  29. package/engine/journals.mjs +11 -1
  30. package/engine/language-data.mjs +236 -1
  31. package/engine/language-rows.mjs +235 -3
  32. package/engine/language-terms.mjs +524 -9
  33. package/engine/language-words.mjs +75 -0
  34. package/engine/markdownlint-run.mjs +12 -2
  35. package/engine/markup-validation.mjs +34 -0
  36. package/engine/note-addresses.mjs +1 -0
  37. package/engine/note-claims.mjs +19 -10
  38. package/engine/note-package.mjs +3 -5
  39. package/engine/note-vocabulary.mjs +29 -1
  40. package/engine/pdf-build.mjs +8 -1
  41. package/engine/prose-lint.mjs +12 -6
  42. package/engine/related-pages.mjs +22 -3
  43. package/engine/see-also.mjs +150 -0
  44. package/engine/site-build.mjs +5 -1
  45. package/engine/site-preview.mjs +4 -2
  46. package/engine/sql-tables.mjs +37 -12
  47. package/engine/standings.mjs +14 -12
  48. package/engine/word-rows.mjs +8 -3
  49. package/engine/word-terms.mjs +60 -2
  50. package/hugo-theme/layouts/partials/related.html +25 -8
  51. package/package.json +2 -1
  52. package/types/container.d.mts +4 -3
  53. package/types/e2e.d.mts +28 -0
  54. package/types/engine/base-compiler.d.mts +2 -2
  55. package/types/engine/compile-corpus.d.mts +2 -1
  56. package/types/engine/content-index.d.mts +20 -7
  57. package/types/engine/diagnostics.d.mts +7 -6
  58. package/types/engine/helpers.d.mts +7 -4
  59. package/types/engine/language-rows.d.mts +89 -3
  60. package/types/engine/language-terms.d.mts +106 -4
  61. package/types/engine/markdownlint-run.d.mts +3 -1
  62. package/types/engine/markup-validation.d.mts +14 -0
  63. package/types/engine/note-addresses.d.mts +10 -0
  64. package/types/engine/note-claims.d.mts +7 -1
  65. package/types/engine/note-package.d.mts +3 -1
  66. package/types/engine/note-vocabulary.d.mts +1 -0
  67. package/types/engine/related-pages.d.mts +10 -1
  68. package/types/engine/see-also.d.mts +55 -0
  69. package/types/engine/sql-tables.d.mts +7 -3
  70. package/types/engine/word-rows.d.mts +2 -1
  71. package/types/engine/word-terms.d.mts +11 -0
@@ -62,6 +62,7 @@ import { WITHHELD_CLASS, parseHeadingLine, withheldSections } from "./heading-at
62
62
  * and a stray `<section>` would close the block early.
63
63
  */
64
64
  const titleParser = new MarkdownIt({ html: false });
65
+ const structureParser = new MarkdownIt({ html: true });
65
66
 
66
67
  /** The blocks an author may open, with the heading each takes by default. */
67
68
  export const BLOCK_NAMES = Object.freeze({
@@ -99,28 +100,24 @@ function escapeAttribute(value) {
99
100
  * @returns {{blocks: Array<{name: string, title: string, id: string, classes: string[], attributes: Record<string, string>, start: number, end: number, body: string}>, errors: Array<{line: number, column: number, message: string}>}}
100
101
  */
101
102
  export function scanBlocks(source) {
102
- const lines = String(source ?? "").split("\n"),
103
+ const lines = String(source ?? "").split(/\r?\n/),
103
104
  blocks = [],
104
105
  errors = [];
105
106
  const stack = [];
106
- let fence = null;
107
+ // A bare fence can belong to a quote through lazy continuation.
108
+ // Parser ranges decide whether it can delimit a container.
109
+ const literalLines = new Set();
110
+ for (const token of structureParser.parse(String(source ?? ""), {})) {
111
+ if (
112
+ !token.map ||
113
+ !["blockquote_open", "fence", "code_block", "html_block"].includes(token.type)
114
+ )
115
+ continue;
116
+ for (let line = token.map[0]; line < token.map[1]; line++) literalLines.add(line);
117
+ }
107
118
  for (let i = 0; i < lines.length; i++) {
119
+ if (literalLines.has(i)) continue;
108
120
  const line = lines[i];
109
- const code = /^ *(`{3,}|~{3,})(.*)$/.exec(line);
110
- if (fence) {
111
- if (
112
- code &&
113
- code[1][0] === fence[0] &&
114
- code[1].length >= fence.length &&
115
- !code[2].trim()
116
- )
117
- fence = null;
118
- continue;
119
- }
120
- if (code) {
121
- fence = code[1];
122
- continue;
123
- }
124
121
  const close = /^ *:::\s*$/.test(line);
125
122
  const named = /^ *:::([A-Za-z][A-Za-z0-9-]*)(?:\s+(.*))?$/.exec(line);
126
123
  const div = /^ *:::\s+(\{.*)$/.exec(line);
@@ -135,7 +132,7 @@ export function scanBlocks(source) {
135
132
  if (!block.body)
136
133
  errors.push({
137
134
  line: block.start + 1,
138
- column: 1,
135
+ column: lines[block.start].indexOf(":::") + 1,
139
136
  message: `${block.name} block is empty`,
140
137
  });
141
138
  if (!stack.length) blocks.push(block);
@@ -144,7 +141,7 @@ export function scanBlocks(source) {
144
141
  if (!close && !named && !div) continue;
145
142
  const name = named?.[1] ?? "div",
146
143
  raw = named?.[2] ?? div?.[1] ?? "";
147
- const at = { line: i + 1, column: 1 };
144
+ const at = { line: i + 1, column: line.indexOf(":::") + 1 };
148
145
  if (name !== "div" && !Object.hasOwn(BLOCK_NAMES, name)) {
149
146
  errors.push({ ...at, message: `there is no ${name} block; the blocks are ${names()}` });
150
147
  stack.push({ start: i, rejected: true });
@@ -189,27 +186,12 @@ export function scanBlocks(source) {
189
186
  if (!block.rejected)
190
187
  errors.push({
191
188
  line: block.start + 1,
192
- column: 1,
189
+ column: lines[block.start].indexOf(":::") + 1,
193
190
  message: `${block.name} block needs a closing ::: line`,
194
191
  });
195
192
  for (const block of blocks) {
196
- let literal = null;
197
193
  for (let i = block.start + 1; i < block.end; i++) {
198
- const marker = /^ *(`{3,}|~{3,})(.*)$/.exec(lines[i]);
199
- if (literal) {
200
- if (
201
- marker &&
202
- marker[1][0] === literal[0] &&
203
- marker[1].length >= literal.length &&
204
- !marker[2].trim()
205
- )
206
- literal = null;
207
- continue;
208
- }
209
- if (marker) {
210
- literal = marker[1];
211
- continue;
212
- }
194
+ if (literalLines.has(i)) continue;
213
195
  if (parseHeadingLine(lines[i])?.startsPage)
214
196
  errors.push({
215
197
  line: i + 1,
@@ -221,6 +221,9 @@ types:
221
221
  - roots
222
222
  - cognates
223
223
  - callingNames
224
+ - older
225
+ - negation
226
+ - naming
224
227
  subTypes:
225
228
  - social
226
229
  - nature
@@ -410,23 +410,25 @@ function foundryBlock(entry, system) {
410
410
  * @param {string} [absPath] - The file, read only on the failing path to locate
411
411
  * the offending key.
412
412
  * @param {string} [contentPackage] - The package this tree compiles as.
413
+ * @param {string} [raw] - Source text already read by the caller.
413
414
  * @returns {void}
414
415
  * @throws {Error} When the note authors a derived key. `file` and `position`
415
416
  * ride on the error.
416
417
  */
417
- function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage) {
418
+ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage, raw) {
418
419
  for (const key of DERIVED_KEYS) {
419
420
  if (!Object.hasOwn(frontmatter ?? {}, key)) continue;
420
421
  if (key === "package") {
421
422
  // Throws with its own wording, and its own position.
422
- assertNoDeclaredPackage(frontmatter, { absPath, configured: contentPackage });
423
+ assertNoDeclaredPackage(frontmatter, { absPath, configured: contentPackage, raw });
423
424
  }
424
425
  const err = new Error(
425
426
  `\`${key}:\` is derived by the content index and cannot be ` +
426
427
  `authored — rename the frontmatter field`,
427
428
  );
428
429
  err.file = relPath;
429
- const position = absPath ? locateFrontmatterKey(absPath, key) : undefined;
430
+ const position =
431
+ absPath ? locateFrontmatterKey(absPath, key, undefined, { raw }) : undefined;
430
432
  if (position) err.position = position;
431
433
  throw err;
432
434
  }
@@ -445,6 +447,7 @@ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage) {
445
447
  * @param {string} options.relPath - Its path below the content root, POSIX-separated.
446
448
  * @param {string} [options.absPath] - The file, read only on the failing path to
447
449
  * locate the offending key.
450
+ * @param {string} [options.raw] - Source text already read by the caller.
448
451
  * @param {string} options.contentPackage - The package the tree compiles as.
449
452
  * @param {string} [options.body] - The note's markdown body, for its anchors
450
453
  * and for the one question that decides whether the note publishes a page. A
@@ -473,6 +476,7 @@ export function buildIndexRecord({
473
476
  frontmatter,
474
477
  relPath,
475
478
  absPath,
479
+ raw,
476
480
  contentPackage,
477
481
  body,
478
482
  bodyLine,
@@ -481,7 +485,7 @@ export function buildIndexRecord({
481
485
  dateContext,
482
486
  resolveRole,
483
487
  }) {
484
- assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage);
488
+ assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage, raw);
485
489
 
486
490
  const posix = relPath.split(path.sep).join("/");
487
491
  const folder = posix.includes("/") ? posix.slice(0, posix.lastIndexOf("/")) : "";
@@ -490,9 +494,7 @@ export function buildIndexRecord({
490
494
  } catch (error) {
491
495
  error.file = absPath ?? relPath;
492
496
  if (absPath && error.keyPath) {
493
- const yamlText = fs
494
- .readFileSync(absPath, "utf8")
495
- .match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
497
+ const yamlText = (raw ?? rawNote(absPath)).match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
496
498
  const locate = (/** @type {any} */ found) => {
497
499
  const position = positionOfYamlPath(yamlText, found.keyPath, {
498
500
  key: found.addressKey,
@@ -540,7 +542,7 @@ export function buildIndexRecord({
540
542
  stub ? null : (
541
543
  [
542
544
  ...collectAnchors(body, bodyLine, resolveRole),
543
- ...eventAnchors(frontmatter, rawNote(absPath)),
545
+ ...eventAnchors(frontmatter, raw ?? rawNote(absPath)),
544
546
  ]
545
547
  .sort((a, b) => (a.line ?? 0) - (b.line ?? 0))
546
548
  .map((a) => ({
@@ -659,6 +661,7 @@ export function indexRecordsForNote({
659
661
  bodyLine,
660
662
  relPath,
661
663
  absPath,
664
+ raw,
662
665
  contentPackage,
663
666
  manifest,
664
667
  addressContext,
@@ -672,6 +675,7 @@ export function indexRecordsForNote({
672
675
  bodyLine,
673
676
  relPath,
674
677
  absPath,
678
+ raw,
675
679
  contentPackage,
676
680
  manifest,
677
681
  addressContext,
@@ -783,10 +787,14 @@ function cultureTuple(value) {
783
787
  * immediate parent whose package is not this one.
784
788
  * @param {object[]} [options.problems] - Receives each conflict as a finding.
785
789
  * Omitted, every conflict is collected and one Error lists them all.
790
+ * @param {Map<string, {raw: string}>} [options.parsed] - Notes already read, keyed by file.
786
791
  * @returns {Array<Record<string, any>>} The same records.
787
792
  * @throws {Error} When `problems` is omitted and any note's sources disagree.
788
793
  */
789
- export function resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems }) {
794
+ export function resolveCultures(
795
+ records,
796
+ { contentPackage, contentBase, foreignRecord, problems, parsed },
797
+ ) {
790
798
  // Each note under every key a link may name it by: its record's canonical
791
799
  // address, and its page address in the `note` system, which is what a
792
800
  // `data.parents`, `data.seat` or `data.government` link names.
@@ -879,7 +887,7 @@ export function resolveCultures(records, { contentPackage, contentBase, foreignR
879
887
  */
880
888
  const conflict = (record, keyPath, subject, distinct) => {
881
889
  const file = contentBase ? noteFile(contentBase, record) : record.file?.path;
882
- const raw = contentBase && fs.existsSync(file) ? fs.readFileSync(file, "utf8") : undefined;
890
+ const raw = parsed?.get(file)?.raw ?? (contentBase ? rawNote(file) : undefined);
883
891
  let position = positionOfFrontmatterPath(raw, keyPath, { key: true });
884
892
  if (position.line === undefined && keyPath.length > 1)
885
893
  position = positionOfFrontmatterPath(raw, ["data"], { key: true });
@@ -1026,9 +1034,9 @@ export function resolveCultures(records, { contentPackage, contentBase, foreignR
1026
1034
  * @param {(key: string) => ({culture?: unknown}|undefined)} [options.foreignRecord] -
1027
1035
  * Another package's entry for a canonical address, read for a parent that
1028
1036
  * package publishes; see {@link resolveCultures}.
1029
- * @param {Map<string, {body: string, frontmatter: object|null}>} [options.parsed] -
1030
- * Filled with each note's body and its frontmatter as authored, keyed by the
1031
- * note's file, so a later pass reads the notes this walk already parsed.
1037
+ * @param {Map<string, {body: string, frontmatter: object|null, raw: string, bodyLine?: number, bodyColumn?: number}>} [options.parsed] -
1038
+ * Filled with each note's source text, body coordinates and frontmatter as
1039
+ * authored, keyed by file, for reuse throughout the command.
1032
1040
  * @returns {Array<Record<string, any>>} The records, in a total order that does
1033
1041
  * not depend on directory-read order.
1034
1042
  */
@@ -1042,7 +1050,7 @@ export function collectContentIndex(
1042
1050
  problems,
1043
1051
  addressContext,
1044
1052
  foreignRecord,
1045
- parsed,
1053
+ parsed = new Map(),
1046
1054
  },
1047
1055
  ) {
1048
1056
  const records = [];
@@ -1115,10 +1123,16 @@ export function collectContentIndex(
1115
1123
 
1116
1124
  // Each recorded note with its body, for the words its prose cites.
1117
1125
  const bodies = [];
1118
- for (const { frontmatter, body, bodyLine, absPath } of parsedNotes) {
1126
+ for (const { frontmatter, body, bodyLine, bodyColumn, raw, absPath } of parsedNotes) {
1119
1127
  // Before the index derives anything onto the frontmatter, so a later
1120
1128
  // pass reads the note as authored.
1121
- parsed?.set(absPath, { body, frontmatter: structuredClone(frontmatter) });
1129
+ parsed.set(absPath, {
1130
+ body,
1131
+ bodyLine,
1132
+ bodyColumn,
1133
+ raw,
1134
+ frontmatter: structuredClone(frontmatter),
1135
+ });
1122
1136
  const fm = frontmatter ?? {};
1123
1137
  applyComputedBeingAge(fm, present, dates);
1124
1138
  const relPath = path.relative(contentBase, absPath);
@@ -1127,6 +1141,7 @@ export function collectContentIndex(
1127
1141
  frontmatter: fm,
1128
1142
  relPath,
1129
1143
  absPath,
1144
+ raw,
1130
1145
  contentPackage,
1131
1146
  body,
1132
1147
  bodyLine,
@@ -1142,9 +1157,7 @@ export function collectContentIndex(
1142
1157
  err.identity = { type: fm.type, shortcode: fm.shortcode };
1143
1158
  err.keyPath = ["shortcode"];
1144
1159
  err.message = `shortcode "${fm.shortcode}" is not strictly alphanumeric — lowercase letters and digits only`;
1145
- const yamlText = fs
1146
- .readFileSync(absPath, "utf8")
1147
- .match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
1160
+ const yamlText = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
1148
1161
  err.position = positionOfYamlPath(yamlText, err.keyPath);
1149
1162
  if (err.position.line) err.position.line++;
1150
1163
  }
@@ -1186,13 +1199,13 @@ export function collectContentIndex(
1186
1199
  // Every note record exists and every Address in it is a tuple, so a parent
1187
1200
  // is found by its canonical address. Before the sort, which reads nothing
1188
1201
  // the pass writes.
1189
- resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems });
1202
+ resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems, parsed });
1190
1203
  const cultureNotes = records
1191
1204
  .filter((record) => isNoteRecord(record) && record.type === "lore")
1192
1205
  .map((record) => ({
1193
1206
  fm: record,
1194
1207
  file: noteFile(contentBase, record),
1195
- raw: fs.readFileSync(noteFile(contentBase, record), "utf8"),
1208
+ raw: parsed.get(noteFile(contentBase, record)).raw,
1196
1209
  }));
1197
1210
  const cultureByKey = new Map(
1198
1211
  cultureNotes.map((note) => [
@@ -1352,7 +1365,7 @@ function dependencyAddresses(config) {
1352
1365
  * aborting the derivation. Omitted, the note throws — which is the contract
1353
1366
  * the emitter needs, since an index missing a note asserts that it does not
1354
1367
  * exist.
1355
- * @param {Map<string, {body: string, frontmatter: object|null}>} [opts.parsed] -
1368
+ * @param {Map<string, {body: string, frontmatter: object|null, raw: string, bodyLine?: number, bodyColumn?: number}>} [opts.parsed] -
1356
1369
  * Filled with each note's body and authored frontmatter, keyed by the note's
1357
1370
  * file. Handed to {@link module:engine/sql-tables.prepareTreeSqlTables}, it
1358
1371
  * spares that pass reading and parsing every note again.
@@ -71,9 +71,12 @@ export function poetryMarkdown(body) {
71
71
  .join("\n\n");
72
72
  }
73
73
 
74
+ import MarkdownIt from "markdown-it";
74
75
  import { slugify } from "./content-slug.mjs";
75
76
  import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
76
77
 
78
+ const structureParser = new MarkdownIt({ html: true });
79
+
77
80
  /** Read poetry code fences; start/end are inclusive zero-based source lines.
78
81
  * @param {string} source - Authored Markdown.
79
82
  */
@@ -81,8 +84,14 @@ export function scanPoetry(source) {
81
84
  const lines = String(source ?? "").split("\n"),
82
85
  blocks = [],
83
86
  errors = [];
87
+ const literalLines = new Set();
88
+ for (const token of structureParser.parse(String(source ?? ""), {})) {
89
+ if (!token.map || !["blockquote_open", "html_block"].includes(token.type)) continue;
90
+ for (let line = token.map[0]; line < token.map[1]; line++) literalLines.add(line);
91
+ }
84
92
  let opening = null;
85
93
  for (let i = 0; i < lines.length; i++) {
94
+ if (literalLines.has(i)) continue;
86
95
  const match = /^ *(`{3,}|~{3,})(.*)$/.exec(lines[i]);
87
96
  if (opening) {
88
97
  if (
@@ -249,15 +249,20 @@ export function positionInFrontmatter(raw, key, value = undefined, { topLevel =
249
249
  * {@link positionInFrontmatter}.
250
250
  * @param {boolean} [options.topLevel=false] - Require the key at column 1, so
251
251
  * an identically named nested key cannot answer for it.
252
+ * @param {string} [options.raw] - Source text already read by the caller.
252
253
  * @returns {{line?: number, column?: number}|undefined} Spreadable position
253
254
  * fields, dropped rather than guessed when the file cannot be read or the key
254
255
  * cannot be found — as `formatDiagnostic` requires.
255
256
  */
256
- export function locateFrontmatterKey(absPath, key, value = undefined, { topLevel = false } = {}) {
257
- if (!absPath) return undefined;
258
- let raw;
257
+ export function locateFrontmatterKey(
258
+ absPath,
259
+ key,
260
+ value = undefined,
261
+ { topLevel = false, raw } = {},
262
+ ) {
263
+ if (!absPath && raw === undefined) return undefined;
259
264
  try {
260
- raw = fs.readFileSync(absPath, "utf8");
265
+ raw ??= fs.readFileSync(absPath, "utf8");
261
266
  } catch {
262
267
  return undefined;
263
268
  }
@@ -306,6 +311,9 @@ export function positionOfLiteral(text, needle, occurrence = 1) {
306
311
  };
307
312
  }
308
313
 
314
+ /** The most recently located text; retaining one document bounds memory. */
315
+ let locatedYaml;
316
+
309
317
  /**
310
318
  * Where a **key or value in a YAML document** sits, addressed by its path.
311
319
  *
@@ -315,11 +323,10 @@ export function positionOfLiteral(text, needle, occurrence = 1) {
315
323
  * first occurrence is routinely not the one the finding is about — which is the
316
324
  * one thing the located form exists to prevent.
317
325
  *
318
- * A path resolves the exact node instead. The document is re-parsed here rather
319
- * than threaded from the loader because the loader returns plain data: `yaml`
320
- * discards ranges once a document is materialised, and carrying a parallel
321
- * position tree through configuration resolution would be a second
322
- * representation of the same file to keep in step.
326
+ * A path resolves the exact node. The most recently located text retains its
327
+ * document and line counter, so several findings in one note share a parse.
328
+ * A different text replaces that entry, including an edited copy of the same
329
+ * file. The loader returns plain data without source ranges.
323
330
  *
324
331
  * Every failure — unparseable text, an `.mjs` configuration, a path that
325
332
  * resolves to nothing — yields `{}`, so a caller spreads the result and the
@@ -348,9 +355,15 @@ export function positionOfYamlPath(text, keyPath, { key = false } = {}) {
348
355
  if (!Array.isArray(keyPath) || keyPath.length === 0) return {};
349
356
 
350
357
  let node;
351
- const counter = new LineCounter();
358
+ let counter;
352
359
  try {
353
- const doc = YAML.parseDocument(text, { lineCounter: counter });
360
+ if (locatedYaml?.text !== text) {
361
+ const lineCounter = new LineCounter();
362
+ const doc = YAML.parseDocument(text, { lineCounter });
363
+ locatedYaml = { text, doc, counter: lineCounter };
364
+ }
365
+ const { doc } = locatedYaml;
366
+ counter = locatedYaml.counter;
354
367
  // `keepScalar` returns the Scalar node rather than its value, which is
355
368
  // the only form carrying a range.
356
369
  node = doc.getIn(keyPath, true);
@@ -1541,7 +1541,7 @@ export function lintNote(
1541
1541
  for (const field of fields) {
1542
1542
  if (note.inheritedCultureFields?.has(field.name)) continue;
1543
1543
  if (typeof field.check !== "function") continue;
1544
- findings.push(...field.check(note, { index }));
1544
+ findings.push(...field.check(note, { index, addressContext: referenceContext }));
1545
1545
  }
1546
1546
  // A check the type declares of the whole note, with the same index.
1547
1547
  if (typeof entry.check === "function") findings.push(...entry.check(note, { index }));
@@ -38,7 +38,7 @@
38
38
  * The HeroicLands vault is not a build input for SoHL content.
39
39
  */
40
40
 
41
- import { noteAddressContext } from "./note-addresses.mjs";
41
+ import { noteAddressContext, decodeNoteAddresses } from "./note-addresses.mjs";
42
42
  import { GENERATED_DRAWING_DIR } from "./generated-sections.mjs";
43
43
  import fs from "fs";
44
44
  import path from "path";
@@ -54,7 +54,7 @@ import { Hm3Actors } from "../hm3/actors.mjs";
54
54
  import { Macros } from "./macros.mjs";
55
55
  import { Scenes } from "./scenes.mjs";
56
56
  import { Bundles } from "./bundles.mjs";
57
- import { statsForPack, parseMarkdownFile, folderFilename } from "./helpers.mjs";
57
+ import { statsForPack, folderFilename } from "./helpers.mjs";
58
58
  import {
59
59
  buildFolderNoteIndex,
60
60
  collectFolderNotes,
@@ -800,7 +800,10 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
800
800
  // every type a system deliberately does not map, which is exactly the
801
801
  // silence the rule requires. Independent of `only`, since it is a fact about the
802
802
  // configured pack list rather than about which passes this run executes.
803
- const unclaimed = unclaimedNoteFindings(config, undefined, { records: corpus.records });
803
+ const unclaimed = unclaimedNoteFindings(config, undefined, {
804
+ records: corpus.records,
805
+ parsed: corpus.parsed,
806
+ });
804
807
  for (const finding of unclaimed) emitDiagnostic(finding);
805
808
 
806
809
  fs.mkdirSync(config.paths.packJson, { recursive: true });
@@ -831,22 +834,8 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
831
834
  try {
832
835
  folderNotes = buildFolderNoteIndex(
833
836
  collectFolderNotes(
834
- // The corpus this compile derived picks the notes; the
835
- // file supplies their frontmatter, and this is one of the few
836
- // places where that distinction is load-bearing rather than
837
- // incidental.
838
- //
839
- // `collectFolderNotes` treats `fm.id` as an **authored pin**,
840
- // which wins over the id it derives under the folder namespace.
841
- // A record's `id` is not that: the index fills it in for every
842
- // addressable note, so handing records straight over
843
- // would make every folder look pinned. The *value*
844
- // would be right either way — the index derives a folder's id
845
- // under the folder namespace, so the two agree — but `derivedId`
846
- // would not, and it is what tells an author whether a duplicate
847
- // id was two pins or two addresses. The index cannot tell a pin
848
- // from a derivation, so the note is read — and only folder notes
849
- // are, 79 of `sohl`'s 1,685 rather than all of them.
837
+ // Folder pins are authored ids; index-derived ids must not
838
+ // turn an unpinned folder into a pinned one.
850
839
  corpus.records
851
840
  .filter(
852
841
  (record) =>
@@ -856,9 +845,10 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
856
845
  .map((record) => {
857
846
  const absPath = noteFile(contentBase, record);
858
847
  return {
859
- frontmatter: parseMarkdownFile(absPath, {
860
- addressContext: noteAddressContext(config),
861
- }).frontmatter,
848
+ frontmatter: decodeNoteAddresses(
849
+ structuredClone(corpus.parsed.get(absPath).frontmatter),
850
+ noteAddressContext(config),
851
+ ),
862
852
  absPath,
863
853
  };
864
854
  }),
@@ -233,17 +233,18 @@ export function renderFoundryMarkdown(body, figures, footnoteNumbers, docId, res
233
233
  /**
234
234
  * Parses a markdown file with YAML frontmatter.
235
235
  *
236
- * Returns `{ frontmatter, body, description, bodyLine, bodyColumn }` where
236
+ * Returns `{ frontmatter, body, description, bodyLine, bodyColumn, raw }` where
237
237
  * `body` is the trimmed raw markdown after the frontmatter block, and
238
- * `description` is `body` rendered to HTML. `bodyLine` / `bodyColumn` are the
238
+ * `description` is `body` rendered to HTML. The full source is retained as
239
+ * `raw` for diagnostic locations. `bodyLine` / `bodyColumn` are the
239
240
  * 1-based **file** position of the body's first character, which is what turns
240
241
  * an offset within `body` into a position a diagnostic can name — see
241
242
  * {@link positionInBody}. If the file has no frontmatter block, returns
242
- * `{ frontmatter: null, body: "", description: "" }` with a warn log, and no
243
+ * `{ frontmatter: null, body: "", description: "", raw }` and no
243
244
  * position: there is no body to have one.
244
245
  * @param {string} filePath - Markdown file to read.
245
246
  * @param {{addressContext?: object}} [opts] - Address defaults for decoded fields.
246
- * @returns {{frontmatter: object|null, body: string, description: string,
247
+ * @returns {{frontmatter: object|null, body: string, description: string, raw: string,
247
248
  * bodyLine?: number, bodyColumn?: number, parseError?: {message: string,
248
249
  * line?: number, column?: number}}} Parsed note and body position.
249
250
  */
@@ -251,7 +252,7 @@ export function parseMarkdownFile(filePath, { addressContext } = {}) {
251
252
  const content = fs.readFileSync(filePath, "utf8");
252
253
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
253
254
  if (!fmMatch) {
254
- return { frontmatter: null, body: "", description: "" };
255
+ return { frontmatter: null, body: "", description: "", raw: content };
255
256
  }
256
257
  let frontmatter;
257
258
  try {
@@ -264,6 +265,7 @@ export function parseMarkdownFile(filePath, { addressContext } = {}) {
264
265
  frontmatter: null,
265
266
  body: "",
266
267
  description: "",
268
+ raw: content,
267
269
  parseError: {
268
270
  message: `YAML parse error: ${message}`,
269
271
  ...(Number.isInteger(location?.line) ? { line: location.line + 1 } : {}),
@@ -295,7 +297,7 @@ export function parseMarkdownFile(filePath, { addressContext } = {}) {
295
297
  const before = content.slice(0, bodyStart);
296
298
  const bodyLine = before.split("\n").length;
297
299
  const bodyColumn = bodyStart - before.lastIndexOf("\n");
298
- return { frontmatter, body, description, bodyLine, bodyColumn };
300
+ return { frontmatter, body, description, bodyLine, bodyColumn, raw: content };
299
301
  }
300
302
 
301
303
  /**
@@ -277,6 +277,9 @@ export const GEAR_UNITS = Object.freeze({
277
277
  * unit?: string, group?: string, phrase?: (value: any) => string}>>}
278
278
  */
279
279
  export const NOTE_FIELD_PRESENTATION = Object.freeze({
280
+ seeAlso: Object.freeze({
281
+ withheld: "navigation machinery, rendered in its own reading section",
282
+ }),
280
283
  ...DURATION_LABELS,
281
284
  ...GEAR_UNITS,
282
285
 
@@ -50,6 +50,7 @@
50
50
  */
51
51
 
52
52
  import log from "loglevel";
53
+ import { seeAlsoEntries, seeAlsoMarkdown } from "./see-also.mjs";
53
54
 
54
55
  import path from "node:path";
55
56
 
@@ -825,7 +826,16 @@ export class Journals extends BasePackCompiler {
825
826
  return buildJournalEntry({
826
827
  id,
827
828
  name,
828
- markdown,
829
+ markdown: [
830
+ markdown,
831
+ seeAlsoMarkdown(
832
+ seeAlsoEntries(fm, (ref) => resolveReference(this.linkIndex, ref)),
833
+ linkToUuid,
834
+ 1,
835
+ ),
836
+ ]
837
+ .filter(Boolean)
838
+ .join("\n\n"),
829
839
  resolveRole: (pathname) => roles.get(pathname),
830
840
  infoboxes: boxes.map((box) => ({
831
841
  id: box.id,