@heroiclands/package-build 22.20.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.
- package/CHANGELOG-content-build.md +0 -8
- package/CHANGELOG.md +204 -0
- package/assets/styles/content-base.css +162 -0
- package/bin/content-commands.mjs +26 -3
- package/bin/package-build.mjs +15 -2
- package/ci/content-format-reference.mjs +68 -1
- package/config.mjs +26 -0
- package/content-config.mjs +73 -4
- package/docs/api.md +46 -28
- package/docs/authoring/dates-and-calendars.md +4 -2
- package/docs/authoring/frontmatter.md +2 -2
- package/docs/authoring/links-and-markup.md +237 -28
- package/docs/configuration.md +27 -1
- package/docs/content-format.md +3 -1
- package/docs/guides/reference-pages.md +15 -0
- package/docs/reference/format-details.md +265 -42
- package/docs/reference/note-types.md +45 -28
- package/engine/actor-compiler.mjs +23 -0
- package/engine/anchored-sections.mjs +35 -4
- package/engine/anchors.mjs +10 -20
- package/engine/base-compiler.mjs +86 -21
- package/engine/base-styles.mjs +149 -0
- package/engine/bundles.mjs +0 -1
- package/engine/compendiums.mjs +8 -1
- package/engine/content-blocks.mjs +410 -0
- package/engine/content-captions.mjs +57 -8
- package/engine/content-footnotes.mjs +189 -0
- package/engine/content-format-check.mjs +15 -12
- package/engine/content-index.mjs +2 -2
- package/engine/content-links.mjs +14 -6
- package/engine/content-tables.mjs +45 -4
- package/engine/content-tasklists.mjs +189 -0
- package/engine/docs-checks.mjs +4 -4
- package/engine/extension-attributes.mjs +48 -2
- package/engine/frontmatter-lint.mjs +62 -66
- package/engine/frontmatter.mjs +3 -3
- package/engine/heading-attributes.mjs +308 -0
- package/engine/helpers.mjs +19 -13
- package/engine/homepage.mjs +8 -8
- package/engine/index.mjs +3 -0
- package/engine/infobox-render.mjs +21 -4
- package/engine/infobox.mjs +167 -7
- package/engine/journals.mjs +36 -17
- package/engine/macros.mjs +1 -1
- package/engine/markdown-expressions.mjs +178 -53
- package/engine/note-addresses.mjs +17 -8
- package/engine/note-dates.mjs +49 -0
- package/engine/note-frontmatter.mjs +7 -16
- package/engine/note-vocabulary.mjs +216 -42
- package/engine/page-lists.mjs +358 -0
- package/engine/pdf-build.mjs +76 -42
- package/engine/pdf-render.mjs +422 -49
- package/engine/rank-ladder.mjs +198 -0
- package/engine/reckoning-markers.mjs +1 -1
- package/engine/reference-targets.mjs +13 -1
- package/engine/retired-fields.mjs +8 -13
- package/engine/scenes.mjs +10 -3
- package/engine/site-build.mjs +41 -12
- package/engine/site-config.mjs +10 -1
- package/engine/site-index.mjs +39 -4
- package/engine/site-preview.mjs +18 -7
- package/engine/social-ties.mjs +52 -14
- package/engine/sql-tables.mjs +66 -9
- package/engine/standing-terms.mjs +19 -0
- package/engine/standings.mjs +538 -0
- package/engine/svg-theme.mjs +15 -4
- package/engine/system-block.mjs +12 -14
- package/engine/web-wikilinks.mjs +48 -1
- package/engine/wikilinks.mjs +3 -1
- package/hm3/actors.mjs +6 -3
- package/manifest.mjs +13 -0
- package/package.json +1 -1
- package/sohl/actors.mjs +82 -103
- package/sohl/item-fields.mjs +4 -5
- package/sohl/items.mjs +1 -1
- package/sohl/note-schemas.mjs +11 -21
- package/types/config.d.mts +9 -0
- package/types/engine/actor-compiler.d.mts +11 -0
- package/types/engine/anchored-sections.d.mts +24 -0
- package/types/engine/base-compiler.d.mts +19 -0
- package/types/engine/base-styles.d.mts +93 -0
- package/types/engine/content-blocks.d.mts +87 -0
- package/types/engine/content-captions.d.mts +14 -1
- package/types/engine/content-footnotes.d.mts +73 -0
- package/types/engine/content-format-check.d.mts +8 -6
- package/types/engine/content-tables.d.mts +12 -4
- package/types/engine/content-tasklists.d.mts +72 -0
- package/types/engine/extension-attributes.d.mts +22 -1
- package/types/engine/frontmatter.d.mts +1 -1
- package/types/engine/heading-attributes.d.mts +174 -0
- package/types/engine/helpers.d.mts +7 -4
- package/types/engine/homepage.d.mts +7 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/infobox-render.d.mts +6 -1
- package/types/engine/journals.d.mts +14 -7
- package/types/engine/markdown-expressions.d.mts +23 -0
- package/types/engine/note-frontmatter.d.mts +1 -4
- package/types/engine/note-vocabulary.d.mts +98 -8
- package/types/engine/page-lists.d.mts +126 -0
- package/types/engine/pdf-render.d.mts +72 -2
- package/types/engine/rank-ladder.d.mts +32 -0
- package/types/engine/retired-fields.d.mts +4 -6
- package/types/engine/site-build.d.mts +13 -1
- package/types/engine/site-config.d.mts +9 -0
- package/types/engine/site-index.d.mts +13 -1
- package/types/engine/site-preview.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +31 -7
- package/types/engine/standing-terms.d.mts +16 -0
- package/types/engine/standings.d.mts +147 -0
- package/types/engine/system-block.d.mts +11 -12
- package/types/engine/web-wikilinks.d.mts +3 -1
- package/types/engine/wikilinks.d.mts +2 -1
- package/types/hm3/actors.d.mts +3 -3
- package/types/sohl/actors.d.mts +25 -7
- package/engine/content-admonitions.mjs +0 -114
- package/engine/content-secrets.mjs +0 -112
- package/types/engine/content-admonitions.d.mts +0 -24
- 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
|
+
}
|
package/engine/pdf-build.mjs
CHANGED
|
@@ -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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
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
|
-
|
|
614
|
-
|
|
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({
|