@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
@@ -0,0 +1,150 @@
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 { parseAddress, renderAddress, hasAnchor } from "./address.mjs";
15
+ import { ASSET_TYPE_NAMES } from "./asset-types.mjs";
16
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
17
+
18
+ /**
19
+ * Validate note references, refusing unresolved, repeated and self destinations.
20
+ * @param {object} note - The authored note, including its raw source.
21
+ * @param {object} [options] - The local and declared dependency context.
22
+ * @param {object} [options.index] - The local and declared dependency index.
23
+ * @param {string} [options.index.contentPackage] - The package owning the note.
24
+ * @param {Set<string>} [options.index.types] - The indexed note types.
25
+ * @param {Set<string>} [options.index.packages] - The indexed packages.
26
+ * @param {(key: string) => {url?: string, uuid?: string, file?: string}|undefined}
27
+ * [options.index.addressHit] - Resolve a canonical Address to its destination.
28
+ * @param {import("./address.mjs").AddressDefaults} [options.addressContext]
29
+ * - The Address defaults and vocabularies at the authored reference.
30
+ * @returns {object[]} Findings located at the offending address.
31
+ */
32
+ export function checkSeeAlso(note, { index, addressContext } = {}) {
33
+ const entries = note.fm?.data?.seeAlso;
34
+ if (!Array.isArray(entries)) return [];
35
+ const defaults = {
36
+ package: index?.contentPackage,
37
+ system: "note",
38
+ types: index?.types,
39
+ packages: index?.packages,
40
+ ...addressContext,
41
+ type: undefined,
42
+ };
43
+ const own = parseAddress(note.fm.shortcode, { ...defaults, type: note.fm.type });
44
+ const seen = new Set();
45
+ const findings = [];
46
+ entries.forEach((entry, i) => {
47
+ if (entry === null) {
48
+ findings.push({
49
+ file: note.file,
50
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "seeAlso", i]),
51
+ severity: "error",
52
+ message: `\`data.seeAlso.${i}\` should be a map with an address`,
53
+ });
54
+ return;
55
+ }
56
+ if (!entry || typeof entry !== "object" || entry.address == null) return;
57
+ const tuple = parseAddress(entry.address, defaults, { declared: true });
58
+ const key = tuple.reason ? undefined : renderAddress(tuple);
59
+ const target = key ? index?.addressHit?.(key) : undefined;
60
+ const destination = target?.url ?? target?.uuid ?? target?.file ?? key;
61
+ const self =
62
+ (!own.reason &&
63
+ tuple.package === own.package &&
64
+ tuple.type === own.type &&
65
+ tuple.shortcode === own.shortcode) ||
66
+ (target?.file && target.file === note.file);
67
+ const reason =
68
+ hasAnchor(entry.address) ? "takes no anchor — it names a whole note"
69
+ : tuple.reason || ASSET_TYPE_NAMES.has(tuple.type) ? "should be a note Address"
70
+ : index?.addressHit && !target ?
71
+ "does not resolve in this package or its declared dependencies"
72
+ : self ? "names the note itself"
73
+ : seen.has(destination) ? "is a duplicate destination"
74
+ : undefined;
75
+ seen.add(destination);
76
+ if (reason)
77
+ findings.push({
78
+ file: note.file,
79
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "seeAlso", i, "address"]),
80
+ severity: "error",
81
+ message: `\`data.seeAlso.${i}.address\` ${reason}`,
82
+ });
83
+ });
84
+ return findings;
85
+ }
86
+
87
+ /**
88
+ * Resolve deliberate references in authored order using the surface's index.
89
+ * @param {object} fm - The note frontmatter.
90
+ * @param {(address: unknown) => object|undefined} resolve - The surface's reference resolver.
91
+ * @returns {object[]} Display names, destinations and optional glosses.
92
+ */
93
+ export function seeAlsoEntries(fm, resolve) {
94
+ const entries = fm?.data?.seeAlso;
95
+ if (!Array.isArray(entries)) return [];
96
+ return entries.flatMap((entry) => {
97
+ const target = entry?.address == null ? undefined : resolve(entry.address);
98
+ if (!target) return [];
99
+ return [{ ...target, text: target.name, ...(entry.gloss ? { gloss: entry.gloss } : {}) }];
100
+ });
101
+ }
102
+
103
+ /** Plain text in a generated Markdown paragraph. */
104
+ const plain = (text) =>
105
+ String(text ?? "")
106
+ .replace(/([\\`*_[\]<>{}|#!~])/g, "\\$1")
107
+ .replace(/\r?\n/g, " ");
108
+
109
+ /**
110
+ * A generated section; an empty list emits nothing.
111
+ * @param {object[]} entries - Resolved references in authored order.
112
+ * @param {(entry: object) => string} link - The medium's link markup.
113
+ * @param {number} [level=2] - Heading level; a journal uses one for a separate page.
114
+ * @returns {string} Markdown for the section.
115
+ */
116
+ export function seeAlsoMarkdown(entries, link, level = 2) {
117
+ if (!entries.length) return "";
118
+ return `${"#".repeat(level)} See also\n\n${entries.map((entry) => `- ${link(entry)}${entry.gloss ? ` — ${plain(entry.gloss)}` : ""}`).join("\n")}\n`;
119
+ }
120
+
121
+ /**
122
+ * A reference rendered as an ordinary Markdown link for the book pipeline.
123
+ * @param {object} entry - A resolved name and URL.
124
+ * @returns {string} Markdown.
125
+ */
126
+ export function seeAlsoWebLink(entry) {
127
+ return entry.url ? `[${plain(entry.text)}](<${entry.url}>)` : plain(entry.text);
128
+ }
129
+
130
+ /**
131
+ * Deliberate site connections for a set of pages, including dependency pages.
132
+ * @param {object[]} pages - Pages carrying frontmatter and their published URL.
133
+ * @param {(address: unknown) => object|undefined} resolve - The site's reference resolver.
134
+ * @returns {Map<string, import("./related-pages.mjs").RelatedEntry[]>} Entries keyed by source URL.
135
+ */
136
+ export function seeAlsoPages(pages, resolve) {
137
+ const deliberate = new Map();
138
+ for (const page of pages) {
139
+ const refs = seeAlsoEntries(page.fm, resolve)
140
+ .filter((ref) => ref.url)
141
+ .map((ref) => ({
142
+ title: ref.name,
143
+ url: ref.url,
144
+ type: ref.address.type,
145
+ ...(ref.gloss ? { gloss: ref.gloss } : {}),
146
+ }));
147
+ if (refs.length) deliberate.set(page.url, refs);
148
+ }
149
+ return deliberate;
150
+ }
@@ -109,6 +109,7 @@ import {
109
109
  } from "./homepage.mjs";
110
110
  import { publishesContentPages } from "../content-config.mjs";
111
111
  import { HUGO_CONTENT } from "./site-config.mjs";
112
+ import { seeAlsoPages } from "./see-also.mjs";
112
113
  import { homepageLinkTargets, relatedPages } from "./related-pages.mjs";
113
114
  import { drawSiteMaps, inlineDrawingFigure } from "./site-maps.mjs";
114
115
  import { generatedDrawing, markGenerated, splitGenerated } from "./generated-sections.mjs";
@@ -1095,7 +1096,10 @@ export function renderPages(pages, options) {
1095
1096
  for (const hit of result.resolved) if (hit.url) edges.push([page.url, hit.url]);
1096
1097
  }
1097
1098
 
1098
- const related = relatedPages(edges, entries);
1099
+ const deliberate = seeAlsoPages([...pages, ...homepages], (ref) =>
1100
+ resolveInfoboxRef(index, ref),
1101
+ );
1102
+ const related = relatedPages(edges, entries, deliberate);
1099
1103
 
1100
1104
  let withMap = 0;
1101
1105
  const outputs = capture === true ? new Map() : null;
@@ -22,7 +22,8 @@ import { foundryIdentities } from "./foundry-entries.mjs";
22
22
  import { applyComputedBeingAge, presentAmongRecords } from "./being-age.mjs";
23
23
  import { reckoningContext } from "./reckoning-markers.mjs";
24
24
  import { cachedMetadataIndexes, noContentIndexPackages } from "./metadata-index.mjs";
25
- import { buildSiteIndex } from "./site-index.mjs";
25
+ import { seeAlsoPages } from "./see-also.mjs";
26
+ import { buildSiteIndex, resolveInfoboxRef } from "./site-index.mjs";
26
27
  import {
27
28
  attachGeneratedSections,
28
29
  findSqlBlocks,
@@ -320,7 +321,8 @@ export async function prepareSitePreview({ config = loadPackConfig() } = {}) {
320
321
  url: page.url,
321
322
  type: String(fm.type),
322
323
  });
323
- const related = relatedPages(edges, entries).get(page.url);
324
+ const deliberate = seeAlsoPages([page], (ref) => resolveInfoboxRef(index, ref));
325
+ const related = relatedPages(edges, entries, deliberate).get(page.url);
324
326
  if (related) result.data.related = related;
325
327
  return {
326
328
  ok: true,
@@ -879,8 +879,9 @@ export function renderSqlTable(
879
879
  * its own table and not the whole build's report.
880
880
  *
881
881
  * @param {object} db - From {@link openNotesDatabase}.
882
- * @param {Array<{source: string, markdown: string}>} sources - The bodies to
883
- * scan.
882
+ * @param {Array<{source: string, markdown: string, frontmatter?: object, authoredSqlBlocks?: number}>} sources -
883
+ * The bodies to scan. `joinGeneratedSections` records the number of authored
884
+ * fences; following generated event fences need no stub comparison.
884
885
  * @param {object} [opts]
885
886
  * @param {(ref: string) => boolean} [opts.linkable] - Passed to
886
887
  * {@link renderSqlTable}.
@@ -904,26 +905,33 @@ export async function prepareSqlTables(db, sources, { linkable, records, pageLan
904
905
  const prepared = new Map();
905
906
  if (records) prepared.pageLists = preparePageLists(records, sources);
906
907
  // The corpus does not change while a preparation runs, so a query written
907
- // twice has one answer. Each use gets its own copy, because a caller
908
- // rewrites the rows it is handed.
908
+ // twice has one answer in each stub context. The normal result and the
909
+ // inclusive comparison occupy separate fields of the same cache entry.
910
+ // Each result use gets a copy because a caller rewrites the rows.
909
911
  const answers = new Map();
910
912
  const reader = {
911
913
  ...db,
912
914
  async query(sql) {
913
- if (!answers.has(sql)) answers.set(sql, await db.query(sql));
914
- return structuredClone(answers.get(sql));
915
+ if (!answers.has(sql)) answers.set(sql, { result: await db.query(sql) });
916
+ return structuredClone(answers.get(sql).result);
917
+ },
918
+ async stubsExcluded(sql, rows) {
919
+ const answer = answers.get(sql);
920
+ if (!Object.hasOwn(answer, "excluded"))
921
+ answer.excluded = await db.stubsExcluded?.(sql, rows);
922
+ return answer.excluded;
915
923
  },
916
924
  };
917
925
  let labels;
918
926
  const labelOf = (address) => labels.get(address);
919
927
  const languageOf =
920
928
  pageLanguage ?? ((_source, frontmatter) => frontmatterLanguage(frontmatter, db));
921
- for (const { source, markdown, frontmatter } of sources) {
929
+ for (const { source, markdown, frontmatter, authoredSqlBlocks = Infinity } of sources) {
922
930
  const blocks = findSqlBlocks(markdown);
923
931
  if (!blocks.length) continue;
924
932
  const forNote = [];
925
933
  prepared.set(source, forNote);
926
- for (const block of blocks) {
934
+ for (const [ordinal, block] of blocks.entries()) {
927
935
  try {
928
936
  if (block.problems.length) throw new Error(block.problems.join("; "));
929
937
  const result = await runSqlQuery(reader, block.query);
@@ -952,7 +960,12 @@ export async function prepareSqlTables(db, sources, { linkable, records, pageLan
952
960
  // relation, so a fence that has not adopted stubs says so
953
961
  // rather than quietly listing fewer settlements than the
954
962
  // region has.
955
- stubsExcluded: await db.stubsExcluded?.(block.query, result.rows.length),
963
+ // Generated event views read entries and events, whose
964
+ // meaning is identical in both stub contexts.
965
+ stubsExcluded:
966
+ ordinal >= authoredSqlBlocks ? 0 : (
967
+ await reader.stubsExcluded(block.query, result.rows.length)
968
+ ),
956
969
  allowEmpty: block.allowEmpty,
957
970
  });
958
971
  } catch (err) {
@@ -1138,7 +1151,7 @@ export async function generatedSectionsFor(
1138
1151
  *
1139
1152
  * @param {{sections: Map<string, string>, views: Set<string>}} generated -
1140
1153
  * From {@link generatedSectionsFor}.
1141
- * @param {Array<{source: string, markdown: string, frontmatter?: object}>} sources -
1154
+ * @param {Array<{source: string, markdown: string, frontmatter?: object, authoredSqlBlocks?: number}>} sources -
1142
1155
  * The bodies to answer, extended in place.
1143
1156
  * @param {Map<string, {body: string, frontmatter: object|null}>} [parsed] -
1144
1157
  * Notes already parsed, keyed by file; a note absent from it is read.
@@ -1148,6 +1161,7 @@ export function joinGeneratedSections(generated, sources, parsed) {
1148
1161
  for (const [absPath, text] of generated.sections) {
1149
1162
  const source = sources.find((one) => one.source === absPath);
1150
1163
  if (source) {
1164
+ source.authoredSqlBlocks ??= findSqlBlocks(source.markdown).length;
1151
1165
  source.markdown = appendGeneratedSections(source.markdown, text);
1152
1166
  continue;
1153
1167
  }
@@ -1157,6 +1171,7 @@ export function joinGeneratedSections(generated, sources, parsed) {
1157
1171
  source: absPath,
1158
1172
  markdown: appendGeneratedSections(body, text),
1159
1173
  frontmatter,
1174
+ authoredSqlBlocks: findSqlBlocks(body).length,
1160
1175
  });
1161
1176
  }
1162
1177
  }
@@ -378,17 +378,19 @@ function resolveBody(note, index, body) {
378
378
 
379
379
  /** Findings about the body an entry names. */
380
380
  function bodyFindings(note, body, path, isKey, found) {
381
- const at = position(note, path, isKey);
381
+ const at = () => position(note, path, isKey);
382
382
  const written = isAddressTuple(body) ? renderAddress(body) : String(body);
383
383
  const label = `\`data.affiliations\`${isKey ? " key" : ""} ${JSON.stringify(written)}`;
384
384
  if (found.reason)
385
- return [{ ...at, message: `${label} is not a complete Address (${found.reason})` }];
385
+ return [{ ...at(), message: `${label} is not a complete Address (${found.reason})` }];
386
386
  if (found.type)
387
- return [{ ...at, message: `\`data.affiliations\` accepts affiliation, not ${found.type}` }];
387
+ return [
388
+ { ...at(), message: `\`data.affiliations\` accepts affiliation, not ${found.type}` },
389
+ ];
388
390
  if (found.missing)
389
391
  return [
390
392
  {
391
- ...at,
393
+ ...at(),
392
394
  message:
393
395
  `\`data.affiliations\` names ${found.address}, which does not resolve ` +
394
396
  "in this package or its declared dependencies",
@@ -438,12 +440,12 @@ function absentRankFindings(note, body, path) {
438
440
  function rankFindings(note, standing, body, path, resolved) {
439
441
  // Absent, or not a whole number: the inner-key check's finding.
440
442
  if (!fitsKind(standing.rank ?? null, "integer")) return [];
441
- const at = position(note, [...path, "rank"]);
443
+ const at = () => position(note, [...path, "rank"]);
442
444
  const level = typeof standing.rank === "number" ? standing.rank : Number(standing.rank);
443
445
  if (typeof standing.rank !== "number" || !Number.isInteger(level))
444
446
  return [
445
447
  {
446
- ...at,
448
+ ...at(),
447
449
  message:
448
450
  `\`data.affiliations\` entry for ${body}: \`rank\` must be the level on that body's own ` +
449
451
  `ladder, as a whole number, but reads ${JSON.stringify(standing.rank)}`,
@@ -455,7 +457,7 @@ function rankFindings(note, standing, body, path, resolved) {
455
457
  if (!ladder.size)
456
458
  return [
457
459
  {
458
- ...at,
460
+ ...at(),
459
461
  message:
460
462
  `\`data.affiliations\` entry for ${body} states a rung, and that body ` +
461
463
  "declares no `data.governance.ranks` ladder to hold one",
@@ -464,7 +466,7 @@ function rankFindings(note, standing, body, path, resolved) {
464
466
  const levels = [...ladder.keys()].sort((a, b) => a - b);
465
467
  return [
466
468
  {
467
- ...at,
469
+ ...at(),
468
470
  message:
469
471
  `\`data.affiliations\` entry for ${body}: rank ${level} is not a level ` +
470
472
  `${resolved.address} confers; its ladder runs ${levels.join(", ")}`,
@@ -477,11 +479,11 @@ function officeFindings(note, standing, body, path, resolved) {
477
479
  // Absent is a membership with no post; a value that is not text is the
478
480
  // inner-key check's finding.
479
481
  if (typeof standing.office !== "string") return [];
480
- const at = position(note, [...path, "office"]);
482
+ const at = () => position(note, [...path, "office"]);
481
483
  if (!standing.office.trim())
482
484
  return [
483
485
  {
484
- ...at,
486
+ ...at(),
485
487
  message:
486
488
  `\`data.affiliations\` entry for ${body}: \`office\` must name a post from its ` +
487
489
  `\`governance.offices\`, but reads ${JSON.stringify(standing.office)}`,
@@ -493,7 +495,7 @@ function officeFindings(note, standing, body, path, resolved) {
493
495
  if (!roster.size)
494
496
  return [
495
497
  {
496
- ...at,
498
+ ...at(),
497
499
  message:
498
500
  `\`data.affiliations\` entry for ${body} names an office, and that body ` +
499
501
  `${resolved.address} declares no \`data.governance.offices\` to hold one`,
@@ -501,7 +503,7 @@ function officeFindings(note, standing, body, path, resolved) {
501
503
  ];
502
504
  return [
503
505
  {
504
- ...at,
506
+ ...at(),
505
507
  message:
506
508
  `\`data.affiliations\` entry for ${body}: office ${JSON.stringify(standing.office)} is not ` +
507
509
  `an office ${resolved.address} names; it holds ${[...roster.keys()].join(", ")}`,
@@ -6,11 +6,13 @@
6
6
 
7
7
  Structure in front matter:
8
8
  related:
9
+ seeAlso: [{ title, url, type, gloss? }, ...] # deliberate references
9
10
  backlinks: [{ title, url, type }, ...] # pages that link here
10
11
  mentions: [{ title, url, type }, ...] # pages this page links to
11
12
 
12
13
  Rendering strategy:
13
- - Merge both directions into a single deduped pool (direction
14
+ - Render deliberate references in authored order, with their glosses.
15
+ - Merge both inferred directions into a single deduped pool (direction
14
16
  doesn't matter to a reader — a connection is a connection).
15
17
  - Group by the referenced page's `type`.
16
18
  - Emit one labelled group per type, its entries laid out as a
@@ -20,7 +22,7 @@
20
22
  - The whole block is wrapped in a bordered, tinted card so it
21
23
  visually sets off the end of the article.
22
24
 
23
- Silent disappear: a page with no `related`, or whose two lists are both
25
+ Silent disappear: a page with no `related`, or whose lists are all
24
26
  empty, emits nothing at all rather than an empty heading. The rows are
25
27
  computed first and the <section> is rendered only when there is something
26
28
  to put in it.
@@ -34,15 +36,23 @@
34
36
  {{- /* `default dict` keeps the lookups below safe on a page with no
35
37
  `related` at all, so the whole computation stays flat. */ -}}
36
38
  {{- $rel := .Params.related | default dict -}}
39
+ {{- $seeAlso := or $rel.seealso slice -}}
37
40
  {{- $backlinks := or $rel.backlinks slice -}}
38
41
  {{- $mentions := or $rel.mentions slice -}}
39
42
 
40
43
  {{- /* Merge both directions, deduped by URL. `union` can't hash
41
44
  slices of maps, so we walk the two lists in order and dedupe
42
- by URL manually. Mentions come first so explicit author
43
- choices win when a page appears in both directions. */ -}}
45
+ by URL manually. Deliberate references take priority; mentions
46
+ precede backlinks among inferred connections. */ -}}
44
47
  {{- $seen := slice -}}
45
48
  {{- $merged := slice -}}
49
+ {{- $deliberate := slice -}}
50
+ {{- range $seeAlso -}}
51
+ {{- if not (in $seen .url) -}}
52
+ {{- $seen = $seen | append .url -}}
53
+ {{- $deliberate = $deliberate | append . -}}
54
+ {{- end -}}
55
+ {{- end -}}
46
56
  {{- range $mentions -}}
47
57
  {{- if not (in $seen .url) -}}
48
58
  {{- $seen = $seen | append .url -}}
@@ -101,12 +111,19 @@
101
111
  {{- $rows = sort $rows "label" "asc" -}}
102
112
  {{- end -}}
103
113
 
104
- {{- /* Deliberately not indented under the `if`: keeping the markup at
105
- column 0 leaves the emitted HTML byte-identical to what pages with
106
- `related` rendered before the guard was added. */ -}}
107
- {{- if $rows -}}
114
+ {{- if or $rows $deliberate -}}
108
115
  <section class="related" aria-labelledby="related-heading" data-pagefind-ignore>
109
116
  <h2 id="related-heading" class="related-heading">Related</h2>
117
+ {{- with $deliberate }}
118
+ <div class="related-group">
119
+ <h3 class="related-group-label">See also</h3>
120
+ <ul class="related-group-grid">
121
+ {{- range . }}
122
+ <li><a href="{{ .url }}">{{ .title }}</a>{{ with .gloss }} — {{ . }}{{ end }}</li>
123
+ {{- end }}
124
+ </ul>
125
+ </div>
126
+ {{- end }}
110
127
  {{- range $rows }}
111
128
  <div class="related-group">
112
129
  <h3 class="related-group-label">{{ .label }}</h3>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "23.7.1",
3
+ "version": "23.8.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -132,6 +132,7 @@
132
132
  "dependencies": {
133
133
  "@duckdb/node-api": "^1.5.5-r.4",
134
134
  "@foundryvtt/foundryvtt-cli": "^3.0.4",
135
+ "@puppeteer/browsers": "^3.2.5",
135
136
  "@viz-js/viz": "^3.30.0",
136
137
  "acorn": "^8.18.0",
137
138
  "archiver": "^8.0.0",
@@ -247,8 +247,8 @@ export function s3RunArgs(plan: S3Plan): string[];
247
247
  */
248
248
  export function s3ShellArgs(plan: S3Plan, command: string): string[];
249
249
  /**
250
- * Start the S3 server on its network and create the bucket, unless it is
251
- * already running, and copy the `awsConfig` into the data root.
250
+ * Start or reuse the S3 server on its network, wait for readiness, ensure the
251
+ * bucket exists, and copy the `awsConfig` into the data root.
252
252
  *
253
253
  * @param {object} opts
254
254
  * @param {S3Plan} opts.plan - The S3 plan.
@@ -261,7 +261,8 @@ export function s3ShellArgs(plan: S3Plan, command: string): string[];
261
261
  * @param {(ms: number) => void} [opts.sleep] - Waits.
262
262
  * @param {number} [opts.attempts] - How often to ask the server whether it is
263
263
  * ready.
264
- * @throws {Error} When an input file is missing or the server never answers.
264
+ * @throws {Error} When an input is not a readable regular file, a reused
265
+ * identities mount is incompatible, or the S3 HTTP service never answers.
265
266
  */
266
267
  export function ensureS3({ plan, dataRoot, log, run, capture, sleep, attempts, }: {
267
268
  plan: S3Plan;
package/types/e2e.d.mts CHANGED
@@ -236,6 +236,32 @@ export function waitForWorld({ url, container, stage, dataRoot, readLog, timeout
236
236
  * @returns {string[]} The executables to resolve, in the order to report them.
237
237
  */
238
238
  export function suiteExecutables(command: readonly string[]): string[];
239
+ /**
240
+ * The browser cache survives reseeding and container teardown.
241
+ * @param {NodeJS.ProcessEnv} [env] - Environment overrides.
242
+ * @returns {string} The browser cache directory.
243
+ */
244
+ export function browserCacheDir(env?: NodeJS.ProcessEnv): string;
245
+ /**
246
+ * Supply pinned Chrome only to a recognizable Cypress command without a browser.
247
+ * Explicit browser arguments remain verbatim, including Electron and paths.
248
+ * @param {object} opts
249
+ * @param {readonly string[]} opts.command - The configured suite command.
250
+ * @param {string[]} [opts.args] - Extra suite arguments.
251
+ * @param {NodeJS.ProcessEnv} [opts.env] - Cache overrides.
252
+ * @param {string} [opts.platform] - Host operating system.
253
+ * @param {string} [opts.arch] - Host architecture.
254
+ * @param {(message: string) => void} [opts.log] - Progress reporting.
255
+ * @returns {Promise<string[]>} Arguments to append to the suite command.
256
+ */
257
+ export function prepareSuiteBrowser({ command, args, env, platform, arch, log, }: {
258
+ command: readonly string[];
259
+ args?: string[] | undefined;
260
+ env?: NodeJS.ProcessEnv | undefined;
261
+ platform?: string | undefined;
262
+ arch?: string | undefined;
263
+ log?: ((message: string) => void) | undefined;
264
+ }): Promise<string[]>;
239
265
  /**
240
266
  * Find an executable the way the child process will: a path is a path, and a
241
267
  * bare name is looked for in the repository's `node_modules/.bin` first, then
@@ -467,6 +493,8 @@ export const E2E_GM_ID: "heroiclandsE2EGM";
467
493
  export const E2E_SCENE_ID: "heroiclandsScene";
468
494
  /** How the suite may be run. */
469
495
  export const E2E_MODES: readonly string[];
496
+ /** The Chrome for Testing build shared by local and CI Cypress runs. */
497
+ export const CHROME_BUILD_ID: "155.0.8059.84";
470
498
  /**
471
499
  * The disposable world's identity and credentials.
472
500
  */
@@ -631,8 +631,8 @@ export class BasePackCompiler {
631
631
  compile(): Promise<void>;
632
632
  currentNote: {
633
633
  absPath: string;
634
- bodyLine: number | undefined;
635
- bodyColumn: number | undefined;
634
+ bodyLine: any;
635
+ bodyColumn: any;
636
636
  } | undefined;
637
637
  #private;
638
638
  }
@@ -15,7 +15,7 @@
15
15
  * created when none is passed, and returned on the corpus either way — a note
16
16
  * the index refuses is a note the compile must still *report*, exactly as the
17
17
  * compile loop reported it when the loop was the first to see it.
18
- * @returns {Promise<{records: object[], linkIndex: object, sqlTables: Map<string, object[]>|undefined, problems: object[]}>} The corpus,
18
+ * @returns {Promise<{records: object[], parsed: Map<string, object>, linkIndex: object, sqlTables: Map<string, object[]>|undefined, problems: object[]}>} The corpus,
19
19
  * its indexes, and the notes it could not record.
20
20
  */
21
21
  export function buildCompileCorpus({ contentBase, assetsBase, skipDirectories, router, config, problems, }: {
@@ -27,6 +27,7 @@ export function buildCompileCorpus({ contentBase, assetsBase, skipDirectories, r
27
27
  problems?: object[] | undefined;
28
28
  }): Promise<{
29
29
  records: object[];
30
+ parsed: Map<string, object>;
30
31
  linkIndex: object;
31
32
  sqlTables: Map<string, object[]> | undefined;
32
33
  problems: object[];
@@ -94,6 +94,7 @@ export function asciiAliases(aliases: unknown): Array<string>;
94
94
  * @param {string} options.relPath - Its path below the content root, POSIX-separated.
95
95
  * @param {string} [options.absPath] - The file, read only on the failing path to
96
96
  * locate the offending key.
97
+ * @param {string} [options.raw] - Source text already read by the caller.
97
98
  * @param {string} options.contentPackage - The package the tree compiles as.
98
99
  * @param {string} [options.body] - The note's markdown body, for its anchors
99
100
  * and for the one question that decides whether the note publishes a page. A
@@ -118,10 +119,11 @@ export function asciiAliases(aliases: unknown): Array<string>;
118
119
  * otherwise be overwritten without a word. `file` and, where the file was
119
120
  * read, `position` ride on the error.
120
121
  */
121
- export function buildIndexRecord({ frontmatter, relPath, absPath, contentPackage, body, bodyLine, manifest, addressContext, dateContext, resolveRole, }: {
122
+ export function buildIndexRecord({ frontmatter, relPath, absPath, raw, contentPackage, body, bodyLine, manifest, addressContext, dateContext, resolveRole, }: {
122
123
  frontmatter: Record<string, any>;
123
124
  relPath: string;
124
125
  absPath?: string | undefined;
126
+ raw?: string | undefined;
125
127
  contentPackage: string;
126
128
  body?: string | undefined;
127
129
  bodyLine?: number | undefined;
@@ -131,12 +133,13 @@ export function buildIndexRecord({ frontmatter, relPath, absPath, contentPackage
131
133
  resolveRole?: ((address: string) => string | undefined) | undefined;
132
134
  }): Record<string, any>;
133
135
  /** Derive every index record belonging to one parsed note. */
134
- export function indexRecordsForNote({ frontmatter, body, bodyLine, relPath, absPath, contentPackage, manifest, addressContext, dateContext, resolveRole, }: {
136
+ export function indexRecordsForNote({ frontmatter, body, bodyLine, relPath, absPath, raw, contentPackage, manifest, addressContext, dateContext, resolveRole, }: {
135
137
  frontmatter: any;
136
138
  body: any;
137
139
  bodyLine: any;
138
140
  relPath: any;
139
141
  absPath: any;
142
+ raw: any;
140
143
  contentPackage: any;
141
144
  manifest: any;
142
145
  addressContext: any;
@@ -188,16 +191,20 @@ export function indexRecordsForNote({ frontmatter, body, bodyLine, relPath, absP
188
191
  * immediate parent whose package is not this one.
189
192
  * @param {object[]} [options.problems] - Receives each conflict as a finding.
190
193
  * Omitted, every conflict is collected and one Error lists them all.
194
+ * @param {Map<string, {raw: string}>} [options.parsed] - Notes already read, keyed by file.
191
195
  * @returns {Array<Record<string, any>>} The same records.
192
196
  * @throws {Error} When `problems` is omitted and any note's sources disagree.
193
197
  */
194
- export function resolveCultures(records: Array<Record<string, any>>, { contentPackage, contentBase, foreignRecord, problems }: {
198
+ export function resolveCultures(records: Array<Record<string, any>>, { contentPackage, contentBase, foreignRecord, problems, parsed }: {
195
199
  contentPackage: string;
196
200
  contentBase?: string | undefined;
197
201
  foreignRecord?: ((key: string) => ({
198
202
  culture?: unknown;
199
203
  } | undefined)) | undefined;
200
204
  problems?: object[] | undefined;
205
+ parsed?: Map<string, {
206
+ raw: string;
207
+ }> | undefined;
201
208
  }): Array<Record<string, any>>;
202
209
  /**
203
210
  * Read a content tree into index records, in the order they will be written.
@@ -233,9 +240,9 @@ export function resolveCultures(records: Array<Record<string, any>>, { contentPa
233
240
  * @param {(key: string) => ({culture?: unknown}|undefined)} [options.foreignRecord] -
234
241
  * Another package's entry for a canonical address, read for a parent that
235
242
  * package publishes; see {@link resolveCultures}.
236
- * @param {Map<string, {body: string, frontmatter: object|null}>} [options.parsed] -
237
- * Filled with each note's body and its frontmatter as authored, keyed by the
238
- * note's file, so a later pass reads the notes this walk already parsed.
243
+ * @param {Map<string, {body: string, frontmatter: object|null, raw: string, bodyLine?: number, bodyColumn?: number}>} [options.parsed] -
244
+ * Filled with each note's source text, body coordinates and frontmatter as
245
+ * authored, keyed by file, for reuse throughout the command.
239
246
  * @returns {Array<Record<string, any>>} The records, in a total order that does
240
247
  * not depend on directory-read order.
241
248
  */
@@ -252,6 +259,9 @@ export function collectContentIndex(contentBase: string, { contentPackage, skipD
252
259
  parsed?: Map<string, {
253
260
  body: string;
254
261
  frontmatter: object | null;
262
+ raw: string;
263
+ bodyLine?: number;
264
+ bodyColumn?: number;
255
265
  }> | undefined;
256
266
  }): Array<Record<string, any>>;
257
267
  /**
@@ -289,7 +299,7 @@ export function serializeContentIndex(records: Array<Record<string, any>>): stri
289
299
  * aborting the derivation. Omitted, the note throws — which is the contract
290
300
  * the emitter needs, since an index missing a note asserts that it does not
291
301
  * exist.
292
- * @param {Map<string, {body: string, frontmatter: object|null}>} [opts.parsed] -
302
+ * @param {Map<string, {body: string, frontmatter: object|null, raw: string, bodyLine?: number, bodyColumn?: number}>} [opts.parsed] -
293
303
  * Filled with each note's body and authored frontmatter, keyed by the note's
294
304
  * file. Handed to {@link module:engine/sql-tables.prepareTreeSqlTables}, it
295
305
  * spares that pass reading and parsing every note again.
@@ -304,6 +314,9 @@ export function indexRecordsFor({ contentBase, assetsBase, config, skipDirectori
304
314
  parsed?: Map<string, {
305
315
  body: string;
306
316
  frontmatter: object | null;
317
+ raw: string;
318
+ bodyLine?: number;
319
+ bodyColumn?: number;
307
320
  }> | undefined;
308
321
  }): object[];
309
322
  /**