@heroiclands/package-build 22.21.0 → 22.22.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 (110) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/assets/styles/content-base.css +162 -0
  3. package/bin/content-commands.mjs +26 -3
  4. package/bin/package-build.mjs +15 -2
  5. package/ci/content-format-reference.mjs +68 -1
  6. package/config.mjs +26 -0
  7. package/content-config.mjs +73 -4
  8. package/docs/api.md +36 -18
  9. package/docs/authoring/dates-and-calendars.md +4 -2
  10. package/docs/authoring/frontmatter.md +2 -2
  11. package/docs/authoring/links-and-markup.md +237 -28
  12. package/docs/configuration.md +27 -1
  13. package/docs/content-format.md +3 -1
  14. package/docs/guides/reference-pages.md +15 -0
  15. package/docs/reference/format-details.md +224 -46
  16. package/docs/reference/note-types.md +21 -4
  17. package/engine/actor-compiler.mjs +23 -0
  18. package/engine/anchored-sections.mjs +35 -4
  19. package/engine/anchors.mjs +10 -20
  20. package/engine/base-compiler.mjs +86 -21
  21. package/engine/base-styles.mjs +149 -0
  22. package/engine/bundles.mjs +0 -1
  23. package/engine/compendiums.mjs +8 -1
  24. package/engine/content-blocks.mjs +410 -0
  25. package/engine/content-captions.mjs +57 -8
  26. package/engine/content-footnotes.mjs +189 -0
  27. package/engine/content-format-check.mjs +15 -12
  28. package/engine/content-index.mjs +2 -2
  29. package/engine/content-links.mjs +14 -6
  30. package/engine/content-tables.mjs +45 -4
  31. package/engine/content-tasklists.mjs +189 -0
  32. package/engine/docs-checks.mjs +4 -4
  33. package/engine/extension-attributes.mjs +48 -2
  34. package/engine/frontmatter-lint.mjs +47 -64
  35. package/engine/frontmatter.mjs +3 -3
  36. package/engine/heading-attributes.mjs +308 -0
  37. package/engine/helpers.mjs +14 -13
  38. package/engine/homepage.mjs +8 -8
  39. package/engine/index.mjs +3 -0
  40. package/engine/infobox-render.mjs +15 -3
  41. package/engine/infobox.mjs +83 -8
  42. package/engine/journals.mjs +36 -17
  43. package/engine/macros.mjs +1 -1
  44. package/engine/markdown-expressions.mjs +178 -53
  45. package/engine/note-dates.mjs +49 -0
  46. package/engine/note-frontmatter.mjs +7 -16
  47. package/engine/note-vocabulary.mjs +172 -37
  48. package/engine/page-lists.mjs +358 -0
  49. package/engine/pdf-build.mjs +76 -42
  50. package/engine/pdf-render.mjs +422 -49
  51. package/engine/rank-ladder.mjs +198 -0
  52. package/engine/reckoning-markers.mjs +1 -1
  53. package/engine/retired-fields.mjs +8 -13
  54. package/engine/scenes.mjs +10 -3
  55. package/engine/site-build.mjs +41 -12
  56. package/engine/site-config.mjs +10 -1
  57. package/engine/site-index.mjs +18 -2
  58. package/engine/site-preview.mjs +18 -7
  59. package/engine/social-ties.mjs +52 -14
  60. package/engine/sql-tables.mjs +66 -9
  61. package/engine/standings.mjs +89 -6
  62. package/engine/system-block.mjs +12 -14
  63. package/engine/web-wikilinks.mjs +48 -1
  64. package/hm3/actors.mjs +6 -3
  65. package/manifest.mjs +13 -0
  66. package/package.json +1 -1
  67. package/sohl/actors.mjs +22 -99
  68. package/sohl/item-fields.mjs +4 -5
  69. package/sohl/items.mjs +1 -1
  70. package/sohl/note-schemas.mjs +11 -21
  71. package/types/config.d.mts +9 -0
  72. package/types/engine/actor-compiler.d.mts +11 -0
  73. package/types/engine/anchored-sections.d.mts +24 -0
  74. package/types/engine/base-compiler.d.mts +19 -0
  75. package/types/engine/base-styles.d.mts +93 -0
  76. package/types/engine/content-blocks.d.mts +87 -0
  77. package/types/engine/content-captions.d.mts +14 -1
  78. package/types/engine/content-footnotes.d.mts +73 -0
  79. package/types/engine/content-format-check.d.mts +8 -6
  80. package/types/engine/content-tables.d.mts +12 -4
  81. package/types/engine/content-tasklists.d.mts +72 -0
  82. package/types/engine/extension-attributes.d.mts +22 -1
  83. package/types/engine/frontmatter.d.mts +1 -1
  84. package/types/engine/heading-attributes.d.mts +174 -0
  85. package/types/engine/helpers.d.mts +7 -4
  86. package/types/engine/homepage.d.mts +7 -7
  87. package/types/engine/index.d.mts +1 -0
  88. package/types/engine/infobox-render.d.mts +6 -1
  89. package/types/engine/journals.d.mts +14 -7
  90. package/types/engine/markdown-expressions.d.mts +23 -0
  91. package/types/engine/note-frontmatter.d.mts +1 -4
  92. package/types/engine/note-vocabulary.d.mts +82 -6
  93. package/types/engine/page-lists.d.mts +126 -0
  94. package/types/engine/pdf-render.d.mts +72 -2
  95. package/types/engine/rank-ladder.d.mts +32 -0
  96. package/types/engine/retired-fields.d.mts +4 -6
  97. package/types/engine/site-build.d.mts +13 -1
  98. package/types/engine/site-config.d.mts +9 -0
  99. package/types/engine/site-index.d.mts +13 -1
  100. package/types/engine/site-preview.d.mts +1 -1
  101. package/types/engine/sql-tables.d.mts +31 -7
  102. package/types/engine/standings.d.mts +21 -3
  103. package/types/engine/system-block.d.mts +11 -12
  104. package/types/engine/web-wikilinks.d.mts +3 -1
  105. package/types/hm3/actors.d.mts +3 -3
  106. package/types/sohl/actors.d.mts +6 -7
  107. package/engine/content-admonitions.mjs +0 -114
  108. package/engine/content-secrets.mjs +0 -112
  109. package/types/engine/content-admonitions.d.mts +0 -24
  110. package/types/engine/content-secrets.d.mts +0 -17
