@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,358 @@
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
+ * Page lists: a tag in, the pages carrying it out.
16
+ *
17
+ * ````markdown
18
+ * ```pagelist {tag="key-concept" descriptions=true}
19
+ * ```
20
+ * ````
21
+ *
22
+ * **A group expressed by intent.** A hand-enumerated list of links drifts — a
23
+ * page is written and nobody adds it to the landing that should name it. A page
24
+ * list names the *tag* instead: tag a page and it files itself, untag it and it
25
+ * leaves.
26
+ *
27
+ * **It is a sibling of the `sql` content table, and answered the same way.**
28
+ * The pages are resolved over the content index ahead of the synchronous
29
+ * expansion pass, then
30
+ * {@link module:engine/content-tables.expandContentTables} splices the result
31
+ * in as ordinary Markdown — a bullet list of wikilinks. So each surface's own
32
+ * wikilink resolver turns them into what that surface links with: a compendium
33
+ * UUID in Foundry, a page URL on the web and in the book. One authored
34
+ * construct, three real lists.
35
+ *
36
+ * **Attributes, never a body.** Everything the directive says about itself is
37
+ * said in the braced attribute grammar every other body extension uses, so
38
+ * there is one grammar to learn and one validator to maintain. The fence holds
39
+ * nothing, and text written inside it is reported rather than ignored.
40
+ *
41
+ * @module
42
+ */
43
+
44
+ import { FENCE_LINE, parseHeaderArgs } from "./code-fences.mjs";
45
+ import { booleanAttribute } from "./extension-attributes.mjs";
46
+ import { isNoteRecord } from "./index-records.mjs";
47
+ import { NOTE_VOCABULARY, hasTag } from "./note-vocabulary.mjs";
48
+
49
+ /** The fence language that opens a page list. */
50
+ export const PAGE_LIST_LANGUAGE = "pagelist";
51
+
52
+ /**
53
+ * The orders a page list may come out in.
54
+ *
55
+ * Ordering is about output rather than about the pages, so it is an attribute
56
+ * rather than something read off a note. `name` is alphabetical, case and
57
+ * accents folded; `type` groups the note types and orders by name inside each.
58
+ *
59
+ * @type {readonly string[]}
60
+ */
61
+ export const PAGE_LIST_SORTS = Object.freeze(["name", "type"]);
62
+
63
+ /**
64
+ * The attributes a page-list fence takes, with what each one is for.
65
+ *
66
+ * **This is the one list.** The fence is validated against it, the authoring
67
+ * document's table is generated from it, and the reference is checked against
68
+ * it — so an attribute added here is one both documents describe, and anything
69
+ * else an author writes is reported by name.
70
+ *
71
+ * @type {Readonly<Record<string, Readonly<{value: string, default: string, summary: string}>>>}
72
+ */
73
+ export const PAGE_LIST_ATTRIBUTES = Object.freeze({
74
+ tag: Object.freeze({
75
+ value: "a tag",
76
+ default: "required",
77
+ summary:
78
+ "The tag a page carries to join the list. Matched the way `tags:` is read — " +
79
+ "case and a leading `#` are not significant, the spelling of the tag itself is.",
80
+ }),
81
+ type: Object.freeze({
82
+ value: "a note type",
83
+ default: "every type",
84
+ summary:
85
+ "Restricts the list to one note type. A page's address is `<type>-<shortcode>`, " +
86
+ "so the type is what divides the corpus into kinds of page.",
87
+ }),
88
+ sort: Object.freeze({
89
+ value: "`name` or `type`",
90
+ default: "`name`",
91
+ summary:
92
+ "The order the pages come out in. `name` is alphabetical; `type` groups the " +
93
+ "note types and orders by name within each.",
94
+ }),
95
+ descriptions: Object.freeze({
96
+ value: "`true` or `false`",
97
+ default: "`false`",
98
+ summary:
99
+ "Whether each page's `description` follows its link. A page that states none " +
100
+ "carries its link alone.",
101
+ }),
102
+ "allow-empty": Object.freeze({
103
+ value: "`true` or `false`",
104
+ default: "`false`",
105
+ summary:
106
+ "Whether a tag carried by no page is allowed. It is a finding otherwise, " +
107
+ "because a heading with nothing under it is how a misspelled tag goes unnoticed.",
108
+ }),
109
+ });
110
+
111
+ /** The note types a `type` attribute may name, from the vocabulary itself. */
112
+ const noteTypes = () => Object.keys(NOTE_VOCABULARY);
113
+
114
+ /**
115
+ * Every `pagelist` fence in a markdown body, with the position each occupies.
116
+ *
117
+ * Positions are 0-based lines into the body as given, which is what a
118
+ * diagnostic about a directive needs and what the expander uses to splice
119
+ * results back in.
120
+ *
121
+ * **A malformed directive is still a directive.** It is returned carrying its
122
+ * problems rather than skipped, so the expander finds a prepared answer at its
123
+ * ordinal and reports the fault where the fence sits. That includes a fence
124
+ * nobody closed, which the author meant as a directive and which would
125
+ * otherwise swallow the rest of the note into a code block.
126
+ *
127
+ * @param {string} markdown - The note body, frontmatter already stripped.
128
+ * @returns {Array<{line: number, close: number, indent: string, tag: string,
129
+ * type: string, sort: string, descriptions: boolean, allowEmpty: boolean,
130
+ * problems: string[], block: string}>} One entry per fence, in document order.
131
+ */
132
+ export function findPageListBlocks(markdown) {
133
+ const lines = String(markdown ?? "").split("\n");
134
+ const blocks = [];
135
+ for (let i = 0; i < lines.length; i += 1) {
136
+ const opening = FENCE_LINE.exec(lines[i]);
137
+ if (!opening) continue;
138
+ const [, indent, marker, info] = opening;
139
+ const closer = new RegExp(`^[ \\t]*${marker[0]}{${marker.length},}[ \\t]*$`);
140
+ let close = i + 1;
141
+ while (close < lines.length && !closer.test(lines[close])) close += 1;
142
+ const { language, args, problems } = parseHeaderArgs(info);
143
+ if (language !== PAGE_LIST_LANGUAGE) {
144
+ // Not ours, but still a fence: skip its body so a directive shown
145
+ // as an example inside a longer fence is never read as one.
146
+ i = close;
147
+ continue;
148
+ }
149
+ const errors = [...problems];
150
+ for (const key of Object.keys(args)) {
151
+ if (!Object.hasOwn(PAGE_LIST_ATTRIBUTES, key))
152
+ errors.push(`${key} is not a page-list attribute`);
153
+ }
154
+ const tag = String(args.tag ?? "").trim();
155
+ if (!tag) errors.push("a pagelist fence needs a tag attribute");
156
+ const type = String(args.type ?? "").trim();
157
+ if (type && !noteTypes().includes(type))
158
+ errors.push(`type ${JSON.stringify(type)} is not a note type`);
159
+ const sort = String(args.sort ?? PAGE_LIST_SORTS[0]).trim();
160
+ if (!PAGE_LIST_SORTS.includes(sort))
161
+ errors.push(`sort needs ${PAGE_LIST_SORTS.join(" or ")}`);
162
+ const flags = { descriptions: false, allowEmpty: false };
163
+ for (const [key, field] of [
164
+ ["descriptions", "descriptions"],
165
+ ["allow-empty", "allowEmpty"],
166
+ ]) {
167
+ if (!Object.hasOwn(args, key)) continue;
168
+ try {
169
+ flags[field] = booleanAttribute(args[key], key);
170
+ } catch (error) {
171
+ errors.push(error.message);
172
+ }
173
+ }
174
+ if (close >= lines.length) errors.push("a pagelist fence needs a closing fence");
175
+ else if (
176
+ lines
177
+ .slice(i + 1, close)
178
+ .join("\n")
179
+ .trim()
180
+ )
181
+ errors.push("a pagelist fence takes no body; its tag is the tag attribute");
182
+ blocks.push({
183
+ line: i,
184
+ close,
185
+ indent,
186
+ tag,
187
+ type,
188
+ sort,
189
+ ...flags,
190
+ problems: errors,
191
+ block: lines.slice(i, Math.min(close + 1, lines.length)).join("\n"),
192
+ });
193
+ i = close;
194
+ }
195
+ return blocks;
196
+ }
197
+
198
+ /** A page's sort key, folded so a circumflex sorts with its letter. */
199
+ const nameKey = (record) => String(record?.nameAscii ?? record?.name?.full ?? "").toLowerCase();
200
+
201
+ /** The address a page is addressed by, as a stable tiebreak. */
202
+ const slugKey = (record) => String(record?.address?.slug ?? "");
203
+
204
+ /**
205
+ * By name, tiebroken on address.
206
+ *
207
+ * The tiebreak keeps the list stable where two pages share a name, which a
208
+ * corpus of a few thousand eventually does.
209
+ */
210
+ function byName(a, b) {
211
+ const an = nameKey(a);
212
+ const bn = nameKey(b);
213
+ if (an !== bn) return an < bn ? -1 : 1;
214
+ const as = slugKey(a);
215
+ const bs = slugKey(b);
216
+ return (
217
+ as < bs ? -1
218
+ : as > bs ? 1
219
+ : 0
220
+ );
221
+ }
222
+
223
+ /** By note type, then by name within it. */
224
+ function byType(a, b) {
225
+ const at = String(a?.type ?? "");
226
+ const bt = String(b?.type ?? "");
227
+ if (at !== bt) return at < bt ? -1 : 1;
228
+ return byName(a, b);
229
+ }
230
+
231
+ const COMPARATORS = Object.freeze({ name: byName, type: byType });
232
+
233
+ /** A link label, with the characters a wikilink reads replaced. */
234
+ function linkLabel(record) {
235
+ const written = String(record?.name?.full ?? record?.shortcode ?? "")
236
+ // A wikilink's own separator, so a name carrying one would truncate
237
+ // the label at it.
238
+ .replace(/\|/g, "/")
239
+ .replace(/[\r\n]+/g, " ")
240
+ .trim();
241
+ return written || slugKey(record);
242
+ }
243
+
244
+ /** A description on one line, so a list item stays a list item. */
245
+ const oneLine = (value) => (typeof value === "string" ? value.replace(/[\r\n]+/g, " ").trim() : "");
246
+
247
+ /**
248
+ * The pages a directive selects, as index records.
249
+ *
250
+ * A **stub** is left out: an empty body publishes no page, which the index says
251
+ * by holding no address, so there is nothing for the list to link to. A
252
+ * documentation journal and an asset are not pages an author tagged, which
253
+ * {@link module:engine/index-records.isNoteRecord} already answers.
254
+ *
255
+ * @param {object[]} records - Content-index records.
256
+ * @param {{tag: string, type: string}} directive - From {@link findPageListBlocks}.
257
+ * @returns {object[]} The selected records, unordered.
258
+ */
259
+ export function pagesForList(records, { tag, type }) {
260
+ return (records ?? []).filter((record) => {
261
+ if (!isNoteRecord(record) || !record.address) return false;
262
+ if (type && record.type !== type) return false;
263
+ return hasTag(record, tag);
264
+ });
265
+ }
266
+
267
+ /**
268
+ * Render one directive's answer as markdown.
269
+ *
270
+ * **The emitted link is the page's `address.slug`** — the `type-shortcode`
271
+ * form a wikilink resolves — which is exactly what a table's `_ref` cell
272
+ * writes. A short form names the package the link is written in, so it is
273
+ * unambiguous for a list of this package's own pages, and it resolves to the
274
+ * note's **page** on each surface: a compendium journal in Foundry, a URL on
275
+ * the web and in the book. An item note's page is its documentation journal,
276
+ * and the short form reaches it the way prose does.
277
+ *
278
+ * @param {object[]} records - Content-index records.
279
+ * @param {object} directive - From {@link findPageListBlocks}.
280
+ * @returns {{markdown: string, pages: number}} The list, and how many pages
281
+ * are in it.
282
+ */
283
+ export function renderPageList(records, directive) {
284
+ const selected = pagesForList(records, directive).sort(
285
+ COMPARATORS[directive.sort] ?? COMPARATORS.name,
286
+ );
287
+ const rows = selected.map((record) => {
288
+ const link = `- [[${slugKey(record)}|${linkLabel(record)}]]`;
289
+ if (!directive.descriptions) return link;
290
+ const summary = oneLine(record.description);
291
+ return summary ? `${link} — ${summary}` : link;
292
+ });
293
+ return { markdown: rows.join("\n"), pages: selected.length };
294
+ }
295
+
296
+ /**
297
+ * What the expander prints where a tag selects nothing.
298
+ *
299
+ * The tag is quoted, because a misspelled tag is the whole of what goes wrong
300
+ * here and a reader needs to see the exact spelling the directive asked for.
301
+ */
302
+ function emptyMessage({ tag, type }) {
303
+ const scope = type ? ` of type ${JSON.stringify(type)}` : "";
304
+ return (
305
+ `page list selects no page; no page${scope} carries the tag ${JSON.stringify(tag)}. ` +
306
+ "Check the spelling, tag some pages, or state {allow-empty=true}"
307
+ );
308
+ }
309
+
310
+ /**
311
+ * Answer every `pagelist` directive in a set of note bodies, ahead of expansion.
312
+ *
313
+ * **Why a separate pass.** The same reason the SQL tables have one:
314
+ * {@link module:engine/content-tables.expandContentTables} is synchronous and
315
+ * exported, and the corpus is enumerated once by whoever already holds it
316
+ * rather than derived again inside a renderer. A page list needs no database —
317
+ * it is a filter over the records — so preparing one costs a walk of what the
318
+ * caller already has.
319
+ *
320
+ * **Keyed by note, then by the directive's ordinal within it**, not by its
321
+ * line: the passes do not agree on what a body is, and trim it differently,
322
+ * but a directive's position in the sequence of fences is the same in each.
323
+ *
324
+ * A malformed directive is recorded rather than thrown, so one bad fence costs
325
+ * its own list and not the whole build's report.
326
+ *
327
+ * @param {object[]} records - Content-index records, already narrowed to the
328
+ * audience the surface publishes to.
329
+ * @param {Array<{source: string, markdown: string}>} sources - The bodies to scan.
330
+ * @returns {Map<string, Array<{markdown?: string, pages?: number, empty?: string,
331
+ * reason?: string, allowEmpty: boolean}>>} Note to results, in document order.
332
+ */
333
+ export function preparePageLists(records, sources) {
334
+ const prepared = new Map();
335
+ for (const { source, markdown } of sources ?? []) {
336
+ const blocks = findPageListBlocks(markdown);
337
+ if (!blocks.length) continue;
338
+ const forNote = [];
339
+ prepared.set(source, forNote);
340
+ for (const block of blocks) {
341
+ if (block.problems.length) {
342
+ forNote.push({
343
+ reason: block.problems.join("; "),
344
+ allowEmpty: block.allowEmpty,
345
+ });
346
+ continue;
347
+ }
348
+ const { markdown: rendered, pages } = renderPageList(records, block);
349
+ forNote.push({
350
+ markdown: rendered,
351
+ pages,
352
+ empty: emptyMessage(block),
353
+ allowEmpty: block.allowEmpty,
354
+ });
355
+ }
356
+ }
357
+ return prepared;
358
+ }
@@ -97,12 +97,12 @@ import {
97
97
  import { resolveInfoboxRef, wikiContext } from "./site-index.mjs";
98
98
  import { resolveWebWikilinks } from "./web-wikilinks.mjs";
99
99
  import { linkFindingMessage } from "./wikilink-syntax.mjs";
100
+ import { positionOfLiteral } from "./diagnostics.mjs";
100
101
  import { artPathname, assetAddressIndex } from "./art-fields.mjs";
101
102
  import { expandContentTables } from "./content-tables.mjs";
102
103
  import { renderMarkdownExpressions } from "./markdown-expressions.mjs";
103
- import { renderSecretBlocks } from "./content-secrets.mjs";
104
- import { scanAdmonitions } from "./content-admonitions.mjs";
105
104
  import { numberCaptions, scanCaptions } from "./content-captions.mjs";
105
+ import { collectAnchors } from "./anchors.mjs";
106
106
  import { protectCode } from "./code-fences.mjs";
107
107
  import { imagesIn, parseImageDirective } from "./content-images.mjs";
108
108
  import {
@@ -378,6 +378,23 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
378
378
  // addresses collide would print the wrong entry under the right name.
379
379
  const scheme = resolved.publish.address;
380
380
  const base = resolved.site.base || `/${resolved.contentPackage}/`;
381
+ // Where the pages this book cites are served. A page's address is written
382
+ // from the root of a site, which is right everywhere on the web and nowhere
383
+ // in a PDF, so the book resolves each one against this — see
384
+ // {@link module:engine/pdf-render.absolutePageUrl}. `package.json`'s
385
+ // `homepage` is the one absolute address a build holds, and the site reads
386
+ // its own `baseURL` from it, so the book and the website cannot disagree
387
+ // about where a page is.
388
+ const site = typeof resolved.homepage === "string" ? resolved.homepage : "";
389
+ if (!site) {
390
+ findings.push({
391
+ file: "package.json",
392
+ severity: "error",
393
+ message:
394
+ "`homepage` is not declared, so a page the book cites is set as a path " +
395
+ `rather than an address a reader can follow — add \`https://www.heroiclands.org/${resolved.contentPackage}/\``,
396
+ });
397
+ }
381
398
  const ctx = {
382
399
  packages: new Set(
383
400
  resolved.site.packages.length ? resolved.site.packages : [resolved.contentPackage],
@@ -528,15 +545,11 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
528
545
  for (const file of resolved.pdf.front) {
529
546
  try {
530
547
  const scan = scanCaptions(fs.readFileSync(file, "utf8"));
548
+ // Numbered here, because a number runs across the whole book and only
549
+ // this loop knows the order; what is *wrong* with a caption is
550
+ // reported by the renderer, which reads every one of the three kinds
551
+ // of markdown a book is made of.
531
552
  frontCaptions.set(file, numberCaptions(scan.captions, captionCounts));
532
- for (const error of scan.errors)
533
- findings.push({
534
- file,
535
- line: error.line,
536
- column: error.column,
537
- severity: "error",
538
- message: error.message,
539
- });
540
553
  } catch {
541
554
  // The rendering pass reports an unreadable front-matter file.
542
555
  }
@@ -555,13 +568,21 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
555
568
  linkable: (d) => Boolean(d.fm.shortcode),
556
569
  source: src,
557
570
  sqlTables: sqlTables?.get(page.file),
571
+ pageLists: sqlTables?.pageLists?.get(page.file),
558
572
  self: { fm: page.fm, path: page.relPath },
559
573
  });
574
+ // A content-table finding states its `reason`, its 0-based line within
575
+ // the body and its column, exactly as the site build reports them.
576
+ // Reading it as `message` yields `[object Object]` and discards the
577
+ // position, which is the one thing a reader needs to find the fence.
560
578
  for (const err of errors) {
561
579
  findings.push({
562
580
  file: page.file,
581
+ ...(typeof err.line === "number" ?
582
+ { line: (page.bodyLine ?? 1) + err.line, column: err.column }
583
+ : {}),
563
584
  severity: "error",
564
- message: String(err.message ?? err),
585
+ message: err.reason ?? String(err.message ?? err),
565
586
  });
566
587
  }
567
588
  const linkCtx = wikiContext(gates.index, {
@@ -577,14 +598,10 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
577
598
  linkCtx.captionLabels = new Map(
578
599
  numberedCaptions.map((caption) => [caption.id, caption.label]),
579
600
  );
580
- for (const error of captionScan.errors)
581
- findings.push({
582
- file: page.file,
583
- line: (page.bodyLine ?? 1) + (lineMap[error.line - 1]?.line ?? error.line - 1),
584
- column: error.column,
585
- severity: "error",
586
- message: error.message,
587
- });
601
+ // This page's own anchors, so a `[[#slug]]` self-link is checked
602
+ // against what the page actually declares, exactly as the site build
603
+ // checks it.
604
+ linkCtx.anchors = new Set(collectAnchors(markdown).map((anchor) => anchor.slug));
588
605
  linkCtx.output = "book";
589
606
  // Code fences are protected for the same reason every other pass
590
607
  // protects them: a wikilink shown as an example is prose about a
@@ -600,37 +617,42 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
600
617
  const resolvedBody = protectCode(expressions.markdown, (text) =>
601
618
  resolveWebWikilinks(pass.beforeLinks ? pass.beforeLinks(text, page) : text, linkCtx),
602
619
  );
620
+ // A link finding carries the authored link and which occurrence of it
621
+ // this was, which is exactly what locates it: the position is implicit
622
+ // in the literal the pass matched, so it is recovered by searching the
623
+ // note for it rather than dropped. Read once per page, and only when
624
+ // there is a finding to locate.
625
+ let raw;
626
+ const noteSource = () => {
627
+ if (raw === undefined) {
628
+ try {
629
+ raw = fs.readFileSync(page.file, "utf8");
630
+ } catch {
631
+ raw = "";
632
+ }
633
+ }
634
+ return raw;
635
+ };
636
+ // An **error**, as it is from the pack build and from the site build. One
637
+ // authored address that resolves nowhere is one defect, and a reader of
638
+ // the book is the one reader who cannot act on it: a page printing
639
+ // `(unresolved link)` has shipped the mistake to somebody holding paper.
640
+ // The source is written and the compiler still runs, so the finding is a
641
+ // report on a book that was built, exactly as the site writes its pages
642
+ // and refuses the run.
603
643
  for (const err of wikiErrors) {
604
644
  findings.push({
605
645
  file: page.file,
606
- severity: "warning",
646
+ ...positionOfLiteral(noteSource(), err.link, err.occurrence),
647
+ severity: "error",
607
648
  // A link finding names a `reason` from the shared table and no
608
649
  // sentence of its own; an embed's directive complaint carries
609
650
  // the sentence instead.
610
651
  message: err.message ?? (err.reason ? linkFindingMessage(err) : String(err)),
611
652
  });
612
653
  }
613
- const secrets = renderSecretBlocks(resolvedBody, "book");
614
- for (const error of renderSecretBlocks(page.body, "book").errors) {
615
- findings.push({
616
- file: page.file,
617
- line: (page.bodyLine ?? 1) + error.line - 1,
618
- column: error.column,
619
- severity: "error",
620
- message: error.message,
621
- });
622
- }
623
- for (const error of scanAdmonitions(page.body).errors) {
624
- findings.push({
625
- file: page.file,
626
- line: (page.bodyLine ?? 1) + error.line - 1,
627
- column: error.column,
628
- severity: "error",
629
- message: error.message,
630
- });
631
- }
632
- stageImages(secrets.markdown, page.file, columns);
633
- const prose = markdownToTypst(secrets.markdown, {
654
+ stageImages(resolvedBody, page.file, columns);
655
+ const prose = markdownToTypst(resolvedBody, {
634
656
  md,
635
657
  links: plan.links,
636
658
  glyphs,
@@ -638,6 +660,12 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
638
660
  headingOffset,
639
661
  anchorPrefix,
640
662
  captions: numberedCaptions,
663
+ url: site,
664
+ findings,
665
+ file: page.file,
666
+ bodyLine: page.bodyLine,
667
+ lineMap,
668
+ prepared: true,
641
669
  });
642
670
  // Infoboxes follow the authored body within this note's entry.
643
671
  const boxes = noteInfoboxes(page.fm, {
@@ -646,7 +674,7 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
646
674
  dates: gates.index.dateContext,
647
675
  });
648
676
  const panel = infoboxesToTypst(boxes, {
649
- link: (value) => linkToTypst(value, plan.links, labelFor),
677
+ link: (value) => linkToTypst(value, plan.links, labelFor, site),
650
678
  });
651
679
  // Whether the entry is settled comes before anything it says: the
652
680
  // notice leads the leaf, before the authored body.
@@ -699,6 +727,9 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
699
727
  headingOffset: entry.depth,
700
728
  anchorPrefix: entry.anchor,
701
729
  captions: numberCaptions(scanCaptions(text).captions, captionCounts),
730
+ url: site,
731
+ findings,
732
+ file,
702
733
  }),
703
734
  );
704
735
  }
@@ -724,6 +755,9 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
724
755
  images,
725
756
  footnotePrefix: `footnote-front-${index + 1}`,
726
757
  captions: frontCaptions.get(file),
758
+ url: site,
759
+ findings,
760
+ file,
727
761
  });
728
762
  } catch {
729
763
  findings.push({