@heroiclands/package-build 23.7.1 → 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 (60) hide show
  1. package/CHANGELOG.md +14 -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 +14 -0
  9. package/docs/commands.md +12 -2
  10. package/docs/content-format.md +23 -0
  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 +44 -9
  15. package/docs/reference/note-types.md +7 -4
  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-index.mjs +35 -22
  22. package/engine/content-poetry.mjs +9 -0
  23. package/engine/diagnostics.mjs +24 -11
  24. package/engine/frontmatter-lint.mjs +1 -1
  25. package/engine/generate.mjs +12 -22
  26. package/engine/helpers.mjs +8 -6
  27. package/engine/infobox.mjs +3 -0
  28. package/engine/journals.mjs +11 -1
  29. package/engine/markdownlint-run.mjs +12 -2
  30. package/engine/markup-validation.mjs +34 -0
  31. package/engine/note-addresses.mjs +1 -0
  32. package/engine/note-claims.mjs +19 -10
  33. package/engine/note-package.mjs +3 -5
  34. package/engine/note-vocabulary.mjs +29 -1
  35. package/engine/pdf-build.mjs +8 -1
  36. package/engine/prose-lint.mjs +12 -6
  37. package/engine/related-pages.mjs +22 -3
  38. package/engine/see-also.mjs +150 -0
  39. package/engine/site-build.mjs +5 -1
  40. package/engine/site-preview.mjs +4 -2
  41. package/engine/sql-tables.mjs +25 -10
  42. package/engine/standings.mjs +14 -12
  43. package/hugo-theme/layouts/partials/related.html +25 -8
  44. package/package.json +2 -1
  45. package/types/container.d.mts +4 -3
  46. package/types/e2e.d.mts +28 -0
  47. package/types/engine/base-compiler.d.mts +2 -2
  48. package/types/engine/compile-corpus.d.mts +2 -1
  49. package/types/engine/content-index.d.mts +20 -7
  50. package/types/engine/diagnostics.d.mts +7 -6
  51. package/types/engine/helpers.d.mts +7 -4
  52. package/types/engine/markdownlint-run.d.mts +3 -1
  53. package/types/engine/markup-validation.d.mts +14 -0
  54. package/types/engine/note-addresses.d.mts +10 -0
  55. package/types/engine/note-claims.d.mts +7 -1
  56. package/types/engine/note-package.d.mts +3 -1
  57. package/types/engine/note-vocabulary.d.mts +1 -0
  58. package/types/engine/related-pages.d.mts +10 -1
  59. package/types/engine/see-also.d.mts +55 -0
  60. package/types/engine/sql-tables.d.mts +7 -3
@@ -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,
@@ -41,6 +41,7 @@
41
41
  import fs from "node:fs";
42
42
  import path from "node:path";
43
43
  import { pathToFileURL } from "node:url";
44
+ import { markupFindings } from "./markup-validation.mjs";
44
45
 
45
46
  import ignore from "ignore";