@@ -0,0 +1,198 @@
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
+ /**
15
+ * What a **rung** of an affiliation's rank ladder has to state.
16
+ *
17
+ * A rung is a map of `level`, `title` and `description`, all three required,
18
+ * plus an optional `lore` Address for a standing that needs more said about it
19
+ * than a description can hold. Those three keys together are a complete
20
+ * statement of a rung; nothing further is needed for a ladder to be correct.
21
+ *
22
+ * **A level belongs to one rung.** The ladder is read by level, so two rungs
23
+ * claiming one level leave a member's `rank` answering to whichever is written
24
+ * first — a standing whose name changes when two lines swap places. A body that
25
+ * confers two titles at one standing writes them as one rung, or gives each its
26
+ * own level.
27
+ *
28
+ * **The severity is error**, because the ladder is what a being's `rank`
29
+ * indexes into: a rung missing its `title` makes a standing that resolves to
30
+ * nothing, and one missing its `description` ships a rung that says nothing on
31
+ * a sheet or a page.
32
+ *
33
+ * Two things are deliberately **not** checked here, because something else
34
+ * already does and a finding reported twice is a finding read once and fixed
35
+ * neither time:
36
+ *
37
+ * - **that `ranks` is a list at all** — the field declares `kind: "list"`, and
38
+ * the shape check names it;
39
+ * - **that `lore` resolves** — the reference checker reads every Address in the
40
+ * note, this one included.
41
+ *
42
+ * @module
43
+ */
44
+
45
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
46
+
47
+ /** The keys a rung may carry. */
48
+ const RUNG_KEYS = Object.freeze(["level", "title", "description", "lore"]);
49
+
50
+ /** The keys a rung must carry a value for. */
51
+ const REQUIRED_TEXT = Object.freeze(["title", "description"]);
52
+
53
+ /**
54
+ * Whether a value is a YAML mapping rather than a list or a scalar.
55
+ *
56
+ * @param {unknown} value - What was authored.
57
+ * @returns {boolean} True for a mapping.
58
+ */
59
+ function mapping(value) {
60
+ return value !== null && typeof value === "object" && !Array.isArray(value);
61
+ }
62
+
63
+ /**
64
+ * Whether a value states a whole number.
65
+ *
66
+ * A quoted `"3"` counts, because YAML hands a quoted scalar back as a string
67
+ * and the ladder reads it as a number either way — `3.5` does not, because a
68
+ * rung sits on a rung and there is nothing between two of them.
69
+ *
70
+ * @param {unknown} value - The authored `level`.
71
+ * @returns {boolean} True when it is an integer.
72
+ */
73
+ function integer(value) {
74
+ if (typeof value === "number") return Number.isInteger(value);
75
+ if (typeof value !== "string" || value.trim() === "") return false;
76
+ return Number.isInteger(Number(value));
77
+ }
78
+
79
+ /**
80
+ * Whether a value states something rather than nothing.
81
+ *
82
+ * @param {unknown} value - The authored `title` or `description`.
83
+ * @returns {boolean} True for a non-empty string.
84
+ */
85
+ function stated(value) {
86
+ return typeof value === "string" && value.trim() !== "";
87
+ }
88
+
89
+ /**
90
+ * Check one affiliation's `data.governance.ranks` against the rung contract.
91
+ *
92
+ * Each problem is its own finding, so a rung missing all three of its required
93
+ * keys reports three rather than the first: a reader fixing a ladder wants the
94
+ * whole of it in one pass.
95
+ *
96
+ * **A finding is located by the node it is about**, through the frontmatter's
97
+ * own YAML tree — a present-but-wrong value points at that value, and an absent
98
+ * key points at the rung that should have carried it, since a key that was
99
+ * never written has no position of its own. Where even the rung cannot be
100
+ * located the position is dropped rather than guessed, and the finding names
101
+ * the file alone.
102
+ *
103
+ * @param {object} note - The note being checked.
104
+ * @param {string} note.file - Its path, as a diagnostic spells it.
105
+ * @param {string} [note.raw] - Its source, for the position.
106
+ * @param {object} [note.fm] - Its parsed frontmatter.
107
+ * @returns {Array<{file: string, line?: number, column?: number,
108
+ * severity: string, message: string}>} The findings, in authored order.
109
+ */
110
+ export function checkRankLadder(note) {
111
+ const ranks = note.fm?.data?.governance?.ranks;
112
+ // Absent is the ordinary case: most bodies confer no ranks at all. A value
113
+ // that is not a list is the shape check's finding, not this one's.
114
+ if (!Array.isArray(ranks)) return [];
115
+
116
+ const findings = [];
117
+ const base = ["data", "governance", "ranks"];
118
+
119
+ /**
120
+ * One finding, located at the first of the given paths that resolves.
121
+ *
122
+ * @param {Array<Array<string|number>>} paths - Candidates, most specific
123
+ * first.
124
+ * @param {string} message - What is wrong.
125
+ * @returns {void}
126
+ */
127
+ const at = (paths, message) => {
128
+ let position = {};
129
+ for (const path of paths) {
130
+ position = positionOfFrontmatterPath(note.raw ?? "", path);
131
+ if (position.line !== undefined) break;
132
+ }
133
+ findings.push({ file: note.file, ...position, severity: "error", message });
134
+ };
135
+
136
+ // Level → how the rung holding it is named, so a repeat can say which rung
137
+ // already holds the level rather than only that something does.
138
+ const held = new Map();
139
+
140
+ ranks.forEach((rung, index) => {
141
+ const rungPath = [...base, index];
142
+ // Named by its level where it states one, because a reader scanning a
143
+ // ladder of fifteen rungs knows them by level and not by position.
144
+ const which =
145
+ integer(rung?.level) ? `rank ${rung.level}` : `rank ${index + 1} of the ladder`;
146
+
147
+ if (!mapping(rung)) {
148
+ at([rungPath, base], `${which} must be a map of level, title and description`);
149
+ return;
150
+ }
151
+
152
+ for (const key of Object.keys(rung)) {
153
+ if (RUNG_KEYS.includes(key)) continue;
154
+ at(
155
+ [[...rungPath, key], rungPath],
156
+ `${which} has unknown key ${key}; a rung takes ${RUNG_KEYS.join(", ")}`,
157
+ );
158
+ }
159
+
160
+ if (!integer(rung.level)) {
161
+ at(
162
+ [[...rungPath, "level"], rungPath],
163
+ rung.level === undefined || rung.level === null ?
164
+ `${which} needs a level — the rung's position on this body's own ladder`
165
+ : `${which} has a level that is not a whole number`,
166
+ );
167
+ } else {
168
+ // A level belongs to one rung. Two rungs claiming it leave a
169
+ // member's `rank` answering to the one written first, so the
170
+ // ladder's own order decides what the standing is called and
171
+ // moving two lines renames it. Reported on the second rung,
172
+ // naming the first, because the first is the one a reader has to
173
+ // find to tell the two apart.
174
+ const level = Number(rung.level);
175
+ const named = stated(rung.title) ? JSON.stringify(rung.title) : which;
176
+ const first = held.get(level);
177
+ if (first === undefined) held.set(level, named);
178
+ else
179
+ at(
180
+ [[...rungPath, "level"], rungPath],
181
+ `rank ${level} is declared twice, as ${first} and ${named}; a level ` +
182
+ `is a rung's identity and a member's rank indexes into it, so ` +
183
+ `each rung states its own`,
184
+ );
185
+ }
186
+
187
+ for (const key of REQUIRED_TEXT) {
188
+ if (stated(rung[key])) continue;
189
+ at(
190
+ [[...rungPath, key], rungPath],
191
+ `${which} needs a ${key}: ` +
192
+ (key === "title" ? "what the standing is called" : "what the standing is"),
193
+ );
194
+ }
195
+ });
196
+
197
+ return findings;
198
+ }
@@ -54,7 +54,7 @@ export function resolveReckoningMarkers(index, daysPerYear) {
54
54
  1,
55
55
  daysPerYear,
56
56
  );
57
- const pkg = fm.package ?? note.package ?? index?.contentPackage;
57
+ const pkg = note.package ?? index?.contentPackage;
58
58
  const declared = [];
59
59
  for (const [position, era] of rows.entries()) {
60
60
  if (!era || typeof era.shortcode !== "string" || !era.shortcode || !fm.shortcode)
@@ -58,11 +58,9 @@
58
58
  * one of them in. Every note that carried one has been moved, across four
59
59
  * repositories, and this is the third step of that retirement.
60
60
  *
61
- * Refusing it matters more than refusing an ordinary dead key, because top
62
- * level is *deliberately open*: an unrecognised key there is passed through to
63
- * Hugo, so a stray `traits:` would not be ignored loudly but would arrive on
64
- * the page as a theme parameter, checked by nothing. The whole argument for
65
- * `data:` being closed is the argument for refusing this.
61
+ * Refusing it by name rather than as an unknown key is what names the home the
62
+ * facts have: the finding says which `data:` key each one becomes, which an
63
+ * author cannot read off a list of the keys the region declares.
66
64
  *
67
65
  * `sohl.traits` is a different field that shares the name — `projectilegear`
68
66
  * declares one, and the theme's gear sidebar reads it — so the refusal is
@@ -350,8 +348,7 @@ export function traitsRetiredMessage(file) {
350
348
  (file ? ` — ${file}` : "") +
351
349
  ". A being's own description belongs in the closed container the " +
352
350
  "content format declares, where `being` declares every one of its " +
353
- "keys; at the top level it was passed through to the page unchecked, " +
354
- "so a misspelling became a theme parameter rather than a finding. " +
351
+ "keys. " +
355
352
  "`gender`, `species`, `age` and `appearance.*` move unchanged; " +
356
353
  "`traits.birthday` becomes `data.born`; three reshape — " +
357
354
  "`traits.height.m` becomes `data.height` " +
@@ -526,12 +523,10 @@ export function legacyKeyMessage(block, field, file) {
526
523
  * What a note writing a field at the **top-level key `data:` gathered it off**
527
524
  * is told.
528
525
  *
529
- * {@link legacyKeyMessage}'s counterpart for the other retiring position. The
530
- * sweep
531
- * did not invent the facts `data:` holds — it collected them out of the note's
532
- * open top level — so `portrait:` beside `img:` is the *pre-`data:`* spelling
533
- * of `data.portrait`, and both are read for the same reason both in-block
534
- * spellings are: a package moves its corpus when it is ready, not on a flag day.
526
+ * {@link legacyKeyMessage}'s counterpart for the other retiring position.
527
+ * `portrait:` beside `img:` is the bare spelling of `data.portrait`, and both
528
+ * are read for the same reason both in-block spellings are: a package moves its
529
+ * corpus when it is ready, not on a flag day.
535
530
  *
536
531
  * It says nothing about which value is emitted, because that is not what an
537
532
  * author needs from it. The value is the same either way; what the finding
package/engine/scenes.mjs CHANGED
@@ -50,6 +50,10 @@ import fs from "fs";
50
50
  import path from "path";
51
51
  import log from "loglevel";
52
52
 
53
+ // The one reader of a system block's own properties, so a scene's effect
54
+ // references resolve from the same position the item compiler emits from.
55
+ import { blockProperty } from "./system-block.mjs";
56
+
53
57
  import {
54
58
  parseMarkdownFile,
55
59
  sohlField,
@@ -235,7 +239,11 @@ export class Scenes extends BasePackCompiler {
235
239
  // `resolveNoteId(fm)` with no package, which falls back to the
236
240
  // ambient one. What remains unset is a file with no address.
237
241
  if (!fm || !fm.id) continue;
238
- if (fm.shortcode && Array.isArray(fm.effects) && fm.effects.length) {
242
+ // The Active Effects a region behaviour may point at, read from
243
+ // the block that carries them onto the document — the one position
244
+ // the format accepts for them.
245
+ const effects = blockProperty(fm, "sohl", "effects");
246
+ if (fm.shortcode && Array.isArray(effects) && effects.length) {
239
247
  effectsByAddress.set(`${fm.type}-${fm.shortcode}`, {
240
248
  id: fm.id,
241
249
  type: fm.type,
@@ -246,7 +254,7 @@ export class Scenes extends BasePackCompiler {
246
254
  // second router is a second answer to where the document
247
255
  // landed, resolved from the working directory.
248
256
  pack: this.#packRouter.resolveOrNull(fm, packForType(fm.type).docType),
249
- effects: fm.effects,
257
+ effects,
250
258
  });
251
259
  }
252
260
  if (!isMapType(fm.type)) continue;
@@ -532,7 +540,6 @@ export class Scenes extends BasePackCompiler {
532
540
  // materialise there too. The id spelling that used
533
541
  // to cross packs verbatim is retired.
534
542
  folder: this.folderResolver(authoredFolder, { isAddress: true }),
535
- flags: fm.flags,
536
543
  })
537
544
  : null;
538
545
 
@@ -62,9 +62,11 @@ import { addressSlug } from "./content-address.mjs";
62
62
  import { protectCode } from "./code-fences.mjs";
63
63
  import { renderMarkdownExpressions } from "./markdown-expressions.mjs";
64
64
  import { expandContentTables } from "./content-tables.mjs";
65
- import { renderSecretBlocks } from "./content-secrets.mjs";
66
- import { renderAdmonitions, scanAdmonitions } from "./content-admonitions.mjs";
65
+ import { renderBlocks, renderWithheldSections, scanBlocks } from "./content-blocks.mjs";
66
+ import { scanHeadingAttributes, withheldSections } from "./heading-attributes.mjs";
67
67
  import { renderCaptionBlocks, scanCaptions } from "./content-captions.mjs";
68
+ import { footnoteFindings } from "./content-footnotes.mjs";
69
+ import { collectAnchors } from "./anchors.mjs";
68
70
  import { renderImageFigures } from "./content-images.mjs";
69
71
  import { pathnameProblem, resolvePathname } from "./pathnames.mjs";
70
72
  import { buildSiteIndex, resolveInfoboxRef, wikiContext } from "./site-index.mjs";
@@ -601,8 +603,7 @@ export function pageFrontmatter(page, { decorate, webSrc, artSrc }) {
601
603
  }
602
604
 
603
605
  /**
604
- * The title a content page publishes under: an authored `title`, else the
605
- * note's name.
606
+ * The title a content page publishes under — the note's name.
606
607
  *
607
608
  * One rule, read by the page's own front matter and by every `related` entry
608
609
  * that names the page, so a card lists a page by exactly the title its heading
@@ -612,7 +613,7 @@ export function pageFrontmatter(page, { decorate, webSrc, artSrc }) {
612
613
  * @returns {string} The title.
613
614
  */
614
615
  function pageTitle(page) {
615
- return page.fm.title ?? page.name;
616
+ return page.name;
616
617
  }
617
618
 
618
619
  /**
@@ -704,6 +705,8 @@ export function renderSitePage(
704
705
  const expressionErrors = [];
705
706
  const secretErrors = [];
706
707
  const captionErrors = [];
708
+ const headingErrors = [];
709
+ const footnoteErrors = [];
707
710
  const wikiErrors = [];
708
711
  const imageErrors = [];
709
712
  const resolved = [];
@@ -753,11 +756,15 @@ export function renderSitePage(
753
756
  linkable,
754
757
  source: src,
755
758
  sqlTables: sqlTables?.get(page.file),
759
+ pageLists: sqlTables?.pageLists?.get(page.file),
756
760
  self: { fm: searchableFrontmatter(page.fm, page.pkg), path: page.relPath },
757
761
  });
758
762
  tableErrors.push(...errors);
759
763
  const captionScan = scanCaptions(markdown);
760
764
  ctx.captionLabels = new Map(captionScan.captions.map((caption) => [caption.id, caption.label]));
765
+ // This page's own anchors, so a `[[#slug]]` self-link is checked against
766
+ // what the page actually declares rather than trusted unconditionally.
767
+ ctx.anchors = new Set(collectAnchors(markdown).map((anchor) => anchor.slug));
761
768
  const expressions = renderMarkdownExpressions(markdown, {
762
769
  fm: page.fm,
763
770
  dates: index.dateContext,
@@ -767,9 +774,8 @@ export function renderSitePage(
767
774
  });
768
775
  expressionErrors.push(...expressions.findings);
769
776
  const data = pageFrontmatter(page, { decorate, webSrc, artSrc });
770
- const secrets = renderSecretBlocks(protectCode(expressions.markdown, resolve), "web");
771
- const admonitions = renderAdmonitions(secrets.markdown);
772
- const captioned = renderCaptionBlocks(admonitions.markdown);
777
+ const blocks = renderBlocks(protectCode(expressions.markdown, resolve), "web");
778
+ const captioned = renderCaptionBlocks(blocks.markdown);
773
779
  for (const error of captionScan.errors)
774
780
  captionErrors.push({
775
781
  file: page.file,
@@ -777,15 +783,19 @@ export function renderSitePage(
777
783
  column: error.column,
778
784
  message: error.message,
779
785
  });
780
- for (const error of renderSecretBlocks(page.body, "book").errors)
786
+ for (const error of scanBlocks(page.body).errors)
781
787
  secretErrors.push({
782
788
  file: page.file,
783
789
  line: (page.bodyLine ?? 1) + error.line - 1,
784
790
  column: error.column,
785
791
  message: error.message,
786
792
  });
787
- for (const error of scanAdmonitions(page.body).errors)
788
- secretErrors.push({
793
+ for (const error of scanHeadingAttributes(page.body, page.bodyLine ?? 1).errors)
794
+ headingErrors.push({ file: page.file, ...error });
795
+ for (const error of withheldSections(page.body, page.bodyLine ?? 1).errors)
796
+ headingErrors.push({ file: page.file, ...error });
797
+ for (const error of footnoteFindings(page.body))
798
+ footnoteErrors.push({
789
799
  file: page.file,
790
800
  line: (page.bodyLine ?? 1) + error.line - 1,
791
801
  column: error.column,
@@ -793,13 +803,18 @@ export function renderSitePage(
793
803
  });
794
804
  return {
795
805
  page,
796
- body: captioned.markdown,
806
+ // The disclosure is written last, over the Markdown the page ships: the
807
+ // passes before this one carry line positions into their findings, and a
808
+ // line inserted ahead of them would move every one of them.
809
+ body: renderWithheldSections(captioned.markdown),
797
810
  data,
798
811
  resolved,
799
812
  tableErrors,
800
813
  expressionErrors,
801
814
  secretErrors,
802
815
  captionErrors,
816
+ headingErrors,
817
+ footnoteErrors,
803
818
  wikiErrors,
804
819
  imageErrors,
805
820
  };
@@ -877,6 +892,8 @@ export function renderPages(pages, options) {
877
892
  const expressionErrors = [];
878
893
  const secretErrors = [];
879
894
  const captionErrors = [];
895
+ const headingErrors = [];
896
+ const footnoteErrors = [];
880
897
  const wikiErrors = [];
881
898
  const imageErrors = [];
882
899
  const byKind = {};
@@ -920,6 +937,8 @@ export function renderPages(pages, options) {
920
937
  expressionErrors.push(...result.expressionErrors);
921
938
  secretErrors.push(...result.secretErrors);
922
939
  captionErrors.push(...result.captionErrors);
940
+ headingErrors.push(...result.headingErrors);
941
+ footnoteErrors.push(...result.footnoteErrors);
923
942
  wikiErrors.push(...result.wikiErrors);
924
943
  imageErrors.push(...result.imageErrors);
925
944
  rendered.push({ page, body: result.body, data: result.data });
@@ -963,6 +982,8 @@ export function renderPages(pages, options) {
963
982
  expressionErrors,
964
983
  secretErrors,
965
984
  captionErrors,
985
+ headingErrors,
986
+ footnoteErrors,
966
987
  wikiErrors,
967
988
  imageErrors,
968
989
  related,
@@ -1131,6 +1152,8 @@ export function buildSite({ config, sqlTables } = {}) {
1131
1152
  expressionErrors: [],
1132
1153
  secretErrors: [],
1133
1154
  captionErrors: [],
1155
+ headingErrors: [],
1156
+ footnoteErrors: [],
1134
1157
  wikiErrors: [],
1135
1158
  imageErrors: [],
1136
1159
  mapFindings: [],
@@ -1153,6 +1176,8 @@ export function buildSite({ config, sqlTables } = {}) {
1153
1176
  expressionErrors: [],
1154
1177
  secretErrors: [],
1155
1178
  captionErrors: [],
1179
+ headingErrors: [],
1180
+ footnoteErrors: [],
1156
1181
  wikiErrors: [],
1157
1182
  imageErrors: [],
1158
1183
  mapFindings: [],
@@ -1201,6 +1226,8 @@ export function buildSite({ config, sqlTables } = {}) {
1201
1226
  expressionErrors: [],
1202
1227
  secretErrors: [],
1203
1228
  captionErrors: [],
1229
+ headingErrors: [],
1230
+ footnoteErrors: [],
1204
1231
  wikiErrors: [],
1205
1232
  imageErrors: [],
1206
1233
  mapFindings: [],
@@ -1259,6 +1286,8 @@ export function buildSite({ config, sqlTables } = {}) {
1259
1286
  expressionErrors: rendered.expressionErrors,
1260
1287
  secretErrors: rendered.secretErrors,
1261
1288
  captionErrors: rendered.captionErrors,
1289
+ headingErrors: rendered.headingErrors,
1290
+ footnoteErrors: rendered.footnoteErrors,
1262
1291
  wikiErrors: rendered.wikiErrors,
1263
1292
  imageErrors: rendered.imageErrors,
1264
1293
  mapFindings: drawn.findings,
@@ -102,9 +102,18 @@ export const DISABLE_KINDS = Object.freeze(["section", "taxonomy", "term", "RSS"
102
102
  * `<span>` marking an unresolved link — and Goldmark drops raw HTML unless
103
103
  * told otherwise. A theme cannot supply this: Hugo does not merge a theme's
104
104
  * `markup` block.
105
+ *
106
+ * `extensions.taskList` is off because the format has no checkbox: Goldmark
107
+ * enables it by default, which is the only reason a list item opened with
108
+ * `[ ]` renders a `<input type="checkbox">` here and the same two characters as
109
+ * plain text in the pack and the book. Off, a list item's text begins right
110
+ * after its marker on every surface.
105
111
  */
106
112
  export const MARKUP = Object.freeze({
107
- goldmark: Object.freeze({ renderer: Object.freeze({ unsafe: true }) }),
113
+ goldmark: Object.freeze({
114
+ renderer: Object.freeze({ unsafe: true }),
115
+ extensions: Object.freeze({ taskList: false }),
116
+ }),
108
117
  });
109
118
 
110
119
  /** Where the navigation is published. */
@@ -51,6 +51,9 @@
51
51
 
52
52
  import { buildReferenceTargets } from "./reference-targets.mjs";
53
53
  import { standingsDigest } from "./standings.mjs";
54
+ // The leaf reader, shared with the content index and the link checker, so a
55
+ // page's anchors are read once rather than re-derived a third way here.
56
+ import { collectAnchors } from "./anchors.mjs";
54
57
 
55
58
  import { resolveShortcodeReference } from "./shortcode-references.mjs";
56
59
 
@@ -83,16 +86,24 @@ import { isDraftNote } from "./note-vocabulary.mjs";
83
86
  * @property {string} slug URL segment.
84
87
  * @property {string} base Source file's basename, e.g. `Climbing.md`.
85
88
  * @property {string} url The page's published address.
89
+ * @property {string} [body] The note's markdown body, for its anchors. A
90
+ * content entry always carries one; absent, the
91
+ * page is indexed as declaring none.
86
92
  */
87
93
 
88
94
  /**
89
95
  * The resolved index and everything a wikilink resolver reads beside it.
90
96
  *
91
97
  * @typedef {object} SiteIndex
92
- * @property {Map<string, {url: string, name?: string, draft?: boolean}>} index
98
+ * @property {Map<string, {url: string, name?: string, draft?: boolean, anchors?: Set<string>|Record<string, string>}>} index
93
99
  * Address → page. `draft` says the page
94
100
  * carries the `draft` tag, which marks a
95
- * link *into* it.
101
+ * link *into* it. `anchors` names the
102
+ * `{#slug}` sections it declares — a
103
+ * `Set` for a local page, or the
104
+ * `{slug: uuid}` map a foreign manifest
105
+ * publishes — and is absent where
106
+ * neither build recorded one.
96
107
  * @property {Set<string>} ambiguous Short addresses claimed by two
97
108
  * packages, and so deliberately absent
98
109
  * from `index`.
@@ -229,6 +240,11 @@ export function buildSiteIndex(
229
240
  draft: isDraftNote(e.fm),
230
241
  ...(e.fm.subType ? { subType: e.fm.subType } : {}),
231
242
  ...(standings ? { standings } : {}),
243
+ // The `{#anchor}` slugs this page declares, so a `#section`
244
+ // written against it can be checked rather than joined onto the
245
+ // URL unverified. A Set even when empty: a page that declares no
246
+ // anchor still has a definite answer for one an author names.
247
+ anchors: new Set(collectAnchors(e.body ?? "").map((anchor) => anchor.slug)),
232
248
  };
233
249
 
234
250
  const shortcode = e.fm.shortcode;
@@ -18,6 +18,7 @@ import { reckoningContext } from "./reckoning-markers.mjs";
18
18
  import { cachedMetadataIndexes, noContentIndexPackages } from "./metadata-index.mjs";
19
19
  import { buildSiteIndex } from "./site-index.mjs";
20
20
  import { openNotesDatabase, prepareSqlTables, findSqlBlocks } from "./sql-tables.mjs";
21
+ import { findPageListBlocks } from "./page-lists.mjs";
21
22
  import { isGmNote } from "./note-vocabulary.mjs";
22
23
  import { relatedPages } from "./related-pages.mjs";
23
24
  import { holdingsNode, holdingsPages, foreignHoldingsNodes } from "./holdings.mjs";
@@ -90,9 +91,13 @@ export async function prepareSitePreview({ config = loadPackConfig() } = {}) {
90
91
  );
91
92
  try {
92
93
  const sources = pages
93
- .filter((page) => findSqlBlocks(page.body).length)
94
+ .filter(
95
+ (page) =>
96
+ findSqlBlocks(page.body).length || findPageListBlocks(page.body).length,
97
+ )
94
98
  .map((page) => ({ source: page.file, markdown: page.body }));
95
- const sqlTables = await prepareSqlTables(db, sources);
99
+ const publicRecords = records.filter((record) => !isGmNote(record));
100
+ const sqlTables = await prepareSqlTables(db, sources, { records: publicRecords });
96
101
  const rendered = renderPages(pages, {
97
102
  index: gates.index,
98
103
  foreign: gates.foreign,
@@ -214,9 +219,11 @@ export async function prepareSitePreview({ config = loadPackConfig() } = {}) {
214
219
  },
215
220
  );
216
221
  }
217
- const sqlTables = await prepareSqlTables(db, [
218
- { source: absolute, markdown: body },
219
- ]);
222
+ const sqlTables = await prepareSqlTables(
223
+ db,
224
+ [{ source: absolute, markdown: body }],
225
+ { records: records.filter((record) => !isGmNote(record)) },
226
+ );
220
227
  const allPages = snapshot.pages.map((item) =>
221
228
  item.file === absolute ? page : item,
222
229
  );
@@ -246,6 +253,10 @@ export async function prepareSitePreview({ config = loadPackConfig() } = {}) {
246
253
  findings.push({ ...error, severity: "error" });
247
254
  for (const error of result.captionErrors)
248
255
  findings.push({ ...error, severity: "error" });
256
+ for (const error of result.headingErrors)
257
+ findings.push({ ...error, severity: "error" });
258
+ for (const error of result.footnoteErrors)
259
+ findings.push({ ...error, severity: "error" });
249
260
  for (const error of result.wikiErrors) {
250
261
  const pos = positionOfLiteral(text, error.link, error.occurrence);
251
262
  findings.push({
@@ -265,7 +276,7 @@ export async function prepareSitePreview({ config = loadPackConfig() } = {}) {
265
276
  const entries = new Map(snapshot.rendered.entries);
266
277
  entries.delete(original.url);
267
278
  entries.set(page.url, {
268
- title: fm.title ?? page.name,
279
+ title: page.name,
269
280
  url: page.url,
270
281
  type: String(fm.type),
271
282
  });
@@ -273,7 +284,7 @@ export async function prepareSitePreview({ config = loadPackConfig() } = {}) {
273
284
  if (related) result.data.related = related;
274
285
  const holdings = holdingsPages([
275
286
  ...allPages.map((item) =>
276
- holdingsNode(item.fm, { title: item.fm.title ?? item.name, url: item.url }),
287
+ holdingsNode(item.fm, { title: item.name, url: item.url }),
277
288
  ),
278
289
  ...foreignHoldingsNodes(snapshot.gates.foreign.index),
279
290
  ]);