46
47
  import {
@@ -447,8 +448,9 @@ function gitignoreHooks(root) {
447
448
  * `config`, `ignores`, `gitignore` and `globs`.
448
449
  * @param {boolean} [params.fix=false] - Repair what markdownlint can, then
449
450
  * report what remains.
450
- * @returns {Promise<{results: object[]}>} markdownlint results, each with its
451
+ * @returns {Promise<{results: object[], markup: object[]}>} markdownlint results, each with its
451
452
  * `fileName` relative to `directory`, ordered by file, line and rule.
453
+ * `markup` carries block syntax diagnostics with absolute filenames.
452
454
  * @throws {MarkdownConfigError} A configuration file the contract refuses.
453
455
  */
454
456
  export async function runMarkdownlint(params) {
@@ -530,12 +532,20 @@ export async function runMarkdownlint(params) {
530
532
  };
531
533
 
532
534
  const resultsByFile = new Map();
535
+ const markup = [];
533
536
  for (const [dir, files] of groups) {
534
537
  const options = await lintOptions(dir, files);
538
+ const malformed = new Set();
539
+ for (const file of files) {
540
+ const findings = markupFindings(fs.readFileSync(file, "utf8"), file);
541
+ if (findings.length) malformed.add(file);
542
+ markup.push(...findings);
543
+ }
535
544
  let results = await lint(options);
536
545
  if (fix) {
537
546
  const toFix = [];
538
547
  for (const file of files) {
548
+ if (malformed.has(file)) continue;
539
549
  const fixable = (results[file] ?? []).filter((error) => error.fixInfo);
540
550
  if (!fixable.length) continue;
541
551
  const original = fs.readFileSync(file, "utf8");
@@ -563,5 +573,5 @@ export async function runMarkdownlint(params) {
563
573
  a.ruleNames[0].localeCompare(b.ruleNames[0]) ||
564
574
  order.get(a) - order.get(b),
565
575
  );
566
- return { results: flat };
576
+ return { results: flat, markup };
567
577
  }
@@ -0,0 +1,34 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ import { scanBlocks } from "./content-blocks.mjs";
15
+ import { scanAlerts } from "./content-alerts.mjs";
16
+
17
+ /**
18
+ * Validate authored block syntax with positions in the original file.
19
+ * Frontmatter is masked so literal YAML values cannot become body markup.
20
+ * @param {string} source - Complete Markdown source, optionally with frontmatter.
21
+ * @param {string} file - Diagnostic filename.
22
+ * @returns {Array<{file: string, line: number, column: number, severity: string, message: string}>} Syntax errors.
23
+ */
24
+ export function markupFindings(source, file) {
25
+ const body = String(source ?? "").replace(
26
+ /^\uFEFF?---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/,
27
+ (frontmatter) => frontmatter.replace(/[^\r\n]/g, " "),
28
+ );
29
+ return [...scanBlocks(body).errors, ...scanAlerts(body).errors].map((error) => ({
30
+ file,
31
+ ...error,
32
+ severity: "error",
33
+ }));
34
+ }
@@ -41,6 +41,7 @@ export {
41
41
 
42
42
  /** Structured positions whose Address is nested inside a declared container. */
43
43
  export const STRUCTURED_ADDRESSES = Object.freeze([
44
+ { path: ["data", "seeAlso", "*", "address"], shape: "value", lenient: true },
44
45
  {
45
46
  path: ["packFolder"],
46
47
  shape: "scalar-or-map",
@@ -666,11 +666,12 @@ function packsServing(config, docType, system) {
666
666
  * @param {string} absPath - The note's path, for the finding's position.
667
667
  * @param {string} type - The note's type.
668
668
  * @param {object} config - The resolved build configuration.
669
+ * @param {string} [raw] - Source text already read by the caller.
669
670
  * @returns {Array<{file: string, line?: number, column?: number,
670
671
  * severity: "error", message: string, type: string}>} One finding per
671
672
  * discarded block.
672
673
  */
673
- function discardedSystemBlockFindings(fm, absPath, type, config) {
674
+ function discardedSystemBlockFindings(fm, absPath, type, config, raw) {
674
675
  const findings = [];
675
676
  for (const system of SHIPPED_SYSTEMS) {
676
677
  if (!carriesSystemBlock(fm, system)) continue;
@@ -683,7 +684,7 @@ function discardedSystemBlockFindings(fm, absPath, type, config) {
683
684
  if (!packs || serving) continue;
684
685
  findings.push({
685
686
  file: absPath,
686
- ...locateFrontmatterKey(absPath, system, undefined, { topLevel: true }),
687
+ ...locateFrontmatterKey(absPath, system, undefined, { topLevel: true, raw }),
687
688
  severity: /** @type {"error"} */ ("error"),
688
689
  type,
689
690
  message:
@@ -715,10 +716,16 @@ function discardedSystemBlockFindings(fm, absPath, type, config) {
715
716
  * @param {object} [opts] - Options.
716
717
  * @param {readonly object[]} [opts.records] - The corpus, derived once by the
717
718
  * compile and handed in — required, for the reason above.
719
+ * @param {Map<string, {body: string, raw: string}>} [opts.parsed] - Already parsed
720
+ * notes, keyed by file, for body checks and finding positions.
718
721
  * @returns {Array<{file: string, line?: number, column?: number,
719
722
  * severity: "error", message: string, type: string}>} One finding per note.
720
723
  */
721
- export function unclaimedNoteFindings(config = loadPackConfig(), sources, { records } = {}) {
724
+ export function unclaimedNoteFindings(
725
+ config = loadPackConfig(),
726
+ sources,
727
+ { records, parsed } = {},
728
+ ) {
722
729
  const resolved = resolveSources(sources);
723
730
  const vocabulary = noteTypeVocabulary(resolved);
724
731
  const findings = [];
@@ -752,17 +759,19 @@ export function unclaimedNoteFindings(config = loadPackConfig(), sources, { reco
752
759
  // Asked first, and asked whatever the type's claim turns out to be: a
753
760
  // note whose documents all have packs may still carry a block none of
754
761
  // them reads, and that is invisible to every question below.
755
- findings.push(...discardedSystemBlockFindings(fm, absPath, type, config));
762
+ findings.push(
763
+ ...discardedSystemBlockFindings(fm, absPath, type, config, parsed?.get(absPath)?.raw),
764
+ );
756
765
 
757
766
  // Every document this note produces, against the classes this
758
767
  // configuration has a pack for. Three outcomes, and the middle one is
759
768
  // the half-claimed one — invisible while the question is only "is anything
760
769
  // claiming this note", because the answer there is yes.
761
770
  const produces = documentClassesFor(type, resolved, {
762
- // Lazy: only a doc-carrying type whose JournalEntry has nowhere to
763
- // go asks, so a tree with a JournalEntry pack — which is most of
764
- // them — reads no bodies at all.
765
- hasProse: () => noteHasProse(absPath),
771
+ hasProse: () => {
772
+ const source = parsed?.get(absPath);
773
+ return source ? Boolean(source.body) : noteHasProse(absPath);
774
+ },
766
775
  });
767
776
  const missing = produces.filter((docType) => !configured.has(docType));
768
777
  const compiled = produces.filter((docType) => configured.has(docType));
@@ -791,7 +800,7 @@ export function unclaimedNoteFindings(config = loadPackConfig(), sources, { reco
791
800
  if (compiled.length) {
792
801
  findings.push({
793
802
  file: absPath,
794
- ...locateFrontmatterKey(absPath, "type", type),
803
+ ...locateFrontmatterKey(absPath, "type", type, { raw: parsed?.get(absPath)?.raw }),
795
804
  severity: /** @type {"error"} */ ("error"),
796
805
  type,
797
806
  message: partialMessage(type, missing, compiled),
@@ -801,7 +810,7 @@ export function unclaimedNoteFindings(config = loadPackConfig(), sources, { reco
801
810
 
802
811
  findings.push({
803
812
  file: absPath,
804
- ...locateFrontmatterKey(absPath, "type", type),
813
+ ...locateFrontmatterKey(absPath, "type", type, { raw: parsed?.get(absPath)?.raw }),
805
814
  severity: /** @type {"error"} */ ("error"),
806
815
  type,
807
816
  message:
@@ -62,12 +62,13 @@ export function searchableFrontmatter(fm, configured) {
62
62
  * @param {string} [options.absPath] - The note's file on disk, read only on the
63
63
  * failing path to locate the offending line and column. The position rides on
64
64
  * the thrown error as `position`, for a caller that emits a diagnostic.
65
+ * @param {string} [options.raw] - Source text already read by the caller.
65
66
  * @param {string} [options.configured] - The package this build compiles.
66
67
  * Defaults to the configured `contentPackage`.
67
68
  * @returns {void}
68
69
  * @throws {Error} When the note declares the field.
69
70
  */
70
- export function assertNoDeclaredPackage(fm, { file, absPath, configured } = {}) {
71
+ export function assertNoDeclaredPackage(fm, { file, absPath, configured, raw } = {}) {
71
72
  if (!fm || typeof fm !== "object" || !Object.hasOwn(fm, "package")) return;
72
73
 
73
74
  const target = configured ?? contentPackage();
@@ -84,10 +85,7 @@ export function assertNoDeclaredPackage(fm, { file, absPath, configured } = {})
84
85
  `\`contentPackage\` ("${target}", in package-build.config.yaml), ` +
85
86
  `and every note in the tree belongs to it.`,
86
87
  );
87
- // Where the field is, so the caller's diagnostic opens on the line that has
88
- // to be deleted. Read here rather than carried through every walk: this is
89
- // the failing path, and the build stops on it.
90
- const position = locateFrontmatterKey(absPath, "package");
88
+ const position = locateFrontmatterKey(absPath, "package", undefined, { raw });
91
89
  if (position) err.position = position;
92
90
  throw err;
93
91
  }
@@ -72,6 +72,7 @@
72
72
  * @module
73
73
  */
74
74
 
75
+ import { checkSeeAlso } from "./see-also.mjs";
75
76
  // The one charset, read rather than restated. A second spelling of the pattern
76
77
  // is how a disagreement between the three arises.
77
78
  import { ART_SLOTS } from "./art-slots.mjs";
@@ -203,7 +204,7 @@ import { checkSettlementTables } from "./settlement-tables.mjs";
203
204
  * @property {"pack"} [keys] - For a `scalar-or-map` field, what its keys name.
204
205
  * `"pack"` means each is a pack this package declares, so a key naming none
205
206
  * is a finding of its own: it addresses a hierarchy nothing will ever read.
206
- * @property {(note: object, opts: {index?: object}) => object[]} [check] - A
207
+ * @property {(note: object, opts: {index?: object, addressContext?: object}) => object[]} [check] - A
207
208
  * check of the value beyond its shape, run by the frontmatter lint with the
208
209
  * link index once the container has been checked. The lint stays
209
210
  * vocabulary-agnostic this way: the rule lives beside the field that needs
@@ -1001,6 +1002,33 @@ export const NOTE_TOP_LEVEL_KEY_SET = Object.freeze(new Set(NOTE_TOP_LEVEL_KEYS)
1001
1002
  * @type {readonly DataFieldSpec[]}
1002
1003
  */
1003
1004
  export const SHARED_DATA_FIELDS = Object.freeze([
1005
+ Object.freeze({
1006
+ name: "seeAlso",
1007
+ kind: "list",
1008
+ shape: "list of `{ address, gloss? }`",
1009
+ entries: Object.freeze({
1010
+ kind: "map",
1011
+ shape: "`{ address, gloss? }`",
1012
+ fields: Object.freeze([
1013
+ Object.freeze({
1014
+ name: "address",
1015
+ kind: "address",
1016
+ shape: "a note Address",
1017
+ required: true,
1018
+ describe: "The note to read next.",
1019
+ }),
1020
+ Object.freeze({
1021
+ name: "gloss",
1022
+ kind: "string",
1023
+ shape: "a string",
1024
+ describe: "A short phrase saying why to follow the reference.",
1025
+ }),
1026
+ ]),
1027
+ }),
1028
+ check: checkSeeAlso,
1029
+ describe:
1030
+ "Deliberate cross-references in reading order, with optional glosses; shown on the site, in journals and in books.",
1031
+ }),
1004
1032
  Object.freeze({
1005
1033
  name: "id",
1006
1034
  ...TEXT,
@@ -120,6 +120,7 @@ import {
120
120
  import { artPathname, assetAddressIndex, pathnameRoles } from "./art-fields.mjs";
121
121
  import { embedRole } from "./content-embeds.mjs";
122
122
  import { expandContentTables } from "./content-tables.mjs";
123
+ import { seeAlsoEntries, seeAlsoMarkdown, seeAlsoWebLink } from "./see-also.mjs";
123
124
  import { renderMarkdownExpressions } from "./markdown-expressions.mjs";
124
125
  import { eventNoteIndex } from "./event-fields.mjs";
125
126
  import { numberFigures, scanFigures } from "./content-figures.mjs";
@@ -1645,7 +1646,13 @@ async function buildBook(book, shared) {
1645
1646
  // Whether the entry is settled comes before anything it says: the
1646
1647
  // notice leads the leaf, before the authored body.
1647
1648
  const notice = isDraftNote(page.fm) ? draftNoticeTypst() : "";
1648
- const parts = [notice, prose, panel].filter((part) => part);
1649
+ const references = seeAlsoMarkdown(
1650
+ seeAlsoEntries(page.fm, (ref) => resolveInfoboxRef(gates.index, ref)),
1651
+ seeAlsoWebLink,
1652
+ );
1653
+ const parts = [notice, prose, panel, references ? typst(references, {}) : ""].filter(
1654
+ (part) => part,
1655
+ );
1649
1656
  return parts.join("\n\n");
1650
1657
  };
1651
1658
 
@@ -43,6 +43,7 @@ import {
43
43
  sharedPrettierDivergence,
44
44
  sharedPrettierOptionsFor,
45
45
  } from "./prose-config.mjs";
46
+ import { markupFindings } from "./markup-validation.mjs";
46
47
  import { MarkdownConfigError, runMarkdownlint } from "./markdownlint-run.mjs";
47
48
  import { walkFiles } from "./walk-files.mjs";
48
49
 
@@ -152,6 +153,13 @@ export async function checkFormatting(root, opts = {}) {
152
153
  filepath: file,
153
154
  };
154
155
  const source = fs.readFileSync(file, "utf8");
156
+ if (["markdown", "mdx"].includes(info.inferredParser)) {
157
+ const syntaxErrors = markupFindings(source, file);
158
+ if (syntaxErrors.length) {
159
+ findings.push(...syntaxErrors);
160
+ continue;
161
+ }
162
+ }
155
163
 
156
164
  // A file Prettier cannot parse is a finding, not a crash. Its own CLI
157
165
  // reports the syntax error and carries on to the next file, and a
@@ -399,9 +407,9 @@ export async function lintMarkdown(root, opts = {}) {
399
407
  const { paths, fix = false } = opts;
400
408
  const directory = path.resolve(root);
401
409
 
402
- let results;
410
+ let results, markup;
403
411
  try {
404
- ({ results } = await runMarkdownlint({
412
+ ({ results, markup } = await runMarkdownlint({
405
413
  directory,
406
414
  globs: paths,
407
415
  fix,
@@ -430,8 +438,6 @@ export async function lintMarkdown(root, opts = {}) {
430
438
  };
431
439
  }
432
440
 
433
- return {
434
- findings: results.map((result) => toDiagnostic(directory, result)),
435
- exitCode: results.length ? 1 : 0,
436
- };
441
+ const findings = [...results.map((result) => toDiagnostic(directory, result)), ...markup];
442
+ return { findings, exitCode: findings.length ? 1 : 0 };
437
443
  }
@@ -27,11 +27,12 @@
27
27
  * `<type>-<shortcode>` form: the site index keys one page under both, and both
28
28
  * answer with one URL.
29
29
  *
30
- * **Only a page of this site is an endpoint.** A link into another package
30
+ * **Only a page of this site is an inferred endpoint.** A link into another package
31
31
  * resolves to a URL the build renders but no page it writes, so it appears
32
32
  * on neither side: a backlink can only be counted on a page this build emits,
33
33
  * and listing a foreign mention beside local ones would make the two lists
34
- * disagree about what a connection is.
34
+ * disagree about what a connection is. Deliberate see-also references also
35
+ * name pages in declared dependencies.
35
36
  *
36
37
  * @module
37
38
  */
@@ -46,6 +47,7 @@ import { homepageAddresses } from "./homepage.mjs";
46
47
  * @property {string} url `<base><slug>/` — the page's address as every href
47
48
  * this build renders composes it, never the
48
49
  * site-root-relative form the page states for itself.
50
+ * @property {string} [gloss] Why the author recommends this page.
49
51
  * @property {string} type The note's `type`, which the theme groups by.
50
52
  */
51
53
 
@@ -53,6 +55,7 @@ import { homepageAddresses } from "./homepage.mjs";
53
55
  * The `related` block one page publishes.
54
56
  *
55
57
  * @typedef {object} Related
58
+ * @property {RelatedEntry[]} [seeAlso] Deliberate references in authored order.
56
59
  * @property {RelatedEntry[]} backlinks Pages that link to this one.
57
60
  * @property {RelatedEntry[]} mentions Pages this one links to.
58
61
  */
@@ -89,10 +92,11 @@ function byTypeThenTitle(a, b) {
89
92
  * the source page's URL and the target page's.
90
93
  * @param {ReadonlyMap<string, RelatedEntry>} entries - Every page of this
91
94
  * site, keyed by URL.
95
+ * @param {ReadonlyMap<string, RelatedEntry[]>} [deliberate] Authored references, including foreign destinations.
92
96
  * @returns {Map<string, Related>} URL → the page's `related` block, both lists
93
97
  * sorted, for every page with at least one entry in either.
94
98
  */
95
- export function relatedPages(edges, entries) {
99
+ export function relatedPages(edges, entries, deliberate = new Map()) {
96
100
  /** @type {Map<string, {backlinks: Set<string>, mentions: Set<string>}>} */
97
101
  const graph = new Map();
98
102
  const node = (url) => {
@@ -120,6 +124,21 @@ export function relatedPages(edges, entries) {
120
124
  for (const [url, { backlinks, mentions }] of graph) {
121
125
  out.set(url, { backlinks: list(backlinks), mentions: list(mentions) });
122
126
  }
127
+ for (const [url, refs] of deliberate) {
128
+ const seen = new Set([url]);
129
+ const seeAlso = refs.filter((ref) => {
130
+ if (seen.has(ref.url)) return false;
131
+ seen.add(ref.url);
132
+ return true;
133
+ });
134
+ if (!seeAlso.length) continue;
135
+ const block = out.get(url) ?? { backlinks: [], mentions: [] };
136
+ out.set(url, {
137
+ seeAlso,
138
+ backlinks: block.backlinks.filter((ref) => !seen.has(ref.url)),
139
+ mentions: block.mentions.filter((ref) => !seen.has(ref.url)),
140
+ });
141
+ }
123
142
  return out;
124
143
  }
125
144